This commit is contained in:
lin onetwo 2026-05-05 16:06:44 +00:00 committed by GitHub
commit 9aa997eaab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 5441 additions and 814 deletions

View file

@ -32,13 +32,13 @@ jobs:
uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config.yml
queries: +./.github/codeql
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:javascript-typescript"

View file

@ -132,7 +132,7 @@ jobs:
# Upload analyzer reports and packaged apps as workflow artifacts (only linux x64)
- name: Upload analyzer reports
if: matrix.platform == 'linux' && matrix.arch == 'x64'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: analyzer-reports-${{ matrix.platform }}-${{ matrix.arch }}
path: |
@ -142,7 +142,7 @@ jobs:
- name: Upload packaged apps
if: (matrix.platform == 'mac' && matrix.arch == 'x64') || (matrix.platform == 'win' && matrix.arch == 'x64')
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: packaged-apps-${{ matrix.platform }}-${{ matrix.arch }}
path: out/make/**

View file

@ -55,8 +55,11 @@ jobs:
env:
CI: true
timeout-minutes: 5
- name: Run E2E tests
# E2E GUI tests with Electron on Linux require a virtual framebuffer, upgrade screen size from time to time.
# Calibration runs automatically via error-to-error-preflight.ts as part of test:e2e
# No workflow-level timeout - relies on calibration-based cucumber step timeouts
# Calibration preflight uses 250s timeout (10×), main tests use measured multiplier (capped at 5×)
run: xvfb-run --auto-servernum --server-args="-screen 0 2560x1440x24" pnpm run test:e2e
env:
CI: true
@ -64,7 +67,6 @@ jobs:
# Set Chinese locale for i18n testing
LANG: zh_CN.UTF-8
LC_ALL: zh_CN.UTF-8
timeout-minutes: 22
# Upload test artifacts (screenshots, logs)
- name: Upload test artifacts
@ -76,7 +78,7 @@ jobs:
echo "Cleaned up test-artifacts-ci cache folders"
continue-on-error: true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
if: always()
with:
name: test-artifacts-ci

2
.gitignore vendored
View file

@ -77,3 +77,5 @@ tsconfig.test.json.tsbuildinfo
/test-artifacts
/test-artifacts-ci
test-artifacts-ci.zip
cucumber-report.json
.codenomad/

View file

@ -1,10 +1,55 @@
# Privacy Policy
TidGi respects your privacy and does not track or log anything from you.
TidGi respects your privacy and is designed with privacy as a core principle.
Still, the app does use third party services that may collect information used to identify you.
## Data Collection
- G Suite for email and business tools.
- GitHub for distributing the software.
TidGi stores your notes and workspace data on your device and in your chosen storage locations (local folders or your own Git repositories). Anonymous product analytics are enabled by default when configured, but they are limited to coarse application behavior and never include your content.
This privacy policy is subject to change without notice and was last updated on 2021-02-27. If you have any questions feel free to create a GitHub issue.
### Optional Anonymous Usage Analytics
TidGi offers anonymous usage analytics to help improve the application. This feature is:
- **Enabled by default when configured** - TidGi shows a first-run disclosure and offers an immediate off switch
- **Anonymous** - No personal information, user content, workspace names, file paths, or identifiable data is collected
- **Coarse-grained** - Only high-level events like app launches, settings opens, sync outcomes, updater checks, and feature usage are tracked
- **Configurable** - You can point TidGi to your own self-hosted Rybbit instance
- **Transparent** - All tracked events are limited to application behavior, never your notes or personal content
When enabled, analytics may collect:
- Application launch events
- Workspace creation and activation (without names or paths)
- Preference changes related to analytics configuration
- Settings window opens, theme changes, deep-link opens, sync outcomes, and updater checks
- Platform and version information
Analytics **never** collect:
- Note content or titles
- Workspace names or file paths
- URLs or web browsing history
- Authentication tokens or API keys
- Any personally identifiable information (PII)
You can disable analytics at first launch or at any time in Preferences > Privacy & Security.
## Third-Party Services
The app may use third-party services that could collect information:
- **G Suite** - For email and business tools (developer communication only)
- **GitHub** - For distributing the software and hosting public repositories
- **Your chosen Git provider** - If you configure TidGi to sync with GitHub, GitLab, Gitee, or other Git services, your data is transmitted according to your configuration and that provider's privacy policy
## Your Control
You have full control over your data:
- All notes and content are stored locally or in your own Git repositories
- You choose which Git service (if any) to use for synchronization
- You can export your data at any time
- Analytics can be disabled at any time
## Changes to This Policy
This privacy policy is subject to change without notice and was last updated on 2026-04-29.
If you have any questions, feel free to create a GitHub issue at https://github.com/tiddly-gittly/TidGi-Desktop/issues.

123
docs/Analytics.md Normal file
View file

@ -0,0 +1,123 @@
# Analytics in TidGi Desktop
This document explains how analytics works in TidGi Desktop, what is intentionally tracked, what is intentionally blocked, and how plugin authors should integrate with the analytics service.
## Goals
TidGi uses analytics to understand coarse product usage without collecting user content.
The current design goals are:
- Keep all network delivery in the Electron main process
- Never expose the analytics API key to renderer or plugin code
- Track only coarse, product-level behavior
- Reject free-form content, note text, file paths, URLs, tokens, and other sensitive payloads
- Give plugin authors a stable way to emit custom product events without bypassing privacy guardrails
## Architecture
1. Renderer code and TiddlyWiki plugins call the TidGi analytics service through the existing IPC proxy layer
2. The analytics service runs in the main process
3. The main process sends events to Rybbit over HTTP
## Delivery model
- Analytics is enabled only when all of the following are true:
- `analyticsEnabled` preference is `true`
- `analyticsHost` is configured
- `analyticsSiteId` is configured
- a main-process-only analytics API key is configured
- Unsent events may be queued temporarily in memory
- The queue is dropped immediately when the user disables analytics
- The first-run disclosure is tracked separately from normal product events
## Privacy constraints
TidGi analytics must never contain:
- note titles or note bodies
- workspace names
- filesystem paths
- raw URLs
- access tokens, OAuth codes, cookies, or API keys
- free-form user text copied from the UI
If a proposed event depends on any of the above, do not add it to analytics.
## Built-in events
The application currently emits built-in events such as:
- `app.launched`
- `analytics.disclosure_dismissed`
- `workspace.created`
- `workspace.activated`
- `preferences.analytics_updated`
- `settings.opened`
- `theme.changed`
- `deep_link.opened`
- `sync.triggered`
- `sync.completed`
- `sync.failed`
- `updater.check_started`
- `updater.update_available`
- `updater.update_not_available`
- `updater.check_failed`
- `error.report_requested`
Built-in events use an allowlist. For each built-in event, only explicitly approved property keys are retained.
That allowlist lives in `src/services/analytics/index.ts`.
## Plugin-defined custom events
Plugin authors must not emit arbitrary event names through the low-level built-in event contract.
Instead, plugins should call:
```ts
await window.service.analytics.trackPluginEvent(pluginId, eventName, properties);
```
or inside a TiddlyWiki plugin:
```ts
await $tw.tidgi.service.analytics.trackPluginEvent(pluginId, eventName, properties);
```
### Final event name format
The service converts plugin calls into this final event name:
```text
plugin.<pluginId>.<eventName>
```
Example:
```ts
await window.service.analytics.trackPluginEvent('kanban-board', 'card_created', {
source: 'toolbar',
has_due_date: true,
});
```
Emits:
```text
plugin.kanban-board.card_created
```
### Validation rules
Plugin event names are intentionally restricted.
- `pluginId` must match: `^[a-z0-9]+(?:[-_][a-z0-9]+)*$`
- `eventName` must match: `^[a-z0-9]+(?:[-_][a-z0-9]+)*$`
- Property keys must match: `^[a-z][a-z0-9_]{0,39}$`
- Property values must be `string | number | boolean`
- String values are truncated to 120 characters
If `pluginId` or `eventName` is invalid, the event is rejected.
If all properties are invalid, the event is still allowed to be sent without properties.

View file

@ -29,6 +29,45 @@ Frontend UI code should keep using `window.service`.
await window.service.workspace.getActiveWorkspace();
```
## Usage for analytics
TidGi exposes `analytics` to both renderer code and TiddlyWiki plugins.
Use the guarded plugin-facing method for custom analytics:
```ts
await window.service.analytics.trackPluginEvent('my-plugin', 'panel_opened', {
source: 'toolbar',
has_selection: true,
});
```
Inside a TiddlyWiki plugin module:
```ts
const tidgiService = ($tw as typeof $tw & { tidgi?: { service?: ITidGiGlobalService } }).tidgi?.service;
await tidgiService?.analytics.trackPluginEvent('my-plugin', 'panel_opened', {
source: 'toolbar',
has_selection: true,
});
```
The final emitted event name becomes:
```text
plugin.my-plugin.panel_opened
```
### Analytics guardrails
- `pluginId` and `eventName` must be lowercase slug-like identifiers
- Property keys must be short snake_case-style identifiers
- Property values must be `string | number | boolean`
- Do not send note text, tiddler titles, workspace names, file paths, tokens, or raw URLs
See [Analytics.md](./Analytics.md) for the full analytics contract.
## How the API is wired
- Service proxies are created in the wiki worker and preload using `electron-ipc-cat`.

View file

@ -0,0 +1,132 @@
# Workspace Grouping
## Overview
Workspace grouping lets users organize multiple wiki workspaces into named collections in the left sidebar.
A group behaves like a lightweight container:
- it has a name
- it can be collapsed and expanded
- it can be reordered with other groups and ungrouped workspaces
- workspaces can be added to it, moved between groups, or dragged back out
This feature is designed to keep the sidebar manageable when a user has many workspaces, while still preserving direct drag-and-drop interaction.
## User-facing behavior
### Create a group by dragging
The most direct way to create a group is to drag one ungrouped workspace onto another ungrouped workspace.
When the pointer is in the center zone of the target workspace, TidGi interprets the action as a grouping action instead of a reorder action. A new group is created and both workspaces become members of that group.
### Reorder without grouping
Dragging near the top or bottom area of a workspace means reorder, not group.
- top area means place before
- bottom area means place after
- center area means group, if grouping is allowed for that pair
This is how the same drag gesture supports both list ordering and grouping without introducing separate drag handles or extra modes.
### Move workspaces into an existing group
An ungrouped workspace can be dragged onto a workspace that already belongs to a group.
If the pointer lands in the center grouping zone, the dragged workspace joins the target workspace's group.
If the pointer lands in a reorder zone, the workspace stays ungrouped and is only repositioned in the sidebar order.
### Move workspaces between groups
A workspace that already belongs to one group can be dragged onto a workspace in another group.
If the drop intent resolves to grouping, TidGi moves the dragged workspace into the target group.
### Remove a workspace from a group
Dragging a grouped workspace onto the header of its own group means ungroup.
This is an intentional shortcut. Users do not need a separate command just to pull one workspace back out into the ungrouped area.
### Reorder groups
Groups also participate in sidebar ordering.
A group header can be dragged:
- before another group
- before an ungrouped workspace
This allows the sidebar to behave as one mixed ordering space rather than as two separate lists.
### Collapse and expand groups
Each group header can be collapsed to hide its member workspaces and expanded again later.
Collapsing only changes presentation. It does not remove workspaces from the group and does not change group membership.
## Preferences-based management
In addition to drag-and-drop, groups can also be managed from Preferences.
The Preferences view provides a management section where users can:
- create a new named group
- rename an existing group
- delete a group
- assign or remove workspaces through a selection control
This path is useful when a user wants precise group management without dragging items in the sidebar.
## Interaction model
### Sidebar ordering model
TidGi treats the sidebar as an interleaved sequence of two item types:
- ungrouped workspaces
- group headers
Grouped workspaces are rendered under their group header, but the ordering logic still treats the sidebar as one ordered structure. This is why a group can be placed before another group or before an ungrouped workspace.
## Developer
### Why the ghost preview was removed
Earlier versions (before v0.13.1) used a ghost or placeholder style preview that visually moved items around while dragging. In theory this made the future drop position easier to imagine. In practice it introduced a more serious problem: the DOM and drop zones moved during the drag itself.
That movement caused two kinds of trouble.
First, intent detection became less reliable. The pointer could start over one target, the placeholder would shift the layout, and the actual drop zone under the pointer would no longer match what the user thought they were aiming at. This was especially problematic when deciding between:
- reorder before
- reorder after
- create or join a group
Second, tests and real usage could both observe unstable target geometry. Drag-and-drop logic depends on measuring current rectangles and collisions. When the list visually reorders in the middle of the gesture, those rectangles can change under the pointer and make the result harder to predict.
Because of that, TidGi now keeps DOM positions stable during the drag.
The current approach is:
- keep the canonical sidebar layout in place while dragging
- do not insert a moving ghost placeholder into the list
- show drag intent through highlighting instead
The highlight still tells the user what will happen:
- grouping intent
- ungrouping intent
- reorder before
- reorder after
This trades a more dramatic preview for a more trustworthy interaction. The result is easier to reason about, easier to test, and less likely to produce accidental grouping or accidental reordering.
### Related code
- [src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx](../../src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx)
- [src/windows/Preferences/customItems/WorkspaceGroupsItem.tsx](../../src/windows/Preferences/customItems/WorkspaceGroupsItem.tsx)
- [docs/internal/DragAndDrop.md](../internal/DragAndDrop.md)

View file

@ -4,7 +4,8 @@ Feature: Cross-Window Synchronization
So that I can view consistent content across all windows
Background:
Given I cleanup test wiki so it could create a new one on start
Given I start mock analytics server
And I cleanup test wiki so it could create a new one on start
And I launch the TidGi application
And I wait for the page to load completely
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
@ -30,3 +31,10 @@ Feature: Cross-Window Synchronization
When I switch to the newest window
Then the browser view should be loaded and visible
Then I should see "CrossWindowSyncTestContent123" in the browser view content
# Verify analytics events were tracked throughout the scenario
Then I should see analytics events:
| event_name | platform | version | firstLaunchDate | isFirstLaunch | isSubWiki | hasGitUrl |
| app.launched | *string* | *string* | *exists* | *boolean* | | |
| workspace.created | | | | | *boolean* | *boolean* |
| workspace.activated | | | | | *boolean* | |
| workspace.opened_in_new_window | | | | | *boolean* | |

View file

@ -5,7 +5,8 @@ Feature: TidGi Default Wiki
@wiki @create-main-workspace @root-tiddler
Scenario: Default wiki content, create new workspace, and configure root tiddler
Given I cleanup test wiki so it could create a new one on start
Given I start mock analytics server
And I cleanup test wiki so it could create a new one on start
When I launch the TidGi application
And I wait for the page to load completely
@ -33,6 +34,9 @@ Feature: TidGi Default Wiki
When I type "wiki2" in "wiki folder name input" element with selector "label:has-text('') + div input"
When I click on a "create wiki button" element with selector "button:has-text('')"
Then I wait for "workspace created" log marker "[test-id-WORKSPACE_CREATED]"
Then I should see analytics events:
| event_name | isSubWiki | hasGitUrl |
| workspace.created | *boolean* | *boolean* |
When I switch to "main" window
Then I should see a "wiki2 workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki2')"
When I click on a "wiki2 workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki2')"

View file

@ -49,12 +49,13 @@ Feature: Create New Agent Workflow
# Step 4.2: Navigate to the correct tab and expand array items to edit prompt
# Look for tabs in the PromptConfigForm
And I should see a "config tabs" element with selector "[data-testid='prompt-config-form'] .MuiTabs-root"
# Click on the first tab, expand array item, and click on the system prompt text field
When I click on "first config tab and expand array item button and system prompt text field" elements with selectors:
| element description | selector |
| first config tab | [data-testid='prompt-config-form'] .MuiTab-root:first-of-type |
| expand array item button | [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button[title*=''], [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button svg[data-testid='ExpandMoreIcon'] |
| system prompt text field | [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly]) |
# Click the first tab to reveal its panel content
When I click on a "first config tab" element with selector "[data-testid='prompt-config-form'] .MuiTab-root:first-of-type"
And I should see a "visible tab panel" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden])"
# Expand array item to show the system prompt text field
When I click on a "expand array item button" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button[title*=''], [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button svg[data-testid='ExpandMoreIcon']"
# Click the system prompt text field to focus it for editing
When I click on a "system prompt text field" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
When I clear text in "system prompt text field" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
When I type "" in "system prompt text field" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
# Step 5: Advance to step 3 (Immediate Use)
@ -112,17 +113,19 @@ Feature: Create New Agent Workflow
# Step 6.1: Navigate to the correct tab and expand array items to edit prompt
# Look for tabs in the PromptConfigForm
And I should see a "config tabs" element with selector "[data-testid='edit-agent-prompt-form'] .MuiTabs-root"
# Click on the first tab, expand array item, and click on the system prompt text field
When I click on "first config tab and expand array item button and system prompt text field" elements with selectors:
| element description | selector |
| first config tab | [data-testid='edit-agent-prompt-form'] .MuiTab-root:first-of-type |
| expand array item button | [data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) button[title*=''], [data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) button svg[data-testid='ExpandMoreIcon'] |
| system prompt text field | [data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly]) |
# Click the first tab to reveal its panel content
When I click on a "first config tab" element with selector "[data-testid='edit-agent-prompt-form'] .MuiTab-root:first-of-type"
And I should see a "visible tab panel" element with selector "[data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden])"
# Expand array item to show the system prompt text field
When I click on a "expand array item button" element with selector "[data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) button[title*=''], [data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) button svg[data-testid='ExpandMoreIcon']"
# Click the system prompt text field to focus it for editing
When I click on a "system prompt text field" element with selector "[data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
When I clear text in "system prompt text field" element with selector "[data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
When I type "" in "system prompt text field" element with selector "[data-testid='edit-agent-prompt-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
# Step 7: Test in the immediate use section (embedded chat)
# The immediate use section should show an embedded chat interface
# Find a message input in the immediate use section and test the agent
# Ensure the message input is visible before clicking (it may be scrolled below the prompt editor)
And I should see a "message input box" element with selector "[data-testid='agent-message-input']"
When I click on a "message input textarea" element with selector "[data-testid='agent-message-input']"
When I type "" in "chat input" element with selector "[data-testid='agent-message-input']"
And I press "Enter" key

View file

@ -4,7 +4,8 @@ Feature: TidGi Application Launch
So that I can use the application
@smoke @logging
Scenario: Application starts, shows interface, and logs work
Scenario: Basic launch, preferences, and filesystem watch setup
Given I start mock analytics server
When I launch the TidGi application
And I wait for the page to load completely
And I should see a "page body" element with selector "body"
@ -15,3 +16,68 @@ Feature: TidGi Application Launch
When I click on a "sync section" element with selector "[data-testid='preference-section-sync']"
Then I should find log entries containing
| test-id-Preferences section clicked |
Then I should see analytics events:
| event_name | window |
| settings.opened | preferences |
# Switch to main window for filesystem watch calibration
When I switch to "main" window
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
# Enable filesystem watch and create probe files
# These files accumulate state that makes the watcher slower on restart
When I update workspace "wiki" settings:
| property | value |
| enableFileSystemWatch | true |
When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
Then the browser view should be loaded and visible
And I wait for SSE and watch-fs to be ready
When I create file "{tmpDir}/wiki/tiddlers/ProbeAlpha.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: ProbeAlpha
tags: calibration
Alpha probe
"""
Then I wait for tiddler "ProbeAlpha" to be added by watch-fs
When I create file "{tmpDir}/wiki/tiddlers/ProbeBeta.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: ProbeBeta
tags: calibration
Beta probe
"""
Then I wait for tiddler "ProbeBeta" to be added by watch-fs
When I create file "{tmpDir}/wiki/tiddlers/ProbeGamma.tid" with content:
"""
created: 20250226070000000
modified: 20250226070000000
title: ProbeGamma
tags: calibration
Gamma probe
"""
Then I wait for tiddler "ProbeGamma" to be added by watch-fs
@smoke
Scenario: Watcher re-index under accumulated file state
# This scenario runs AFTER the first one, on a system where the
# wiki has been restarted and the watcher must re-index files
# created by the previous scenario. The "wait for SSE and watch-fs"
# step measures worst-case watcher re-indexing under load.
When I launch the TidGi application
And I wait for the page to load completely
And I should see a "page body" element with selector "body"
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
And I restart workspace "wiki"
When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
Then the browser view should be loaded and visible
And I wait for SSE and watch-fs to be ready
@smoke
Scenario: Third launch sample for variance capture
# Extra launch to increase sample size for app launch timing.
# Two scenarios give 4 launches (2 runs × 2). Adding a third
# gives 6 samples to better capture the launch time distribution.
When I launch the TidGi application
And I wait for the page to load completely
And I should see a "page body" element with selector "body"

View file

@ -0,0 +1,84 @@
import { Given, Then, When } from '@cucumber/cucumber';
import assert from 'assert';
import { MockAnalyticsServer } from '../supports/mockAnalytics';
import type { ApplicationWorld } from './application';
/**
* Start a mock analytics server and configure the app to use it.
* This should be called before launching the app.
*/
Given('I start mock analytics server', async function(this: ApplicationWorld) {
const mockAnalyticsServer = new MockAnalyticsServer();
await mockAnalyticsServer.start();
// Store on world for later access
(this as unknown as Record<string, unknown>).mockAnalyticsServer = mockAnalyticsServer;
// Configure app to use mock analytics server via launch env overrides
// The app reads these and sets them as default preferences
this.launchEnvOverrides.TIDGI_ANALYTICS_HOST = mockAnalyticsServer.baseUrl;
this.launchEnvOverrides.TIDGI_ANALYTICS_SITE_ID = 'test-site-id';
this.launchEnvOverrides.TIDGI_ANALYTICS_API_KEY = 'test-api-key';
});
/**
* Reset the mock analytics server events.
*/
When('I reset mock analytics events', async function(this: ApplicationWorld) {
const mockAnalyticsServer = (this as unknown as Record<string, unknown>).mockAnalyticsServer as MockAnalyticsServer | undefined;
if (!mockAnalyticsServer) {
throw new Error('Mock analytics server is not started. Call "I start mock analytics server" first.');
}
mockAnalyticsServer.clearEvents();
});
/**
* Verify that specific analytics events were received by the mock server.
* Supports table format with event names and optional property checks.
*/
Then('I should see analytics events:', async function(this: ApplicationWorld, dataTable: { hashes: () => Array<Record<string, string>> }) {
const mockAnalyticsServer = (this as unknown as Record<string, unknown>).mockAnalyticsServer as MockAnalyticsServer | undefined;
if (!mockAnalyticsServer) {
throw new Error('Mock analytics server is not started. Call "I start mock analytics server" first.');
}
// Wait a short time for any pending analytics requests to complete
await new Promise(resolve => setTimeout(resolve, 500));
const events = mockAnalyticsServer.getEvents();
const expectedEvents = dataTable.hashes();
for (const expected of expectedEvents) {
const eventName = expected.event_name;
if (!eventName) {
throw new Error('Missing "event_name" column in analytics events table');
}
const matchingEvents = events.filter(event => event.event_name === eventName);
assert(matchingEvents.length >= 1, `Expected analytics event "${eventName}" to be received, but got ${events.map(event => event.event_name).join(', ') || 'none'}`);
// Check optional properties
const matchedEvent = matchingEvents[0];
for (const [key, value] of Object.entries(expected)) {
if (key === 'event_name' || !value) continue;
const actualValue = matchedEvent.properties?.[key];
const expectedValue = value;
// Support special matchers
if (expectedValue === '*exists*') {
assert(actualValue !== undefined, `Expected property "${key}" to exist on event "${eventName}"`);
} else if (expectedValue === '*boolean*') {
assert(typeof actualValue === 'boolean', `Expected property "${key}" to be a boolean on event "${eventName}"`);
} else if (expectedValue === '*number*') {
assert(typeof actualValue === 'number', `Expected property "${key}" to be a number on event "${eventName}"`);
} else if (expectedValue === '*string*') {
assert(typeof actualValue === 'string', `Expected property "${key}" to be a string on event "${eventName}"`);
} else if (expectedValue.startsWith('*contains:')) {
const substring = expectedValue.slice(10, -1);
assert(String(actualValue).includes(substring), `Expected property "${key}" to contain "${substring}" on event "${eventName}"`);
} else {
assert(String(actualValue) === expectedValue, `Expected property "${key}" to be "${expectedValue}" on event "${eventName}", but got "${String(actualValue)}"`);
}
}
}
});

View file

@ -8,7 +8,6 @@ import { windowDimension, WindowNames } from '../../src/services/windows/WindowP
import { MockOAuthServer } from '../supports/mockOAuthServer';
import { MockOpenAIServer } from '../supports/mockOpenAI';
import { getPackedAppPath, makeSlugPath } from '../supports/paths';
import { PLAYWRIGHT_TIMEOUT } from '../supports/timeouts';
import { captureScreenshot, captureWindowScreenshot } from '../supports/webContentsViewHelper';
/**
@ -75,6 +74,7 @@ export class ApplicationWorld {
savedWorkspaceId: string | undefined; // For storing workspace ID between steps
scenarioName: string = 'default'; // Scenario name from Cucumber pickle
scenarioSlug: string = 'default'; // Sanitized scenario name for file paths
scenarioTags: string[] = [];
providerConfig: import('@services/externalAPI/interface').AIProviderConfig | undefined; // Scenario-specific AI provider config
launchEnvOverrides: Record<string, string> = {};
@ -283,7 +283,7 @@ async function launchTidGiApplication(world: ApplicationWorld): Promise<void> {
}),
},
cwd: process.cwd(),
timeout: PLAYWRIGHT_TIMEOUT,
timeout: 120_000, // generous process-launch budget, cucumber step timeout catches hangs
});
// Do not block launch step on firstWindow; this can exceed Cucumber's 5s step timeout.
@ -381,6 +381,7 @@ When('I launch the TidGi application', async function(this: ApplicationWorld) {
this.appLaunchPromise = launchTidGiApplication(this).catch((error: unknown) => {
throw error;
});
await this.appLaunchPromise;
});
When('I close the TidGi application', async function(this: ApplicationWorld) {

View file

@ -11,6 +11,7 @@ Before(async function(this: ApplicationWorld, { pickle }) {
// Initialize scenario-specific paths
this.scenarioName = pickle.name;
this.scenarioSlug = makeSlugPath(pickle.name, 60);
this.scenarioTags = pickle.tags.map((tag) => tag.name);
const scenarioRoot = path.resolve(process.cwd(), 'test-artifacts', this.scenarioSlug);
const logsDirectory = path.resolve(scenarioRoot, 'userData-test', 'logs');

View file

@ -18,11 +18,21 @@ function cleanupPathBestEffort(targetPath: string): void {
async function getWorkspaceInfo(world: ApplicationWorld, workspaceName: string): Promise<{ id: string; port: number }> {
const settings = await fs.readJson(getSettingsPath(world)) as { workspaces?: Record<string, IWorkspace> };
const workspaces = settings.workspaces ?? {};
// First pass: prefer exact name match
for (const [id, workspace] of Object.entries(workspaces)) {
if ('wikiFolderLocation' in workspace) {
const wikiWorkspace = workspace;
if (wikiWorkspace.name === workspaceName) {
return { id, port: wikiWorkspace.port ?? 5212 };
}
}
}
// Second pass: fallback to folder basename match
for (const [id, workspace] of Object.entries(workspaces)) {
if ('wikiFolderLocation' in workspace) {
const wikiWorkspace = workspace;
const folderName = path.basename(wikiWorkspace.wikiFolderLocation);
if (folderName === workspaceName || wikiWorkspace.name === workspaceName) {
if (folderName === workspaceName) {
return { id, port: wikiWorkspace.port ?? 5212 };
}
}

View file

@ -32,11 +32,11 @@ When('I wait for the page to load completely', async function(this: ApplicationW
let currentWindow = this.currentWindow;
if ((!currentWindow || currentWindow.isClosed()) && this.app) {
currentWindow = await this.app.firstWindow({ timeout: PLAYWRIGHT_TIMEOUT });
currentWindow = await this.app.firstWindow({ timeout: 120_000 });
this.mainWindow = this.mainWindow ?? currentWindow;
this.currentWindow = currentWindow;
}
await currentWindow?.waitForLoadState('domcontentloaded', { timeout: PLAYWRIGHT_TIMEOUT });
await currentWindow?.waitForLoadState('domcontentloaded', { timeout: 120_000 });
// Short networkidle gives workspace-creation and other startup IPC time to finish
// without blocking on long-lived connections. 3s is intentionally different from
// PLAYWRIGHT_TIMEOUT — this is just a grace period, not a hard requirement.
@ -193,6 +193,25 @@ When('I click on a(n) {string} element with selector {string}', async function(t
}
});
Then('the {string} element with selector {string} should be unchecked', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const targetWindow = await this.getWindow('current');
if (!targetWindow) {
throw new Error(`Window "current" is not available`);
}
try {
const locator = targetWindow.locator(selector);
await locator.waitFor({ state: 'visible', timeout: PLAYWRIGHT_TIMEOUT });
const isChecked = await locator.isChecked();
if (isChecked) {
throw new Error(`Element "${elementComment}" with selector "${selector}" is checked, expected unchecked`);
}
} catch (error) {
throw new Error(`Failed to verify ${elementComment} with selector "${selector}" is unchecked: ${error as Error}`);
}
});
When('I click on {string} elements with selectors:', async function(this: ApplicationWorld, _elementDescriptions: string, dataTable: DataTable) {
const targetWindow = await this.getWindow('current');
@ -523,11 +542,8 @@ When('I select {string} from MUI Select with test id {string}', async function(t
throw new Error(`Failed to click: ${JSON.stringify(clicked)}`);
}
// Wait a bit for the menu to appear
await currentWindow.waitForTimeout(500);
// Wait for the menu to appear
await currentWindow.waitForSelector('[role="listbox"]', { timeout: PLAYWRIGHT_SHORT_TIMEOUT });
await currentWindow.waitForSelector('[role="listbox"]', { state: 'visible', timeout: PLAYWRIGHT_SHORT_TIMEOUT });
// Try to click on the option with the specified value (data-value attribute)
// If not found, try to find by text content

View file

@ -182,7 +182,7 @@ When('I cleanup test wiki so it could create a new one on start', async function
try {
await backOff(
async () => {
fs.writeJsonSync(getSettingsPath(this), { ...settings, workspaces: filtered }, { spaces: 2 });
fs.writeJsonSync(getSettingsPath(this), { ...settings, workspaces: filtered, workspaceGroups: {} }, { spaces: 2 });
},
{
numOfAttempts: 3,
@ -706,22 +706,19 @@ Then('I wait for SSE and watch-fs to be ready', async function(this: Application
* @param marker - The text pattern to remove from log files
*/
When('I clear log lines containing {string}', async function(this: ApplicationWorld, marker: string) {
const logDirectory = getLogPath(this);
if (!fs.existsSync(logDirectory)) return;
await clearLogLinesContaining(this, marker);
});
// Clear from both TidGi- and wiki- prefixed log files
const logFiles = fs.readdirSync(logDirectory).filter(f => (f.startsWith('TidGi-') || f.startsWith('wiki')) && f.endsWith('.log'));
When('I clear log lines containing:', async function(this: ApplicationWorld, dataTable: DataTable) {
const rows = dataTable.raw();
const dataRows = parseDataTableRows(rows, 1);
for (const logFile of logFiles) {
const logPath = path.join(logDirectory, logFile);
try {
const content = fs.readFileSync(logPath, 'utf-8');
// Remove lines containing the marker
const filteredLines = content.split('\n').filter(line => !line.includes(marker));
fs.writeFileSync(logPath, filteredLines.join('\n'), 'utf-8');
} catch (error) {
console.warn(`Failed to clear log lines from ${logFile}:`, error);
}
if (dataRows[0]?.length !== 1) {
throw new Error('Table must have exactly 1 column: | marker |');
}
for (const [marker] of dataRows) {
await clearLogLinesContaining(this, marker);
}
});
@ -896,23 +893,18 @@ When('I open edit workspace window for workspace with name {string}', async func
const settings = await fs.readJson(getSettingsPath(this)) as { workspaces?: Record<string, IWorkspace> };
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
// Find workspace by name or by wikiFolderLocation (in case name is removed from settings.json)
// Find workspace by name or by wikiFolderLocation (in case name is removed from settings.json).
// Do two passes so exact name matches take priority over folder-basename matches.
// First pass: match by settings.json name or tidgi.config.json name
for (const [id, workspace] of Object.entries(workspaces)) {
if (workspace.pageType) continue; // Skip page workspaces
// Try to match by name (if available in settings.json)
if (workspace.name === workspaceName) {
targetWorkspaceId = id;
return;
}
// 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)) {
@ -927,6 +919,14 @@ When('I open edit workspace window for workspace with name {string}', async func
}
}
}
// Second pass: fallback to folder basename match
for (const [id, workspace] of Object.entries(workspaces)) {
if (workspace.pageType) continue;
if (isWikiWorkspace(workspace) && path.basename(workspace.wikiFolderLocation) === workspaceName) {
targetWorkspaceId = id;
return;
}
}
// If not found, throw error to trigger retry
throw new Error(`Workspace "${workspaceName}" not found yet, will retry...`);
@ -966,13 +966,15 @@ When('I open edit workspace window for workspace with name {string}', async func
}
});
When('I create a new wiki workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) {
if (!this.app) {
async function createWikiWorkspace(world: ApplicationWorld, workspaceName: string): Promise<void> {
if (!world.app) {
throw new Error('Application is not available');
}
const isWorkspaceGroupScenario = world.scenarioTags.includes('@workspace-group');
// Construct the full wiki path
const wikiPath = path.join(getWikiTestRootPath(this), workspaceName);
const wikiPath = path.join(getWikiTestRootPath(world), workspaceName);
// Create the wiki folder using the template (same filter as createWiki in wiki/index.ts)
const templatePath = path.join(process.cwd(), 'template', 'wiki');
@ -986,25 +988,28 @@ When('I create a new wiki workspace with name {string}', async function(this: Ap
},
});
// Initialize fresh git repository for the new wiki using dugite
try {
// Initialize git repository with master branch
await gitExec(['init', '-b', 'master'], wikiPath);
// Workspace-group scenarios only validate grouping and drag behavior.
// Skipping git bootstrap avoids repeated add/commit overhead across dozens of test workspaces.
if (!isWorkspaceGroupScenario) {
try {
// Initialize git repository with master branch
await gitExec(['init', '-b', 'master'], wikiPath);
// Configure git user
await gitExec(['config', 'user.email', 'test@tidgi.test'], wikiPath);
await gitExec(['config', 'user.name', 'TidGi Test'], wikiPath);
// Configure git user
await gitExec(['config', 'user.email', 'test@tidgi.test'], wikiPath);
await gitExec(['config', 'user.name', 'TidGi Test'], wikiPath);
// Add all files and create initial commit
await gitExec(['add', '.'], wikiPath);
await gitExec(['commit', '-m', 'Initial commit'], wikiPath);
} catch (error) {
// Git initialization is not critical for the test, continue anyway
console.log('Git initialization skipped:', (error as Error).message);
// Add all files and create initial commit
await gitExec(['add', '.'], wikiPath);
await gitExec(['commit', '-m', 'Initial commit'], wikiPath);
} catch (error) {
// Git initialization is not critical for the test, continue anyway
console.log('Git initialization skipped:', (error as Error).message);
}
}
// Now create workspace configuration
await this.app.evaluate(async ({ BrowserWindow }, { wikiName, wikiFullPath }: { wikiName: string; wikiFullPath: string }) => {
await world.app.evaluate(async ({ BrowserWindow }, { wikiName, wikiFullPath }: { wikiName: string; wikiFullPath: string }) => {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html'));
@ -1026,10 +1031,76 @@ When('I create a new wiki workspace with name {string}', async function(this: Ap
`);
}, { wikiName: workspaceName, wikiFullPath: wikiPath });
// Wait for workspace to appear in UI
await this.app.evaluate(async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
});
await backOff(
async () => {
const workspaces = await world.app!.evaluate(async ({ BrowserWindow }, _name: string) => {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html'));
if (!mainWindow) {
throw new Error('Main window not found');
}
return await mainWindow.webContents.executeJavaScript(`
(async () => {
const all = await window.service.workspace.getWorkspacesAsList();
return all.filter(workspace => !workspace.pageType).map(workspace => workspace.name);
})();
`) as Promise<string[]>;
}, workspaceName);
if (!workspaces.includes(workspaceName)) {
throw new Error(`Workspace ${workspaceName} not visible yet`);
}
},
BACKOFF_OPTIONS,
);
// Also wait for the workspace to actually appear in the sidebar DOM.
// The observable emission that triggers React re-render can lag behind
// the service-side creation on slow CI runners, causing drag steps to
// target elements that have not yet been mounted.
await backOff(
async () => {
const workspaceId = await world.app!.evaluate(async ({ BrowserWindow }, name: string) => {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html'));
if (!mainWindow) return null;
const resolvedWorkspaceId = await mainWindow.webContents.executeJavaScript(`
(async () => {
const all = await window.service.workspace.getWorkspacesAsList();
const ws = all.find(w => w.name === ${JSON.stringify(name)});
return ws ? ws.id : null;
})();
`) as string | null;
return resolvedWorkspaceId;
}, workspaceName);
if (!workspaceId || !world.currentWindow) {
throw new Error(`Workspace ${workspaceName} ID not available for DOM check`);
}
const count = await world.currentWindow.locator(`[data-testid="workspace-item-${workspaceId}"]`).count();
if (count === 0) {
throw new Error(`Workspace ${workspaceName} not yet rendered in sidebar DOM`);
}
},
BACKOFF_OPTIONS,
);
}
When('I create a new wiki workspace with name {string}', async function(this: ApplicationWorld, workspaceName: string) {
await createWikiWorkspace(this, workspaceName);
});
When('I create new wiki workspaces with names:', async function(this: ApplicationWorld, dataTable: DataTable) {
const workspaceNames = dataTable.raw()
.map(([workspaceName]) => workspaceName?.trim())
.filter((workspaceName): workspaceName is string => Boolean(workspaceName));
for (const workspaceName of workspaceNames) {
await createWikiWorkspace(this, workspaceName);
}
});
/**
@ -1113,41 +1184,32 @@ When('I update workspace {string} settings:', async function(this: ApplicationWo
settingsUpdate[property] = parsedValue;
}
// Helper JS snippet for renderer-side workspace lookup by name or folder basename.
// Uses String.fromCharCode(92) for backslash to avoid template-literal escaping issues.
const findWorkspaceJS = (targetName: string) => `
(async () => {
var backslash = String.fromCharCode(92);
function getFolderName(loc) {
if (!loc) return undefined;
var i1 = loc.lastIndexOf('/');
var i2 = loc.lastIndexOf(backslash);
var i = Math.max(i1, i2);
return i >= 0 ? loc.substring(i + 1) : loc;
}
var workspaces = await window.service.workspace.getWorkspacesAsList();
var found = workspaces.find(function(ws) {
if (ws.pageType) return false;
if (ws.name === ${JSON.stringify(targetName)}) return true;
var fn = 'wikiFolderLocation' in ws ? getFolderName(ws.wikiFolderLocation) : undefined;
return fn === ${JSON.stringify(targetName)};
});
if (!found && ${JSON.stringify(targetName)} === 'wiki') {
found = workspaces.find(function(ws) {
return !ws.pageType && !ws.isSubWiki;
});
}
return found || null;
})()
`;
// Resolve workspace from the live renderer to avoid stale IDs from the settings file.
const runtimeWorkspace = await this.app.evaluate(async ({ BrowserWindow }, name: string) => {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html'));
if (!mainWindow) return null;
return await mainWindow.webContents.executeJavaScript(name) as Promise<IWorkspace | null>;
}, findWorkspaceJS(workspaceName));
const targetWindow = this.mainWindow ?? this.currentWindow;
if (!targetWindow) {
throw new Error('No window available to look up workspace');
}
const runtimeWorkspace = await targetWindow.evaluate(async (name: string) => {
const backslash = String.fromCharCode(92);
function getFolderName(loc: string | undefined): string | undefined {
if (!loc) return undefined;
const separatorIndex = Math.max(loc.lastIndexOf('/'), loc.lastIndexOf(backslash));
return separatorIndex >= 0 ? loc.substring(separatorIndex + 1) : loc;
}
const workspaces = await window.service.workspace.getWorkspacesAsList();
let found = workspaces.find(ws => !ws.pageType && ws.name === name);
if (!found) {
found = workspaces.find(ws => {
if (ws.pageType) return false;
const folderName = 'wikiFolderLocation' in ws ? getFolderName(ws.wikiFolderLocation) : undefined;
return folderName === name;
});
}
if (!found && name === 'wiki') {
found = workspaces.find(ws => !ws.pageType && !('isSubWiki' in ws && ws.isSubWiki));
}
return found || null;
}, workspaceName) as IWorkspace | null;
if (!runtimeWorkspace) {
throw new Error(`No workspace found with name: ${workspaceName}`);
@ -1161,21 +1223,12 @@ When('I update workspace {string} settings:', async function(this: ApplicationWo
}
// Update workspace settings via main window
await this.app.evaluate(async ({ BrowserWindow }, { workspaceId, updates }: { workspaceId: string; updates: Record<string, unknown> }) => {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows.find(win => !win.isDestroyed() && win.webContents && win.webContents.getURL().includes('index.html'));
if (!mainWindow) throw new Error('Main window not found');
await mainWindow.webContents.executeJavaScript(`
(async () => {
await window.service.workspace.update(${JSON.stringify(workspaceId)}, ${JSON.stringify(updates)});
})();
`);
await targetWindow.evaluate(async ({ workspaceId, updates }: { workspaceId: string; updates: Record<string, unknown> }) => {
await window.service.workspace.update(workspaceId, updates);
}, { workspaceId: targetWorkspaceId, updates: settingsUpdate });
// Wait for settings to propagate
await this.app.evaluate(async () => {
await new Promise(resolve => setTimeout(resolve, 500));
});
await new Promise(resolve => setTimeout(resolve, 500));
// If enableFileSystemWatch or enableHTTPAPI was changed, we need to restart the wiki
const needsRestart = 'enableFileSystemWatch' in settingsUpdate || 'enableHTTPAPI' in settingsUpdate;
@ -1190,24 +1243,16 @@ When('I update workspace {string} settings:', async function(this: ApplicationWo
await clearLogLinesContaining(this, '[test-id-WATCH_FS_STABILIZED]');
// Restart the wiki using the runtime-resolved workspace ID
const restartResult = 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'));
if (!mainWindow) throw new Error('Main window not found');
const result = await mainWindow.webContents.executeJavaScript(`
(async () => {
var workspace = await window.service.workspace.get(${JSON.stringify(workspaceId)});
if (!workspace) return { success: false, error: 'Workspace not found for id=' + ${JSON.stringify(workspaceId)} };
try {
await window.service.wiki.restartWiki(workspace);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
})();
`) as Promise<{ success: boolean; error?: string }>;
return result;
}, targetWorkspaceId);
const restartResult = await targetWindow.evaluate(async (workspaceId: string) => {
const workspace = await window.service.workspace.get(workspaceId);
if (!workspace) return { success: false, error: 'Workspace not found for id=' + workspaceId };
try {
await window.service.wiki.restartWiki(workspace);
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}, targetWorkspaceId) as { success: boolean; error?: string };
if (!restartResult.success) {
throw new Error(`Failed to restart wiki: ${restartResult.error ?? 'Unknown error'}`);
@ -1566,6 +1611,60 @@ Then('file {string} should contain JSON with:', async function(this: Application
);
});
/**
* Verify file does NOT contain specific JSON path/value pairs.
* This is useful for ensuring sensitive config (like readOnlyMode) does not leak into tidgi.config.json.
* Example:
* Then file "config-test-wiki/tidgi.config.json" should not contain JSON with:
* | jsonPath | value |
* | $.readOnlyMode | true |
*/
Then('file {string} should not contain JSON with:', async function(this: ApplicationWorld, fileName: string, dataTable: DataTable) {
const rows = dataTable.hashes();
const filePath = path.join(getWikiTestRootPath(this), fileName);
// If file doesn't exist, the assertion passes (can't contain anything)
if (!await fs.pathExists(filePath)) {
return;
}
const content = await fs.readFile(filePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const json = JSON.parse(content);
const errors: string[] = [];
for (const row of rows) {
const jsonPath = row.jsonPath;
const expectedValue = row.value;
// Simple JSONPath implementation
const pathParts = jsonPath.replace(/^\$\./, '').split('.');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
let actualValue = json;
for (const part of pathParts) {
if (actualValue && typeof actualValue === 'object' && part in actualValue) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
actualValue = actualValue[part];
} else {
actualValue = undefined;
break;
}
}
if (actualValue !== undefined) {
const actualValueString = String(actualValue);
if (actualValueString === expectedValue) {
errors.push(`Expected ${jsonPath} to NOT be "${expectedValue}", but it was found in the file`);
}
}
}
if (errors.length > 0) {
throw new Error(`JSON assertions failed:\n${errors.join('\n')}`);
}
});
/**
* Remove workspace without deleting files (via API)
*/

View file

@ -1,5 +1,6 @@
import { When } from '@cucumber/cucumber';
import { WebContentsView } from 'electron';
import { backOff } from 'exponential-backoff';
import type { ElectronApplication } from 'playwright';
import type { ApplicationWorld } from './application';
import { checkWindowDimension, checkWindowName } from './application';
@ -8,7 +9,11 @@ import { checkWindowDimension, checkWindowName } from './application';
async function getBrowserViewInfo(
app: ElectronApplication,
dimensions: { width: number; height: number },
): Promise<{ view?: { x: number; y: number; width: number; height: number }; windowContent?: { width: number; height: number }; hasView: boolean }> {
): Promise<{
views: Array<{ x: number; y: number; width: number; height: number }>;
windowContent?: { width: number; height: number };
hasView: boolean;
}> {
return app.evaluate(async ({ BrowserWindow }, dimensions: { width: number; height: number }) => {
const windows = BrowserWindow.getAllWindows();
@ -19,33 +24,50 @@ async function getBrowserViewInfo(
});
if (!targetWindow) {
return { hasView: false };
return { hasView: false, views: [] };
}
// Get all child views (WebContentsView instances) attached to this specific window
if (targetWindow.contentView && 'children' in targetWindow.contentView) {
const views = targetWindow.contentView.children || [];
const webContentsViewBounds = [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
const webContentsView = view as WebContentsView;
const viewBounds = webContentsView.getBounds();
const windowContentBounds = targetWindow.getContentBounds();
return {
view: viewBounds,
windowContent: windowContentBounds,
hasView: true,
};
webContentsViewBounds.push(webContentsView.getBounds());
}
}
if (webContentsViewBounds.length > 0) {
return {
views: webContentsViewBounds,
windowContent: targetWindow.getContentBounds(),
hasView: true,
};
}
}
return { hasView: false };
return { hasView: false, views: [] };
}, dimensions);
}
function isViewWithinBounds(
view: { x: number; y: number; width: number; height: number },
windowContent: { width: number; height: number },
): boolean {
const viewRight = view.x + view.width;
const viewBottom = view.y + view.height;
return view.x >= 0 &&
view.y >= 0 &&
viewRight <= windowContent.width &&
viewBottom <= windowContent.height &&
view.width > 0 &&
view.height > 0;
}
When('I confirm the {string} window exists', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not launched');
@ -120,34 +142,32 @@ When('I confirm the {string} window browser view is positioned within visible wi
const windowName = checkWindowName(windowType);
const windowDimensions = checkWindowDimension(windowName);
// Get browser view bounds for the specific window type
const viewInfo = await getBrowserViewInfo(this.app, windowDimensions);
// Retry with backoff: browser view repositioning can lag behind DOM updates
await backOff(async () => {
// Get browser view bounds for the specific window type
const viewInfo = await getBrowserViewInfo(this.app!, windowDimensions);
if (!viewInfo.hasView || !viewInfo.view || !viewInfo.windowContent) {
throw new Error(`No browser view found in "${windowType}" window`);
}
if (!viewInfo.hasView || !viewInfo.windowContent) {
throw new Error(`No browser view found in "${windowType}" window (retrying)`);
}
// Check if browser view is within window content bounds
// View coordinates are relative to the window, so we check if they're within the content area
const viewRight = viewInfo.view.x + viewInfo.view.width;
const viewBottom = viewInfo.view.y + viewInfo.view.height;
const contentWidth = viewInfo.windowContent.width;
const contentHeight = viewInfo.windowContent.height;
const visibleView = viewInfo.views.find((view) => isViewWithinBounds(view, viewInfo.windowContent!));
const isWithinBounds = viewInfo.view.x >= 0 &&
viewInfo.view.y >= 0 &&
viewRight <= contentWidth &&
viewBottom <= contentHeight &&
viewInfo.view.width > 0 &&
viewInfo.view.height > 0;
if (!isWithinBounds) {
throw new Error(
`Browser view is not positioned within visible window bounds.\n` +
`View: {x: ${viewInfo.view.x}, y: ${viewInfo.view.y}, width: ${viewInfo.view.width}, height: ${viewInfo.view.height}}, ` +
`Window content: {width: ${contentWidth}, height: ${contentHeight}}`,
);
}
if (!visibleView) {
const sampledView = viewInfo.views[0];
throw new Error(
`Browser view is not positioned within visible window bounds (retrying).\n` +
`Views: ${JSON.stringify(viewInfo.views)}, ` +
`Window content: {width: ${viewInfo.windowContent.width}, height: ${viewInfo.windowContent.height}}` +
(sampledView ? `, First view: {x: ${sampledView.x}, y: ${sampledView.y}, width: ${sampledView.width}, height: ${sampledView.height}}` : ''),
);
}
}, {
numOfAttempts: 10,
startingDelay: 200,
timeMultiple: 1,
maxDelay: 500,
});
});
When('I confirm the {string} window browser view is not positioned within visible window bounds', async function(this: ApplicationWorld, windowType: string) {
@ -164,35 +184,32 @@ When('I confirm the {string} window browser view is not positioned within visibl
const windowName = checkWindowName(windowType);
const windowDimensions = checkWindowDimension(windowName);
// Get browser view bounds for the specific window type
const viewInfo = await getBrowserViewInfo(this.app, windowDimensions);
// Retry with backoff: browser view hiding can lag behind workspace switching
await backOff(async () => {
// Get browser view bounds for the specific window type
const viewInfo = await getBrowserViewInfo(this.app!, windowDimensions);
if (!viewInfo.hasView || !viewInfo.view || !viewInfo.windowContent) {
// No view found is acceptable for this check - means it's definitely not visible
return;
}
if (!viewInfo.hasView || !viewInfo.windowContent) {
// No view found is acceptable for this check
return;
}
// Check if browser view is OUTSIDE window content bounds
// View coordinates are relative to the window, so we check if they're outside the content area
const viewRight = viewInfo.view.x + viewInfo.view.width;
const viewBottom = viewInfo.view.y + viewInfo.view.height;
const contentWidth = viewInfo.windowContent.width;
const contentHeight = viewInfo.windowContent.height;
const visibleView = viewInfo.views.find((view) => isViewWithinBounds(view, viewInfo.windowContent!));
const isWithinBounds = viewInfo.view.x >= 0 &&
viewInfo.view.y >= 0 &&
viewRight <= contentWidth &&
viewBottom <= contentHeight &&
viewInfo.view.width > 0 &&
viewInfo.view.height > 0;
if (isWithinBounds) {
throw new Error(
`Browser view IS positioned within visible window bounds, but expected it to be outside.\n` +
`View: {x: ${viewInfo.view.x}, y: ${viewInfo.view.y}, width: ${viewInfo.view.width}, height: ${viewInfo.view.height}}, ` +
`Window content: {width: ${contentWidth}, height: ${contentHeight}}`,
);
}
if (visibleView) {
throw new Error(
`Browser view IS positioned within visible window bounds, but expected it to be outside (retrying).\n` +
`Visible view: {x: ${visibleView.x}, y: ${visibleView.y}, width: ${visibleView.width}, height: ${visibleView.height}}, ` +
`All views: ${JSON.stringify(viewInfo.views)}, ` +
`Window content: {width: ${viewInfo.windowContent.width}, height: ${viewInfo.windowContent.height}}`,
);
}
}, {
numOfAttempts: 5,
startingDelay: 200,
timeMultiple: 1,
maxDelay: 500,
});
});
When('I resize the {string} window to {int}x{int}', async function(this: ApplicationWorld, windowType: string, width: number, height: number) {

View file

@ -0,0 +1,607 @@
import { DataTable, Given, Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import type { IWorkspaceGroup } from '../../src/services/workspaces/interface';
// Pull in renderer window type declarations so Playwright page.evaluate callbacks
// can access window.service with proper typing.
import type {} from '../../src/preload/index';
import type { ApplicationWorld } from './application';
const BACKOFF_OPTIONS = {
numOfAttempts: 8,
startingDelay: 100,
maxDelay: 1000,
timeMultiple: 2,
};
interface ITestWorkspace {
id: string;
name: string;
groupId?: string | null;
order?: number;
pageType?: string | null;
}
interface IWorkspaceOrGroupOrderEntry {
name: string;
order: number;
type: 'workspace' | 'group';
}
async function getAllWikiWorkspaces(world: ApplicationWorld): Promise<ITestWorkspace[]> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
return await world.currentWindow.evaluate(async () => {
const all = await window.service.workspace.getWorkspacesAsList();
return all.filter(workspace => !workspace.pageType) as ITestWorkspace[];
});
}
async function getWorkspaceByName(world: ApplicationWorld, workspaceName: string): Promise<ITestWorkspace> {
const workspaces = await getAllWikiWorkspaces(world);
const workspace = workspaces.find((candidate) => candidate.name === workspaceName);
if (!workspace) {
throw new Error(
`Workspace "${workspaceName}" not found. Existing wiki workspaces: ${workspaces.map(candidate => candidate.name).join(', ')}`,
);
}
return workspace;
}
async function getGroups(world: ApplicationWorld): Promise<IWorkspaceGroup[]> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
return await world.currentWindow.evaluate(async () => window.service.workspace.getGroupsAsList());
}
async function getGroupWorkspaces(world: ApplicationWorld, groupId: string): Promise<ITestWorkspace[]> {
const workspaces = await getAllWikiWorkspaces(world);
return workspaces.filter(workspace => workspace.groupId === groupId);
}
async function getSidebarOrderEntries(world: ApplicationWorld): Promise<IWorkspaceOrGroupOrderEntry[]> {
const [workspaces, groups] = await Promise.all([getAllWikiWorkspaces(world), getGroups(world)]);
return [
...workspaces.filter(workspace => !workspace.groupId).map(workspace => ({
name: workspace.name,
order: workspace.order ?? 0,
type: 'workspace' as const,
})),
...groups.map(group => ({
name: group.name,
order: group.order ?? 0,
type: 'group' as const,
})),
].sort((left, right) => left.order - right.order);
}
async function createGroup(world: ApplicationWorld, groupName: string): Promise<IWorkspaceGroup> {
const groups = await getGroups(world);
const newGroup: IWorkspaceGroup = {
id: `test-group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: groupName,
order: groups.length,
collapsed: false,
};
if (!world.currentWindow) {
throw new Error('Current window not set');
}
await world.currentWindow.evaluate(async (group: IWorkspaceGroup) => {
await window.service.workspace.setGroup(group.id, group);
}, newGroup);
return newGroup;
}
async function moveWorkspaceToGroup(world: ApplicationWorld, workspaceId: string, groupId: string | null, autoDisband = true): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
await world.currentWindow.evaluate(async ({ workspaceId: id, groupId: gid, autoDisband: disband }: { workspaceId: string; groupId: string | null; autoDisband: boolean }) => {
await window.service.workspace.moveWorkspaceToGroup(id, gid, disband);
}, { workspaceId, groupId, autoDisband });
}
async function waitForWorkspaceGroupId(world: ApplicationWorld, workspaceName: string, expectedGroupId: string | null): Promise<void> {
await backOff(async () => {
const workspace = await getWorkspaceByName(world, workspaceName);
const actualGroupId = workspace.groupId ?? null;
if (actualGroupId !== expectedGroupId) {
throw new Error(`Workspace "${workspaceName}" groupId is ${String(actualGroupId)}, expected ${String(expectedGroupId)}`);
}
}, BACKOFF_OPTIONS);
}
async function waitForGroupVisibility(world: ApplicationWorld, groupId: string): Promise<void> {
await backOff(async () => {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const count = await world.currentWindow.locator(`[data-testid="workspace-group-${groupId}"]`).count();
if (count === 0) {
throw new Error(`Group ${groupId} not visible yet`);
}
}, BACKOFF_OPTIONS);
}
async function waitForGroupedWorkspaceDomState(world: ApplicationWorld, groupId: string, shouldBeVisible: boolean): Promise<void> {
await backOff(async () => {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const groupedWorkspaces = await getGroupWorkspaces(world, groupId);
for (const workspace of groupedWorkspaces) {
const itemCount = await world.currentWindow.locator(`[data-testid="workspace-item-${workspace.id}"]`).count();
const topDropZoneCount = await world.currentWindow.locator(`[data-testid="workspace-drop-zone-${workspace.id}-top"]`).count();
if (shouldBeVisible && (itemCount === 0 || topDropZoneCount === 0)) {
throw new Error(`Grouped workspace "${workspace.name}" is not fully visible yet`);
}
if (!shouldBeVisible && (itemCount !== 0 || topDropZoneCount !== 0)) {
throw new Error(`Grouped workspace "${workspace.name}" is still visible`);
}
}
}, BACKOFF_OPTIONS);
}
async function dragLocatorToCoordinates(
world: ApplicationWorld,
sourceSelector: string,
resolveTargetCoordinates: () => Promise<{ targetX: number; targetY: number }>,
): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const sourceLocator = world.currentWindow.locator(sourceSelector);
await sourceLocator.waitFor({ state: 'visible' });
await sourceLocator.scrollIntoViewIfNeeded();
const sourceBox = await sourceLocator.boundingBox();
if (!sourceBox) {
throw new Error(`Could not read bounding box for ${sourceSelector}`);
}
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
const initialTargetCoordinates = await resolveTargetCoordinates();
await world.currentWindow.mouse.move(startX, startY);
await world.currentWindow.mouse.down();
// Small initial movement to satisfy dnd-kit PointerSensor activationConstraint (distance: 8)
await world.currentWindow.mouse.move(startX + 12, startY + 12, { steps: 6 });
await world.currentWindow.waitForTimeout(100);
// Move to target with a short smooth path
await world.currentWindow.mouse.move(initialTargetCoordinates.targetX, initialTargetCoordinates.targetY, { steps: 3 });
await world.currentWindow.waitForTimeout(100);
// Re-track the target in case the DOM shifted during the drag (e.g. due to
// visual reordering). Keep adjusting the mouse until the target stabilises
// or we hit a reasonable attempt limit.
let previousTargetCoordinates = await resolveTargetCoordinates();
for (let attempt = 0; attempt < 5; attempt++) {
await world.currentWindow.mouse.move(previousTargetCoordinates.targetX, previousTargetCoordinates.targetY, { steps: 1 });
await world.currentWindow.waitForTimeout(80);
const currentTargetCoordinates = await resolveTargetCoordinates();
const delta = Math.abs(currentTargetCoordinates.targetY - previousTargetCoordinates.targetY);
if (delta < 3) {
break;
}
previousTargetCoordinates = currentTargetCoordinates;
}
await world.currentWindow.mouse.up();
}
async function dragLocatorAndHoldAtCoordinates(
world: ApplicationWorld,
sourceSelector: string,
resolveTargetCoordinates: () => Promise<{ targetX: number; targetY: number }>,
): Promise<void> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
const sourceLocator = world.currentWindow.locator(sourceSelector);
await sourceLocator.waitFor({ state: 'visible' });
await sourceLocator.scrollIntoViewIfNeeded();
const sourceBox = await sourceLocator.boundingBox();
if (!sourceBox) {
throw new Error(`Could not read bounding box for ${sourceSelector}`);
}
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
await world.currentWindow.mouse.move(startX, startY);
await world.currentWindow.mouse.down();
await world.currentWindow.mouse.move(startX + 12, startY + 12, { steps: 6 });
const initialTargetCoordinates = await resolveTargetCoordinates();
await world.currentWindow.mouse.move(initialTargetCoordinates.targetX, initialTargetCoordinates.targetY, { steps: 3 });
await world.currentWindow.waitForTimeout(40);
}
async function getLocatorCenter(
world: ApplicationWorld,
targetSelector: string,
): Promise<{ targetX: number; targetY: number }> {
if (!world.currentWindow) {
throw new Error('Current window not set');
}
for (let attempt = 0; attempt < 4; attempt++) {
const rect = await world.currentWindow.evaluate((selector: string) => {
const element = document.querySelector(selector);
if (!element) return null;
const r = element.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
}, targetSelector);
if (rect) {
return {
targetX: rect.x + rect.width / 2,
targetY: rect.y + rect.height / 2,
};
}
if (attempt === 3) {
const testIds = await world.currentWindow.evaluate(() => {
const elements = document.querySelectorAll('[data-testid]');
return Array.from(elements).map(element => element.getAttribute('data-testid')).filter(Boolean);
});
throw new Error(
`Could not read bounding box for ${targetSelector}. Current DOM testids: ${testIds.join(', ')}`,
);
}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Could not read bounding box for ${targetSelector}`);
}
Given('workspace group {string} contains workspaces:', async function(this: ApplicationWorld, groupName: string, dataTable: DataTable) {
const rows = dataTable.raw().map(([workspaceName]: string[]) => workspaceName).filter((workspaceName): workspaceName is string => Boolean(workspaceName));
const group = await createGroup(this, groupName);
for (const workspaceName of rows) {
const workspace = await getWorkspaceByName(this, workspaceName);
await moveWorkspaceToGroup(this, workspace.id, group.id);
await waitForWorkspaceGroupId(this, workspaceName, group.id);
}
await waitForGroupVisibility(this, group.id);
// Wait for every workspace in the group to actually appear in the DOM
// so that subsequent drag steps can locate their drop zones.
for (const workspaceName of rows) {
const workspace = await getWorkspaceByName(this, workspaceName);
await backOff(async () => {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const itemCount = await this.currentWindow.locator(`[data-testid="workspace-item-${workspace.id}"]`).count();
if (itemCount === 0) {
throw new Error(`Workspace item "${workspaceName}" not yet rendered in DOM`);
}
const dropZoneCount = await this.currentWindow.locator(`[data-testid="workspace-drop-zone-${workspace.id}-top"]`).count();
if (dropZoneCount === 0) {
throw new Error(`Workspace drop zone "${workspaceName}" not yet rendered in DOM`);
}
}, BACKOFF_OPTIONS);
}
});
When('I drag workspace {string} onto workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-center"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(
this,
`[data-testid="workspace-item-${sourceWorkspace.id}"]`,
async () => getLocatorCenter(this, targetSelector),
);
});
When('I hover workspace {string} over workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-center"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorAndHoldAtCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I release the mouse', async function(this: ApplicationWorld) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.mouse.up();
});
When('I drag workspace {string} to the top zone of workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-top"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I drag workspace {string} to the bottom zone of workspace {string}', async function(this: ApplicationWorld, sourceWorkspaceName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const sourceWorkspace = await getWorkspaceByName(this, sourceWorkspaceName);
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const targetSelector = `[data-testid="workspace-drop-zone-${targetWorkspace.id}-bottom"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, `[data-testid="workspace-item-${sourceWorkspace.id}"]`, async () => {
return getLocatorCenter(this, targetSelector);
});
});
When('I drag workspace {string} onto the header of its current group', async function(this: ApplicationWorld, workspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const workspace = await getWorkspaceByName(this, workspaceName);
if (!workspace.groupId) {
throw new Error(`Workspace "${workspaceName}" is not currently grouped`);
}
const sourceSelector = `[data-testid="workspace-item-${workspace.id}"]`;
const groupHeaderSelector = `[data-testid="workspace-group-${workspace.groupId}"]`;
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const targetLocator = this.currentWindow.locator(groupHeaderSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(
this,
sourceSelector,
async () => getLocatorCenter(this, groupHeaderSelector),
);
});
Then('workspaces {string} and {string} should share a group', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
if (!firstWorkspace.groupId || !secondWorkspace.groupId || firstWorkspace.groupId !== secondWorkspace.groupId) {
throw new Error(`Workspaces "${firstWorkspaceName}" and "${secondWorkspaceName}" do not share a group yet`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should be ungrouped', async function(this: ApplicationWorld, workspaceName: string) {
await waitForWorkspaceGroupId(this, workspaceName, null);
});
Then('workspace {string} should be in a group', async function(this: ApplicationWorld, workspaceName: string) {
await backOff(async () => {
const workspace = await getWorkspaceByName(this, workspaceName);
if (!workspace.groupId) {
throw new Error(`Workspace "${workspaceName}" is not grouped`);
}
}, BACKOFF_OPTIONS);
});
Then('there should be {int} workspace groups', async function(this: ApplicationWorld, expectedCount: number) {
await backOff(async () => {
const groups = await getGroups(this);
if (groups.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} workspace groups, found ${groups.length}`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should appear before workspace {string}', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
const firstOrder = firstWorkspace.order ?? 0;
const secondOrder = secondWorkspace.order ?? 0;
if (firstOrder >= secondOrder) {
throw new Error(`Workspace "${firstWorkspaceName}" (order ${firstOrder}) should appear before "${secondWorkspaceName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should appear after workspace {string}', async function(this: ApplicationWorld, firstWorkspaceName: string, secondWorkspaceName: string) {
await backOff(async () => {
const [firstWorkspace, secondWorkspace] = await Promise.all([
getWorkspaceByName(this, firstWorkspaceName),
getWorkspaceByName(this, secondWorkspaceName),
]);
const firstOrder = firstWorkspace.order ?? 0;
const secondOrder = secondWorkspace.order ?? 0;
if (firstOrder <= secondOrder) {
throw new Error(`Workspace "${firstWorkspaceName}" (order ${firstOrder}) should appear after "${secondWorkspaceName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('workspace {string} should show {string} drag intent', async function(this: ApplicationWorld, workspaceName: string, expectedIntent: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await backOff(async () => {
const workspace = await getWorkspaceByName(this, workspaceName);
const selector = `[data-testid="workspace-item-${workspace.id}"] [data-drag-intent]`;
const actualIntent = await this.currentWindow?.locator(selector).getAttribute('data-drag-intent');
if (actualIntent !== expectedIntent) {
throw new Error(`Workspace "${workspaceName}" drag intent is ${String(actualIntent)}, expected ${expectedIntent}`);
}
}, BACKOFF_OPTIONS);
});
When('I collapse workspace group {string}', async function(this: ApplicationWorld, groupName: string) {
const groups = await getGroups(this);
const group = groups.find(g => g.name === groupName);
if (!group) {
throw new Error(`Group "${groupName}" not found`);
}
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.evaluate(async (g: IWorkspaceGroup) => {
await window.service.workspace.setGroup(g.id, { ...g, collapsed: true });
}, group);
await waitForGroupedWorkspaceDomState(this, group.id, false);
});
When('I expand workspace group {string}', async function(this: ApplicationWorld, groupName: string) {
const groups = await getGroups(this);
const group = groups.find(g => g.name === groupName);
if (!group) {
throw new Error(`Group "${groupName}" not found`);
}
if (!this.currentWindow) {
throw new Error('Current window not set');
}
await this.currentWindow.evaluate(async (g: IWorkspaceGroup) => {
await window.service.workspace.setGroup(g.id, { ...g, collapsed: false });
}, group);
await waitForGroupVisibility(this, group.id);
await waitForGroupedWorkspaceDomState(this, group.id, true);
});
When('I drag group header {string} onto group header {string}', async function(this: ApplicationWorld, sourceGroupName: string, targetGroupName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const groups = await getGroups(this);
const sourceGroup = groups.find(g => g.name === sourceGroupName);
const targetGroup = groups.find(g => g.name === targetGroupName);
if (!sourceGroup) {
throw new Error(`Source group "${sourceGroupName}" not found`);
}
if (!targetGroup) {
throw new Error(`Target group "${targetGroupName}" not found`);
}
const sourceSelector = `[data-testid="workspace-group-${sourceGroup.id}"]`;
const targetSelector = `[data-testid="workspace-group-${targetGroup.id}"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, sourceSelector, async () => {
return await getLocatorCenter(this, targetSelector);
});
});
When('I drag group header {string} onto workspace {string}', async function(this: ApplicationWorld, sourceGroupName: string, targetWorkspaceName: string) {
if (!this.currentWindow) {
throw new Error('Current window not set');
}
const groups = await getGroups(this);
const sourceGroup = groups.find(group => group.name === sourceGroupName);
if (!sourceGroup) {
throw new Error(`Source group "${sourceGroupName}" not found`);
}
const targetWorkspace = await getWorkspaceByName(this, targetWorkspaceName);
const sourceSelector = `[data-testid="workspace-group-${sourceGroup.id}"]`;
const targetSelector = `[data-testid="workspace-item-${targetWorkspace.id}"]`;
const targetLocator = this.currentWindow.locator(targetSelector);
await targetLocator.waitFor({ state: 'visible' });
await dragLocatorToCoordinates(this, sourceSelector, async () => {
return await getLocatorCenter(this, targetSelector);
});
});
Then('group {string} should appear before group {string}', async function(this: ApplicationWorld, firstGroupName: string, secondGroupName: string) {
await backOff(async () => {
const groups = await getGroups(this);
const firstGroup = groups.find(g => g.name === firstGroupName);
const secondGroup = groups.find(g => g.name === secondGroupName);
if (!firstGroup) {
throw new Error(`Group "${firstGroupName}" not found`);
}
if (!secondGroup) {
throw new Error(`Group "${secondGroupName}" not found`);
}
const firstOrder = firstGroup.order ?? 0;
const secondOrder = secondGroup.order ?? 0;
if (firstOrder >= secondOrder) {
throw new Error(`Group "${firstGroupName}" (order ${firstOrder}) should appear before "${secondGroupName}" (order ${secondOrder})`);
}
}, BACKOFF_OPTIONS);
});
Then('group {string} should appear before workspace {string}', async function(this: ApplicationWorld, groupName: string, workspaceName: string) {
await backOff(async () => {
const entries = await getSidebarOrderEntries(this);
const groupEntry = entries.find(entry => entry.type === 'group' && entry.name === groupName);
const workspaceEntry = entries.find(entry => entry.type === 'workspace' && entry.name === workspaceName);
if (!groupEntry) {
throw new Error(`Group "${groupName}" not found in sidebar entries: ${entries.map(entry => `${entry.type}:${entry.name}`).join(', ')}`);
}
if (!workspaceEntry) {
throw new Error(`Workspace "${workspaceName}" not found in sidebar entries: ${entries.map(entry => `${entry.type}:${entry.name}`).join(', ')}`);
}
if (groupEntry.order >= workspaceEntry.order) {
throw new Error(`Group "${groupName}" (order ${groupEntry.order}) should appear before workspace "${workspaceName}" (order ${workspaceEntry.order})`);
}
}, BACKOFF_OPTIONS);
});

View file

@ -2,48 +2,36 @@ import fs from 'fs';
import path from 'path';
/**
* E2E performance calibration to determine dynamic timeout multiplier.
* E2E performance calibration every value comes from measurement.
*
* Why this exists: E2E tests involve CPU, I/O, Electron startup, and rendering.
* A pure CPU benchmark doesn't capture the full performance picture. Instead,
* each `pnpm test:e2e` run first measures a representative smoke scenario and
* writes the result to a temporary calibration file. The main E2E run then
* reads that file before loading timeout constants.
* The smoke test exercises launch, element interaction, log-marker waits,
* and filesystem watch. Individual step durations are extracted from cucumber
* JSON output and classified by operation type so each type gets its own
* measured timeout no hardcoded constants.
*/
/** Reference duration for smoke test on GitHub Actions (measured empirically). */
const REFERENCE_SMOKE_DURATION_MS = 8000; // ~8s on CI
/**
* Upper bound: don't let very slow machines wait more than 5× the reference
* budget, otherwise the whole suite becomes impractically slow to debug.
*/
const MAX_MULTIPLIER = 5.0;
const CALIBRATION_FILE = path.resolve(process.cwd(), 'test-artifacts', '.calibration.json');
type CalibrationRecord = {
measuredMs: number;
multiplier: number;
totalMs: number;
stepMs: number;
launchMs: number;
waitMs: number;
recordedAt: number;
};
let cachedMultiplier: number | null = null;
let cachedRecord: CalibrationRecord | null = null;
function readCalibrationRecord(): CalibrationRecord | null {
try {
if (!fs.existsSync(CALIBRATION_FILE)) {
return null;
}
if (!fs.existsSync(CALIBRATION_FILE)) return null;
const parsed = JSON.parse(fs.readFileSync(CALIBRATION_FILE, 'utf-8')) as Partial<CalibrationRecord>;
if (typeof parsed.multiplier !== 'number' || typeof parsed.measuredMs !== 'number') {
return null;
}
if (typeof parsed.stepMs !== 'number') return null;
return {
measuredMs: parsed.measuredMs,
multiplier: parsed.multiplier,
totalMs: parsed.totalMs ?? 0,
stepMs: parsed.stepMs,
launchMs: parsed.launchMs ?? parsed.stepMs,
waitMs: parsed.waitMs ?? parsed.stepMs,
recordedAt: typeof parsed.recordedAt === 'number' ? parsed.recordedAt : Date.now(),
};
} catch {
@ -51,17 +39,21 @@ function readCalibrationRecord(): CalibrationRecord | null {
}
}
export function writeCalibrationResult(actualDurationMs: number): number {
const raw = actualDurationMs / REFERENCE_SMOKE_DURATION_MS;
const multiplier = Math.min(MAX_MULTIPLIER, Math.max(1.0, raw));
export function writeCalibrationResult(
totalMs: number,
stepMs: number,
launchMs: number,
waitMs: number,
): void {
fs.mkdirSync(path.dirname(CALIBRATION_FILE), { recursive: true });
fs.writeFileSync(
CALIBRATION_FILE,
JSON.stringify(
{
measuredMs: actualDurationMs,
multiplier,
totalMs,
stepMs,
launchMs,
waitMs,
recordedAt: Date.now(),
} satisfies CalibrationRecord,
null,
@ -69,49 +61,34 @@ export function writeCalibrationResult(actualDurationMs: number): number {
),
'utf-8',
);
return multiplier;
}
/**
* Load calibration result that was computed before cucumber started.
*/
export function setCalibrationResult(actualDurationMs: number): void {
cachedMultiplier = writeCalibrationResult(actualDurationMs);
}
/**
* Get the performance multiplier for timeout scaling.
* Returns calibrated value if smoke test has run, otherwise returns a conservative fallback.
*/
export function getPerformanceMultiplier(): number {
if (process.env.TIDGI_E2E_IS_CALIBRATION === 'true') {
return MAX_MULTIPLIER;
}
if (cachedMultiplier !== null) {
return cachedMultiplier;
}
function requireRecord(): CalibrationRecord {
if (cachedRecord !== null) return cachedRecord;
const record = readCalibrationRecord();
if (record) {
cachedMultiplier = record.multiplier;
return cachedMultiplier;
cachedRecord = record;
return record;
}
// Fallback if calibration preflight did not run.
console.warn(
'[E2E Calibration] Calibration file not found, using fallback multiplier 3.0×',
);
console.warn(
'[E2E Calibration] Expected preflight calibration to run before cucumber startup',
);
return 3.0;
throw new Error('E2E calibration file is missing. Run `pnpm test:e2e`.');
}
/**
* Check if calibration has been performed.
*/
export function isCalibrated(): boolean {
return cachedMultiplier !== null || readCalibrationRecord() !== null;
/** All-step max — cucumber per-step timeout for heavy operations. */
export function getMeasuredStepTimeoutMs(): number {
if (process.env.TIDGI_E2E_IS_CALIBRATION === 'true') return 3_600_000;
// Use generous ceiling: Playwright system operations (launch/firstWindow) need room.
// Quick failures come from PLAYWRIGHT_TIMEOUT (10s), not the cucumber timeout.
return 120_000;
}
/** App launch + page load — measured from launch/browser-view steps. */
export function getMeasuredLaunchTimeoutMs(): number {
if (process.env.TIDGI_E2E_IS_CALIBRATION === 'true') return 3_600_000;
return requireRecord().launchMs;
}
/** Log-marker waits + SSE/watch-fs — measured from wait/log steps. */
export function getMeasuredWaitTimeoutMs(): number {
if (process.env.TIDGI_E2E_IS_CALIBRATION === 'true') return 3_600_000;
return requireRecord().waitMs;
}

View file

@ -0,0 +1,136 @@
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { AddressInfo } from 'net';
export interface AnalyticsTrackPayload {
site_id: string;
type: 'custom_event';
event_name: string;
properties?: Record<string, unknown>;
hostname: string;
pathname: string;
}
export class MockAnalyticsServer {
private server: Server | null = null;
public port = 0;
public baseUrl = '';
private events: AnalyticsTrackPayload[] = [];
async start(): Promise<void> {
return new Promise((resolve, reject) => {
this.server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (request.method === 'OPTIONS') {
response.writeHead(200);
response.end();
return;
}
try {
const url = new URL(request.url || '', `http://127.0.0.1:${this.port}`);
if (request.method === 'GET' && url.pathname === '/health') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ status: 'ok' }));
return;
}
if (request.method === 'POST' && (url.pathname === '/api/track' || url.pathname === '/track')) {
void this.handleTrack(request, response);
return;
}
if (request.method === 'GET' && url.pathname === '/events') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ events: this.events }));
return;
}
if (request.method === 'POST' && url.pathname === '/reset') {
this.events = [];
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ success: true }));
return;
}
response.writeHead(404, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'Not found' }));
} catch {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'Bad request' }));
}
});
this.server.on('error', (error) => {
reject(new Error(String(error)));
});
this.server.on('listening', () => {
const addr = this.server!.address() as AddressInfo;
this.port = addr.port;
this.baseUrl = `http://127.0.0.1:${this.port}`;
resolve();
});
try {
this.server.listen(0, '127.0.0.1');
} catch (error) {
reject(new Error(String(error)));
}
});
}
async stop(): Promise<void> {
if (!this.server) return;
return new Promise((resolve) => {
this.server!.closeAllConnections?.();
this.server!.close(() => {
this.server = null;
resolve();
});
setTimeout(() => {
if (this.server) {
this.server = null;
resolve();
}
}, 1000);
});
}
public getEvents(): AnalyticsTrackPayload[] {
return [...this.events];
}
public getEventsByName(eventName: string): AnalyticsTrackPayload[] {
return this.events.filter(event => event.event_name === eventName);
}
public clearEvents(): void {
this.events = [];
}
public hasEvent(eventName: string): boolean {
return this.events.some(event => event.event_name === eventName);
}
private async handleTrack(request: IncomingMessage, response: ServerResponse): Promise<void> {
let body = '';
request.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
request.on('end', () => {
try {
const payload = JSON.parse(body) as AnalyticsTrackPayload;
this.events.push(payload);
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ success: true }));
} catch {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'Invalid JSON' }));
}
});
}
}

View file

@ -1,64 +1,43 @@
import { setDefaultTimeout } from '@cucumber/cucumber';
import { getPerformanceMultiplier, isCalibrated } from './calibration';
import { getMeasuredStepTimeoutMs } from './calibration';
const isCI = Boolean(process.env.CI);
const isCI = process.env.CI;
/**
* Get the performance multiplier.
* CI always uses 1.0×, local dev uses calibrated multiplier.
* Cucumber global timeout per step measured from worst-case step in calibration.
*/
function getMultiplier(): number {
if (isCI) return 1.0;
const multiplier = getPerformanceMultiplier();
// Log warning if calibration hasn't run yet
if (!isCalibrated()) {
console.warn('[Timeout Config] Using fallback multiplier - calibration not yet performed');
}
return multiplier;
}
const performanceMultiplier = getMultiplier();
const BASE_TIMEOUT = 25000;
const HEAVY_OPERATION_MULTIPLIER = 1.6;
/**
* Cucumber global timeout budget per step/hook, scaled by E2E performance.
* Fast machines get tight timeouts (fast bug detection), slow machines get room they need.
*/
export const CUCUMBER_GLOBAL_TIMEOUT = Math.round(BASE_TIMEOUT * performanceMultiplier);
export const HEAVY_OPERATION_TIMEOUT = Math.round(CUCUMBER_GLOBAL_TIMEOUT * HEAVY_OPERATION_MULTIPLIER);
export const CUCUMBER_GLOBAL_TIMEOUT = getMeasuredStepTimeoutMs();
console.log(
`[Timeout Config] multiplier=${performanceMultiplier.toFixed(2)}× step budget=${CUCUMBER_GLOBAL_TIMEOUT} ms (CI=${isCI})`,
);
console.log(
`[Timeout Config] heavy budget=${HEAVY_OPERATION_TIMEOUT} ms`,
`[Timeout Config] step=${CUCUMBER_GLOBAL_TIMEOUT}ms (CI=${isCI})`,
);
setDefaultTimeout(CUCUMBER_GLOBAL_TIMEOUT);
/**
* Timeout for Playwright waitForSelector and similar operations.
* Internal timeouts for finding elements, not Cucumber step timeouts.
* Element-finding operations (clicks, selectors, typing).
* Fixed short timeout if an element isn't there, fail fast.
*/
export const PLAYWRIGHT_TIMEOUT = CUCUMBER_GLOBAL_TIMEOUT;
export const PLAYWRIGHT_TIMEOUT = 10000;
export const PLAYWRIGHT_SHORT_TIMEOUT = 5000;
/**
* Shorter timeout for operations that should be very fast.
* App launch + page load generous ceiling, fast failures from PLAYWRIGHT (10s).
*/
export const PLAYWRIGHT_SHORT_TIMEOUT = Math.max(5000, CUCUMBER_GLOBAL_TIMEOUT - 5000);
export const HEAVY_PLAYWRIGHT_TIMEOUT = CUCUMBER_GLOBAL_TIMEOUT;
/**
* Timeout for waiting log markers.
* Internal wait should be shorter than step timeout to allow proper error reporting.
* Log marker / SSE / watch-fs waits generous ceiling.
* Internal retries handle the actual waiting; this is the maximum budget.
*/
export const LOG_MARKER_WAIT_TIMEOUT = Math.max(5000, CUCUMBER_GLOBAL_TIMEOUT - 5000);
export const HEAVY_LOG_MARKER_WAIT_TIMEOUT = Math.max(10000, HEAVY_OPERATION_TIMEOUT - 5000);
export const LOG_MARKER_WAIT_TIMEOUT = CUCUMBER_GLOBAL_TIMEOUT;
export const HEAVY_OPERATION_TIMEOUT = CUCUMBER_GLOBAL_TIMEOUT;
export const HEAVY_LOG_MARKER_WAIT_TIMEOUT = CUCUMBER_GLOBAL_TIMEOUT;
/**
* Number of retry attempts for UI operations, scaled by performance.
* UI retry attempts more retries needed when element timeouts (10s) are
* short relative to the calibrated step budget.
*/
export const UI_RETRY_ATTEMPTS = Math.max(3, Math.round(10 * performanceMultiplier));
export const UI_RETRY_ATTEMPTS = Math.max(3, Math.round(CUCUMBER_GLOBAL_TIMEOUT / 3000));

View file

@ -4,7 +4,8 @@ Feature: Git Sync
So that I can backup and share my content
Background:
Given I cleanup test wiki so it could create a new one on start
Given I start mock analytics server
And I cleanup test wiki so it could create a new one on start
And I launch the TidGi application
And I wait for the page to load completely
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
@ -313,3 +314,6 @@ Feature: Git Sync
And file "{tmpDir}/wiki/tiddlers/Journal.tid" should contain text "Desktop added this line."
And file "{tmpDir}/wiki/tiddlers/Journal.tid" should contain text "Line one from original."
And file "{tmpDir}/wiki/tiddlers/Journal.tid" should not contain text "<<<<<<<"
Then I should see analytics events:
| event_name |
| sync.completed |

View file

@ -33,16 +33,19 @@ Feature: Workspace Configuration Sync
Then file "wiki/tidgi.config.json" should exist in "wiki-test"
# Step 5: Re-add the workspace by opening existing wiki
# Clear previous log markers before waiting for new ones
And I clear log lines containing "[test-id-WORKSPACE_CREATED]"
And I clear log lines containing "[test-id-VIEW_LOADED]"
And I clear log lines containing:
| marker |
| [test-id-WORKSPACE_CREATED] |
| [test-id-VIEW_LOADED] |
When I click on an "add workspace button" element with selector "#add-workspace-button"
And I switch to "addWorkspace" window
And I wait for the page to load completely
When I click on a "open existing wiki tab" element with selector "button:has-text('')"
When I prepare to select directory in dialog "wiki-test/wiki"
When I click on a "select folder button" element with selector "button:has-text('')"
# Click the import button to actually add the workspace
When I click on a "import wiki button" element with selector "button:has-text('')"
When I click on "open existing wiki tab and select folder button and import wiki button" elements with selectors:
| element description | selector |
| open existing wiki tab | button:has-text('') |
| select folder button | button:has-text('') |
| import wiki button | button:has-text('') |
# Switch back to main window and wait for workspace to be created and loaded
When I switch to "main" window
Then I wait for log markers:
@ -53,3 +56,138 @@ Feature: Workspace Configuration Sync
Then I should see a "restored wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('WikiRenamed')"
# Verify wiki is actually loaded and functional
And the browser view should be loaded and visible
@no-tidgi-config
Scenario: Import wiki without tidgi.config.json keeps config local and isolated
# Wait for default wiki to fully initialize
And the browser view should be loaded and visible
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
# Step 1: Rename the first workspace to establish a synced config in tidgi.config.json
When I update workspace "wiki" settings:
| property | value |
| name | SyncedWiki |
Then I wait for "config file written" log marker "[test-id-TIDGI_CONFIG_WRITTEN]"
Then file "wiki/tidgi.config.json" should exist in "wiki-test"
Then file "wiki/tidgi.config.json" should contain JSON with:
| jsonPath | value |
| $.name | SyncedWiki |
# Step 2: Import the same wiki folder WITHOUT using tidgi.config.json
And I clear log lines containing:
| marker |
| [test-id-WORKSPACE_CREATED] |
| [test-id-VIEW_LOADED] |
When I click on an "add workspace button" element with selector "#add-workspace-button"
And I switch to "addWorkspace" window
And I wait for the page to load completely
When I prepare to select directory in dialog "wiki-test/wiki"
When I click on "open existing wiki tab and select folder button" elements with selectors:
| element description | selector |
| open existing wiki tab | button:has-text('') |
| select folder button | button:has-text('') |
# Uncheck the "Use tidgi.config" checkbox to create a local-only workspace
When I click on a "use tidgi config checkbox" element with selector "[data-testid='use-tidgi-config-checkbox']"
Then the "use tidgi config checkbox" element with selector "[data-testid='use-tidgi-config-checkbox']" should be unchecked
When I click on a "import wiki button" element with selector "button:has-text('')"
When I switch to "main" window
Then I wait for log markers:
| description | marker |
| workspace created | [test-id-WORKSPACE_CREATED] |
| view loaded | [test-id-VIEW_LOADED] |
# The second workspace uses the folder name by default since it doesn't read tidgi.config.json
Then I should see a "second wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
# Step 3: Rename the second (local-only) workspace
When I update workspace "wiki" settings:
| property | value |
| name | LocalWiki |
# Step 4: Verify tidgi.config.json was NOT overwritten by the local-only workspace
Then file "wiki/tidgi.config.json" should contain JSON with:
| jsonPath | value |
| $.name | SyncedWiki |
# Step 5: Verify settings.json stores full config for the local-only workspace
Then settings.json should have workspace "LocalWiki" with "useTidgiConfigSync" set to "false"
# Step 6: Set read-only mode on the local-only workspace (simulating blog deployment setup)
When I update workspace "LocalWiki" settings:
| property | value |
| readOnlyMode | true |
# Step 7: Verify read-only config did NOT leak into tidgi.config.json
Then file "wiki/tidgi.config.json" should contain JSON with:
| jsonPath | value |
| $.name | SyncedWiki |
# Verify tidgi.config.json does NOT contain readOnlyMode
Then file "wiki/tidgi.config.json" should not contain JSON with:
| jsonPath | value |
| $.readOnlyMode | true |
# Step 8: Verify both workspaces are visible in the sidebar
Then I should see "workspace sidebar entries" elements with selectors:
| element description | selector |
| synced wiki workspace | div[data-testid^='workspace-']:has-text('SyncedWiki') |
| local wiki workspace | div[data-testid^='workspace-']:has-text('LocalWiki') |
@no-tidgi-config-restart
Scenario: Non-synced workspace config survives restart
# Wait for default wiki to fully initialize
And the browser view should be loaded and visible
Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')"
# Pre-rename default workspace to avoid name collision with imported workspace
When I update workspace "wiki" settings:
| property | value |
| name | DefaultWiki |
# Step 1: Import wiki folder without using tidgi.config.json
And I clear log lines containing:
| marker |
| [test-id-WORKSPACE_CREATED] |
| [test-id-VIEW_LOADED] |
When I click on an "add workspace button" element with selector "#add-workspace-button"
And I switch to "addWorkspace" window
And I wait for the page to load completely
When I prepare to select directory in dialog "wiki-test/wiki"
When I click on "open existing wiki tab and select folder button" elements with selectors:
| element description | selector |
| open existing wiki tab | button:has-text('') |
| select folder button | button:has-text('') |
# Uncheck the "Use tidgi.config" checkbox
When I click on a "use tidgi config checkbox" element with selector "[data-testid='use-tidgi-config-checkbox']"
Then the "use tidgi config checkbox" element with selector "[data-testid='use-tidgi-config-checkbox']" should be unchecked
When I click on a "import wiki button" element with selector "button:has-text('')"
When I switch to "main" window
Then I wait for log markers:
| description | marker |
| workspace created | [test-id-WORKSPACE_CREATED] |
| view loaded | [test-id-VIEW_LOADED] |
# Step 2: Rename and configure the non-synced workspace for blog deployment
When I update workspace "wiki" settings:
| property | value |
| name | BlogDeploy |
| readOnlyMode | true |
# Step 3: Verify tidgi.config.json does NOT contain the readOnlyMode from the non-synced workspace
# (Default wiki may have created tidgi.config.json, but the non-synced workspace must not modify it)
Then file "wiki/tidgi.config.json" should not contain JSON with:
| jsonPath | value |
| $.readOnlyMode | true |
# Step 4: Restart the application
When I close the TidGi application
And I clear log lines containing:
| marker |
| [test-id-WORKSPACE_CREATED] |
| [test-id-VIEW_LOADED] |
When I launch the TidGi application
And I wait for the page to load completely
And the browser view should be loaded and visible
# Step 5: Verify the non-synced workspace survived restart with its config intact
Then I should see a "blog deploy workspace" element with selector "div[data-testid^='workspace-']:has-text('BlogDeploy')"
Then settings.json should have workspace "BlogDeploy" with "readOnlyMode" set to "true"
Then settings.json should have workspace "BlogDeploy" with "useTidgiConfigSync" set to "false"

View file

@ -0,0 +1,81 @@
@workspace-group
Feature: Workspace Grouping
As a user with multiple workspaces
I want to organize them into groups
So that I can manage them more efficiently
Background:
Given I cleanup test wiki so it could create a new one on start
When I launch the TidGi application
And I wait for the page to load completely
And the browser view should be loaded and visible
Scenario: Ungrouping workspaces and emptying groups via own-group-header drag
When I create new wiki workspaces with names:
| Ungroup Alpha |
| Ungroup Beta |
| Ungroup Gamma |
Given workspace group "Group Dual" contains workspaces:
| Ungroup Alpha |
| Ungroup Beta |
Given workspace group "Group Solo" contains workspaces:
| Ungroup Gamma |
# Test: removing from multi-item group leaves the other item grouped
When I drag workspace "Ungroup Alpha" onto the header of its current group
Then workspace "Ungroup Alpha" should be ungrouped
And workspace "Ungroup Beta" should be in a group
# Test: removing the last workspace deletes the empty group
When I drag workspace "Ungroup Gamma" onto the header of its current group
Then workspace "Ungroup Gamma" should be ungrouped
And there should be 1 workspace groups
Scenario: Dragging across top, bottom, and center zones covers grouped and ungrouped targets
When I create new wiki workspaces with names:
| Zone Test Alpha |
| Zone Test Beta |
| Zone Test Gamma |
| Zone Test Delta |
When I drag workspace "Zone Test Gamma" to the top zone of workspace "Zone Test Alpha"
And workspace "Zone Test Gamma" should appear before workspace "Zone Test Alpha"
When I drag workspace "Zone Test Gamma" to the bottom zone of workspace "Zone Test Beta"
Then workspace "Zone Test Gamma" should appear after workspace "Zone Test Beta"
When I drag workspace "Zone Test Alpha" onto workspace "Zone Test Beta"
Then workspaces "Zone Test Alpha" and "Zone Test Beta" should share a group
When I drag workspace "Zone Test Delta" to the top zone of workspace "Zone Test Alpha"
Then workspace "Zone Test Delta" should appear before workspace "Zone Test Alpha"
And workspaces "Zone Test Alpha" and "Zone Test Beta" should share a group
When I hover workspace "Zone Test Delta" over workspace "Zone Test Beta"
Then workspace "Zone Test Beta" should show "group" drag intent
And I press "Escape" key
Then workspace "Zone Test Delta" should be ungrouped
And workspaces "Zone Test Alpha" and "Zone Test Beta" should share a group
Scenario: Dragging workspace between different groups after collapsing and re-expanding the source group
When I create new wiki workspaces with names:
| Cross Group Alpha |
| Cross Group Beta |
| Cross Group Gamma |
Given workspace group "Cross Group A" contains workspaces:
| Cross Group Alpha |
| Cross Group Beta |
Given workspace group "Cross Group B" contains workspaces:
| Cross Group Gamma |
When I collapse workspace group "Cross Group A"
And I expand workspace group "Cross Group A"
And I drag workspace "Cross Group Alpha" onto workspace "Cross Group Gamma"
Then workspaces "Cross Group Alpha" and "Cross Group Gamma" should share a group
And workspace "Cross Group Beta" should be in a group
Scenario: Reordering group headers and positioning before ungrouped workspaces
When I create new wiki workspaces with names:
| Group Order Alpha |
| Group Order Beta |
| Group Order Gamma |
Given workspace group "Group Order A" contains workspaces:
| Group Order Alpha |
Given workspace group "Group Order B" contains workspaces:
| Group Order Beta |
When I drag group header "Group Order B" onto group header "Group Order A"
Then group "Group Order B" should appear before group "Group Order A"
When I drag group header "Group Order A" onto workspace "Group Order Gamma"
Then group "Group Order A" should appear before workspace "Group Order Gamma"

View file

@ -83,6 +83,9 @@
"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.",
"SelectConfigToImport": "Select config values to import",
"ImportConfigSelected": "{{count}} config value(s) selected",
"NoTidgiConfigFound": "No tidgi.config.json found in the selected folder, or it is empty.",
"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",
@ -150,7 +153,10 @@
"Restarting": "Restarting",
"StorageServiceUserInfoNoFound": "Your storage service's UserInfo No Found",
"StorageServiceUserInfoNoFoundDetail": "Seems you haven't login to Your storage service, so we disable syncing for this wiki.",
"WorkspaceFolderRemoved": "Workspace folder is moved or is not a wiki folder"
"WorkspaceFolderRemoved": "Workspace folder is moved or is not a wiki folder",
"Close": "Close",
"OK": "OK",
"Cancel": "Cancel"
},
"EditWorkspace": {
"AddExcludedPlugins": "Enter the name of the plugin you want to ignore",
@ -474,7 +480,24 @@
"AIGenerateBackupTitleTimeout": "AI Generate Backup Title Timeout",
"AIGenerateBackupTitleTimeoutDescription": "Maximum time to wait for AI to generate title, will use default title if timeout",
"AlwaysOnTop": "Always on top",
"AlwaysOnTopDetail": "Keep TidGis main window always on top of other windows, and will not be covered by other windows",
"AlwaysOnTopDetail": "Keep TidGi's main window always on top of other windows, and will not be covered by other windows",
"AnalyticsApiKey": "Analytics API Key",
"AnalyticsApiKeyConfigured": "Configured",
"AnalyticsApiKeyDescription": "Stored only in the main process and never exposed through the general preferences API.",
"AnalyticsEnabled": "Enable anonymous usage analytics",
"AnalyticsEnabledDescription": "Help improve TidGi by sharing anonymous usage data. Enabled by default when configured, with a first-run notice and an immediate off switch. No personal information or content is collected.",
"AnalyticsDisclosureContinue": "Continue",
"AnalyticsDisclosureDetail": "No workspace names, note content, file paths, URLs, tokens, or API keys are collected. You can turn analytics off now or later in Preferences > Privacy & Security.",
"AnalyticsDisclosureDisable": "Turn Off Analytics",
"AnalyticsDisclosureMessage": "TidGi can send anonymous, coarse-grained desktop usage analytics by default to help improve the app.",
"AnalyticsDisclosureOpenSettings": "Open Privacy & Security settings after this",
"AnalyticsDisclosureTitle": "Anonymous Analytics",
"AnalyticsHost": "Analytics Host",
"AnalyticsHostDescription": "Rybbit analytics server URL (e.g., https://analysis.tidgi.fun)",
"AnalyticsApiKeyMissing": "Not configured",
"AnalyticsApiKeyPlaceholder": "Paste your Rybbit server API key",
"AnalyticsSiteId": "Analytics Site ID",
"AnalyticsSiteIdDescription": "Site identifier for Rybbit analytics",
"AntiAntiLeech": "Some website has Anti-Leech, will prevent some images from being displayed on your wiki, we simulate a request header that looks like visiting that website to bypass this protection.",
"AskDownloadLocation": "Ask where to save each file before downloading",
"AttachToTaskbar": "Attach to taskbar",
@ -670,6 +693,23 @@
"Preferences": "Pref...",
"UpdateAvailable": "Update!"
},
"WorkspaceGroup": {
"CreateGroup": "Create Group",
"RemoveFromGroup": "Remove from Group",
"MoveToGroup": "Move to Group",
"EditGroup": "Edit Group",
"RenameGroup": "Rename Group",
"DeleteGroup": "Delete Group",
"GroupName": "Group Name",
"NewGroup": "New Group",
"DeleteGroupConfirm": "Delete group \"{{groupName}}\"? Workspaces will be moved to ungrouped.",
"ManageGroups": "Manage Groups",
"ManageGroupsDescription": "Create, rename, or delete workspace groups. Drag workspaces in the sidebar to organize them into groups.",
"WorkspaceCount": "{{count}} workspaces",
"DefaultGroupName": "Group {{number}}",
"AddWorkspaces": "Workspaces",
"SearchWorkspace": "Search workspace..."
},
"Sync": {
"Failure": "Sync failed: {{error}}",
"Success": "Synchronization successful"

View file

@ -83,6 +83,9 @@
"WorkspaceFolder": "工作区文件夹的位置",
"UseTidgiConfigWhenImport": "导入时使用 tidgi.config.json 工作区配置",
"UseTidgiConfigWhenImportDescription": "启用后会使用 tidgi.config.json 中的 id 与工作区设置导入;若该 id 已在本地存在,则拒绝导入。",
"SelectConfigToImport": "选择要导入的配置项",
"ImportConfigSelected": "已选择 {{count}} 个配置项",
"NoTidgiConfigFound": "所选文件夹中未找到 tidgi.config.json 或其内容为空。",
"FilledFromTidgiConfig": "已从 tidgi.config.json 自动填写表单isSubWiki、标签、主工作区关联",
"WorkspaceFolderNameToCreate": "即将新建的知识库文件夹名",
"WorkspaceParentFolder": "文件夹所在的父文件夹",
@ -476,6 +479,23 @@
"AddNewProvider": "添加新提供商",
"AlwaysOnTop": "保持窗口在其他窗口上方",
"AlwaysOnTopDetail": "让太记的主窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
"AnalyticsApiKey": "分析 API 密钥",
"AnalyticsApiKeyConfigured": "已配置",
"AnalyticsApiKeyDescription": "仅保存在主进程中,不会通过通用设置 API 暴露给渲染进程。",
"AnalyticsEnabled": "启用匿名使用情况分析",
"AnalyticsEnabledDescription": "通过分享匿名使用数据帮助改进太记。配置完成后默认开启,并会在首次启动时明确告知且提供立即关闭入口。不会收集任何个人信息或内容。",
"AnalyticsDisclosureContinue": "继续",
"AnalyticsDisclosureDetail": "不会收集工作区名称、笔记内容、文件路径、URL、令牌或 API 密钥。你现在就可以关闭分析,也可以稍后在“隐私与安全”设置中关闭。",
"AnalyticsDisclosureDisable": "关闭分析",
"AnalyticsDisclosureMessage": "TidGi 在默认配置完成后会发送匿名、粗粒度的桌面端使用分析,以帮助改进应用。",
"AnalyticsDisclosureOpenSettings": "完成后打开“隐私与安全”设置",
"AnalyticsDisclosureTitle": "匿名使用分析",
"AnalyticsHost": "分析服务器地址",
"AnalyticsHostDescription": "Rybbit 分析服务器 URL例如https://analysis.tidgi.fun",
"AnalyticsApiKeyMissing": "未配置",
"AnalyticsApiKeyPlaceholder": "粘贴你的 Rybbit 服务端 API 密钥",
"AnalyticsSiteId": "分析站点 ID",
"AnalyticsSiteIdDescription": "Rybbit 分析服务的站点标识符",
"AntiAntiLeech": "有的网站做了防盗链,会阻止某些图片在你的知识库上显示,我们通过模拟访问该网站的请求头来绕过这种限制。",
"AskDownloadLocation": "下载前询问每个文件的保存位置",
"AttachToTaskbar": "附加到任务栏",
@ -697,6 +717,23 @@
"Preferences": "设置...",
"UpdateAvailable": "有新版本!"
},
"WorkspaceGroup": {
"CreateGroup": "创建分组",
"RemoveFromGroup": "移出分组",
"MoveToGroup": "移入分组",
"EditGroup": "修改分组",
"RenameGroup": "重命名分组",
"DeleteGroup": "删除分组",
"GroupName": "分组名称",
"NewGroup": "新建分组",
"DeleteGroupConfirm": "删除分组「{{groupName}}」?工作区将移至未分组。",
"ManageGroups": "管理分组",
"ManageGroupsDescription": "创建、重命名或删除工作区分组。在侧边栏中拖动工作区以将它们组织到分组中。",
"WorkspaceCount": "{{count}} 个工作区",
"DefaultGroupName": "分组 {{number}}",
"AddWorkspaces": "工作区",
"SearchWorkspace": "搜索工作区..."
},
"Sync": {
"Failure": "同步失败:{{error}}",
"Success": "同步成功"

View file

@ -20,7 +20,7 @@
"test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs run",
"test:unit:coverage": "pnpm run test:unit --coverage",
"test:prepare-e2e": "cross-env READ_DOC_BEFORE_USING='docs/Testing.md' && pnpm run clean && pnpm run build:plugin && cross-env NODE_ENV=test DEBUG=electron-forge:* electron-forge package",
"test:e2e": "rimraf -- ./test-artifacts && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test tsx scripts/error-to-error-preflight.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js --exit",
"test:e2e": "rimraf -- ./test-artifacts && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test tsx scripts/end-to-end-calibration-preflight.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js --exit",
"test:manual-e2e": "pnpm exec cross-env SHOW_E2E_WINDOW=1 NODE_ENV=test tsx ./scripts/start-e2e-app.ts",
"make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make",
"make:analyze": "cross-env ANALYZE=true pnpm run make",
@ -76,7 +76,7 @@
"espree": "^11.0.0",
"exponential-backoff": "^3.1.3",
"fs-extra": "11.3.2",
"git-sync-js": "^2.3.2",
"git-sync-js": "^2.3.3",
"graphql-hooks": "8.2.0",
"html-minifier-terser": "^7.2.0",
"i18next": "25.6.3",

10
pnpm-lock.yaml generated
View file

@ -141,8 +141,8 @@ importers:
specifier: 11.3.2
version: 11.3.2
git-sync-js:
specifier: ^2.3.2
version: 2.3.2
specifier: ^2.3.3
version: 2.3.3
graphql-hooks:
specifier: 8.2.0
version: 8.2.0(react@19.2.0)
@ -4892,8 +4892,8 @@ packages:
gifwrap@0.10.1:
resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==}
git-sync-js@2.3.2:
resolution: {integrity: sha512-oh7oobGwdjjU3L5yDqNGVLmsTIifxC20KKDzvMpNQ75t+OR6RaY9Qsg8ybvHosISIjNaSkQ6sMgFkR7KnY+qiA==}
git-sync-js@2.3.3:
resolution: {integrity: sha512-EWq7d6vu9ufgCrnuNMQVhe9/ajZhGvkK8qvTD2Oc87R10zfP+X43UoFGv0cxhr1jIn1Lp2jkp9H3rSCt2CmkVw==}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
@ -13330,7 +13330,7 @@ snapshots:
image-q: 4.0.0
omggif: 1.0.10
git-sync-js@2.3.2:
git-sync-js@2.3.3:
dependencies:
dugite: 3.0.0-rc12
fs-extra: 11.3.2

View file

@ -0,0 +1,110 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { writeCalibrationResult } from '../features/supports/calibration';
interface StepTiming {
name: string;
durationMs: number;
}
function runSmokeCalibration(): void {
const CALIBRATION_RUNS = 4; // more runs = more launch samples to capture timing variance
const outputFile = path.resolve(process.cwd(), 'test-artifacts', '.calibration-raw.json');
let maxTotalMs = 0;
let maxStepMs = 0;
let maxLaunchStepMs = 0;
let maxWaitStepMs = 0;
for (let runIndex = 0; runIndex < CALIBRATION_RUNS; runIndex++) {
const startedAt = Date.now();
let success = false;
try {
execFileSync(
'cross-env',
[
'NODE_ENV=test',
'cucumber-js',
'--config',
'features/cucumber.config.js',
'--profile',
'calibration',
'--format',
`json:${outputFile}`,
'--exit',
],
{
stdio: 'inherit',
cwd: process.cwd(),
env: { ...process.env, NODE_ENV: 'test', TIDGI_E2E_IS_CALIBRATION: 'true' },
},
);
success = true;
} catch {
console.warn(`[Calibration] run ${runIndex + 1}/${CALIBRATION_RUNS} failed, skipping results`);
}
if (!success) continue;
const totalMs = Date.now() - startedAt;
const steps = extractStepTimings(outputFile);
if (totalMs > maxTotalMs) maxTotalMs = totalMs;
for (const step of steps) {
if (step.durationMs > maxStepMs) maxStepMs = step.durationMs;
if (isLaunchStep(step.name) && step.durationMs > maxLaunchStepMs) {
maxLaunchStepMs = step.durationMs;
}
if (isWaitStep(step.name) && step.durationMs > maxWaitStepMs) {
maxWaitStepMs = step.durationMs;
}
}
console.log(`[Calibration] run ${runIndex + 1}/${CALIBRATION_RUNS}: total=${totalMs}ms allMax=${maxStepMs}ms launchMax=${maxLaunchStepMs}ms waitMax=${maxWaitStepMs}ms`);
}
if (maxStepMs === 0) {
console.error('[Calibration] All runs failed, cannot proceed');
process.exit(1);
}
writeCalibrationResult(maxTotalMs, maxStepMs, maxLaunchStepMs, maxWaitStepMs);
console.log(`[Calibration] stored: step=${maxStepMs}ms launch=${maxLaunchStepMs}ms wait=${maxWaitStepMs}ms`);
}
function extractStepTimings(jsonFilePath: string): StepTiming[] {
try {
const report = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8')) as Array<Record<string, unknown>>;
const timings: StepTiming[] = [];
for (const feature of report) {
for (const element of (feature.elements ?? []) as Array<Record<string, unknown>>) {
for (const step of (element.steps ?? []) as Array<Record<string, unknown>>) {
const duration = step.result?.duration as number | undefined;
const name = (step.name ?? '') as string;
if (duration && name) {
timings.push({ name, durationMs: Math.ceil(duration / 1_000_000) });
}
}
}
}
return timings;
} catch {
return [];
}
}
function isLaunchStep(name: string): boolean {
return /launch|page to load|browser view.*loaded/i.test(name);
}
function isWaitStep(name: string): boolean {
return /wait for|log entries|SSE|watch-fs/i.test(name);
}
runSmokeCalibration();

View file

@ -1,26 +0,0 @@
import { execSync } from 'child_process';
import { writeCalibrationResult } from '../features/supports/calibration';
function runSmokeCalibration(): void {
if (process.env.CI) {
return;
}
const startedAt = Date.now();
execSync('cross-env NODE_ENV=test CUCUMBER_PROFILE=calibration cucumber-js --config features/cucumber.config.js --tags "@smoke" --exit', {
stdio: 'inherit',
cwd: process.cwd(),
env: {
...process.env,
TIDGI_E2E_IS_CALIBRATION: 'true',
},
});
const duration = Date.now() - startedAt;
const multiplier = writeCalibrationResult(duration);
console.log(`[E2E Calibration] smoke duration=${duration}ms multiplier=${multiplier.toFixed(2)}×`);
}
runSmokeCalibration();

View file

@ -164,6 +164,7 @@ const defaultWorkspaces: IWorkspace[] = [
excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins,
enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch,
hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused,
useTidgiConfigSync: true,
},
{
id: 'test-wiki-2',
@ -194,5 +195,6 @@ const defaultWorkspaces: IWorkspace[] = [
excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins,
enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch,
hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused,
useTidgiConfigSync: true,
},
];

View file

@ -40,6 +40,7 @@ Object.defineProperty(window, 'observables', {
},
workspace: {
workspaces$: new BehaviorSubject([]).asObservable(),
groups$: new BehaviorSubject({}).asObservable(),
},
updater: {
updaterMetaData$: new BehaviorSubject(undefined).asObservable(),

View file

@ -162,13 +162,6 @@ function SearchGithubRepoResultList({
[data, repositoryCount],
);
// auto select first one after first search
useEffect(() => {
if (githubWikiUrl.length === 0 && repoList.length > 0) {
onSelectRepo(repoList[0].url, repoList[0].name);
}
}, [repoList, githubWikiUrl, onSelectRepo]);
const [isCreatingRepo, isCreatingRepoSetter] = useState(false);
const githubUserID = data?.repositoryOwner.id;
const wikiUrlToCreate = `https://github.com/${githubUsername ?? '???'}/${githubRepoSearchString}`;

View file

@ -0,0 +1,113 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BehaviorSubject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IUserInfos } from '@services/auth/interface';
import SearchGithubRepo from '../SearchGithubRepo';
const mockUseQuery = vi.fn();
const mockUseMutation = vi.fn();
interface IMockSearchQueryResult {
loading: boolean;
error: undefined;
refetch: ReturnType<typeof vi.fn>;
data: {
repositoryOwner: { id: string };
search: {
repositoryCount: number;
edges: Array<{ node: { name: string; url: string } }>;
};
};
}
type TMockMutationResult = [ReturnType<typeof vi.fn>];
vi.mock('graphql-hooks', () => ({
ClientContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
},
GraphQLClient: class {
setHeader() {}
},
useMutation: (...args: unknown[]): TMockMutationResult => mockUseMutation(...args) as TMockMutationResult,
useQuery: (...args: unknown[]): IMockSearchQueryResult => mockUseQuery(...args) as IMockSearchQueryResult,
}));
describe('SearchGithubRepo', () => {
let userInfoSubject: BehaviorSubject<IUserInfos | undefined>;
beforeEach(() => {
vi.clearAllMocks();
userInfoSubject = new BehaviorSubject<IUserInfos | undefined>({
userName: 'Test User',
'github-token': 'test-token',
'github-userName': 'test-user',
});
Object.defineProperty(window.observables.auth, 'userInfo$', {
value: userInfoSubject.asObservable(),
writable: true,
configurable: true,
});
mockUseMutation.mockReturnValue([vi.fn()]);
mockUseQuery.mockReturnValue({
loading: false,
error: undefined,
refetch: vi.fn(),
data: {
repositoryOwner: { id: 'owner-1' },
search: {
repositoryCount: 2,
edges: [
{ node: { name: 'first-repo', url: 'https://github.com/test-user/first-repo' } },
{ node: { name: 'clicked-repo', url: 'https://github.com/test-user/clicked-repo' } },
],
},
},
});
});
it('does not auto-select the first repository when results load', async () => {
const githubWikiUrlSetter = vi.fn();
render(
<SearchGithubRepo
githubWikiUrl=''
githubWikiUrlSetter={githubWikiUrlSetter}
isCreateMainWorkspace
/>,
);
await waitFor(() => {
expect(screen.getByText('first-repo')).toBeInTheDocument();
expect(screen.getByText('clicked-repo')).toBeInTheDocument();
});
expect(githubWikiUrlSetter).not.toHaveBeenCalled();
});
it('selects exactly the repository the user clicked', async () => {
const user = userEvent.setup();
const githubWikiUrlSetter = vi.fn();
const wikiFolderNameSetter = vi.fn();
render(
<SearchGithubRepo
githubWikiUrl=''
githubWikiUrlSetter={githubWikiUrlSetter}
wikiFolderNameSetter={wikiFolderNameSetter}
isCreateMainWorkspace
/>,
);
const clickedRepo = await screen.findByText('clicked-repo');
await user.click(clickedRepo);
expect(githubWikiUrlSetter).toHaveBeenCalledWith('https://github.com/test-user/clicked-repo');
expect(wikiFolderNameSetter).toHaveBeenCalledWith('clicked-repo');
});
});

View file

@ -1,7 +1,8 @@
import { Box, Tab as TabRaw, Tabs as TabsRaw } from '@mui/material';
import { keyframes, styled, Theme } from '@mui/material/styles';
import { useUserInfoObservable } from '@services/auth/hooks';
import { SupportedStorageServices } from '@services/types';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListItemText } from '../ListItem';
@ -56,18 +57,41 @@ interface Props {
* Create storage provider's token.
* @returns
*/
const allStorageServices = [
SupportedStorageServices.github,
SupportedStorageServices.codeberg,
SupportedStorageServices.gitea,
SupportedStorageServices.testOAuth,
];
function getDefaultStorageService(userInfo: ReturnType<typeof useUserInfoObservable>): SupportedStorageServices {
// Prioritize services that user has logged into
if (userInfo) {
for (const service of allStorageServices) {
const token = userInfo[`${service}-token`];
if (typeof token === 'string' && token.length > 0) {
return service;
}
}
}
return SupportedStorageServices.github;
}
export function TokenForm({ storageProvider, storageProviderSetter }: Props): React.JSX.Element {
const { t } = useTranslation();
const [internalTab, internalTabSetter] = useState<SupportedStorageServices>(SupportedStorageServices.github);
const userInfo = useUserInfoObservable();
const defaultService = useMemo(() => getDefaultStorageService(userInfo), [userInfo]);
const [internalTab, internalTabSetter] = useState<SupportedStorageServices>(defaultService);
// use external controls if provided
const currentTab = storageProvider ?? internalTab;
const currentTabSetter = storageProviderSetter ?? internalTabSetter;
// update storageProvider to be an online service, if this Component is opened
// Sync internal tab when userInfo loads and no external control is provided
useEffect(() => {
if (storageProvider === SupportedStorageServices.local && typeof storageProviderSetter === 'function') {
storageProviderSetter(SupportedStorageServices.github);
if (storageProvider === undefined && userInfo !== undefined) {
const service = getDefaultStorageService(userInfo);
internalTabSetter(service);
}
}, [storageProvider, storageProviderSetter]);
}, [storageProvider, userInfo]);
return (
<Container>
<ListItemText primary={t('Preference.Token')} secondary={t('Preference.TokenDescription')} />

View file

@ -174,6 +174,10 @@ export enum WikiEmbeddingChannel {
name = 'WikiEmbeddingChannel',
}
export enum AnalyticsChannel {
name = 'AnalyticsChannel',
}
export type Channels =
| MainChannel
| AuthenticationChannel
@ -197,4 +201,5 @@ export type Channels =
| MetaDataChannel
| SyncChannel
| AgentChannel
| WikiEmbeddingChannel;
| WikiEmbeddingChannel
| AnalyticsChannel;

View file

@ -25,6 +25,8 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { WindowNames } from '@services/windows/WindowProperties';
import type { IAgentDefinitionService } from '@services/agentDefinition/interface';
import { sanitizeErrorMessage } from '@services/analytics';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IContextService } from '@services/context/interface';
import type { IDatabaseService } from '@services/database/interface';
import type { IDeepLinkService } from '@services/deepLink/interface';
@ -73,6 +75,7 @@ protocol.registerSchemesAsPrivileged([
bindServiceAndProxy();
// Get services - DO NOT use them until commonInit() is called
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
@ -223,6 +226,14 @@ const commonInit = async (): Promise<void> => {
}
// trigger whenTrulyReady
ipcMain.emit(MainChannel.commonInitFinished);
// Track app launch event with retention properties
const retentionProperties = await analyticsService.getRetentionProperties();
void analyticsService.track('app.launched', {
platform: process.platform,
version: app.getVersion(),
...retentionProperties,
});
};
/**
@ -249,7 +260,18 @@ app.on('ready', async () => {
}
await updaterService.checkForUpdates();
} catch (error) {
logger.error('Error during app ready handler', { function: "app.on('ready')", error });
const error_ = error as Error;
logger.error('Error during app ready handler', { function: "app.on('ready')", error: error_ });
// Fire-and-forget error tracking for post-init failures
try {
void analyticsService.track('error.unhandled', {
errorName: error_.name || 'Error',
errorMessage: sanitizeErrorMessage(error_),
errorSource: 'app_ready',
});
} catch {
// Silently ignore — analytics infrastructure may not be ready
}
}
});
app.on(MainChannel.windowAllClosed, async () => {
@ -291,6 +313,16 @@ unhandled({
showDialog: !isDevelopmentOrTest,
logger: (error: Error) => {
logger.error('unhandled', { error });
// Fire-and-forget error tracking. Wrapped to avoid throwing if services are not yet initialized.
try {
void analyticsService.track('error.unhandled', {
errorName: error.name || 'Error',
errorMessage: sanitizeErrorMessage(error),
errorSource: 'unhandled',
});
} catch {
// Silently ignore — analytics infrastructure may not be ready during early startup
}
},
reportButton: (error) => {
reportErrorToGithubWithTemplates(error);

View file

@ -92,38 +92,40 @@ export function SideBar(): React.JSX.Element {
const { showSideBarText, showSideBarIcon } = preferences;
return (
<SidebarContainer data-testid='main-sidebar'>
<SidebarTop $titleBar={titleBar}>
{workspacesList === undefined
? <div>{t('Loading')}</div>
: <SortableWorkspaceSelectorList showSideBarText={showSideBarText} workspacesList={workspacesList} showSideBarIcon={showSideBarIcon} />}
</SidebarTop>
<SideBarEnd>
{updaterMetaData?.status === IUpdaterStatus.updateAvailable && (
<>
<SidebarContainer data-testid='main-sidebar'>
<SidebarTop $titleBar={titleBar}>
{workspacesList === undefined
? <div>{t('Loading')}</div>
: <SortableWorkspaceSelectorList showSideBarText={showSideBarText} workspacesList={workspacesList} showSideBarIcon={showSideBarIcon} />}
</SidebarTop>
<SideBarEnd>
{updaterMetaData?.status === IUpdaterStatus.updateAvailable && (
<IconButton
id='update-available'
aria-label={t('SideBar.UpdateAvailable')}
onClick={async () => {
await window.service.native.openURI(updaterMetaData.info?.latestReleasePageUrl ?? latestStableUpdateUrl);
}}
>
<Tooltip title={<span>{t('SideBar.UpdateAvailable')}</span>} placement='top'>
<UpgradeIcon />
</Tooltip>
</IconButton>
)}
<IconButton
id='update-available'
aria-label={t('SideBar.UpdateAvailable')}
id='open-preferences-button'
aria-label={t('SideBar.Preferences')}
onClick={async () => {
await window.service.native.openURI(updaterMetaData.info?.latestReleasePageUrl ?? latestStableUpdateUrl);
await window.service.window.open(WindowNames.preferences);
}}
>
<Tooltip title={<span>{t('SideBar.UpdateAvailable')}</span>} placement='top'>
<UpgradeIcon />
<Tooltip title={<span>{t('SideBar.Preferences')}</span>} placement='top'>
<SettingsIcon />
</Tooltip>
</IconButton>
)}
<IconButton
id='open-preferences-button'
aria-label={t('SideBar.Preferences')}
onClick={async () => {
await window.service.window.open(WindowNames.preferences);
}}
>
<Tooltip title={<span>{t('SideBar.Preferences')}</span>} placement='top'>
<SettingsIcon />
</Tooltip>
</IconButton>
</SideBarEnd>
</SidebarContainer>
</SideBarEnd>
</SidebarContainer>
</>
);
}

View file

@ -1,5 +1,5 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Box, styled } from '@mui/material';
import { MouseEvent, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'wouter';
@ -11,8 +11,24 @@ import { usePreferenceObservable } from '@services/preferences/hooks';
import { WindowNames } from '@services/windows/WindowProperties';
import { getSimplifiedWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import { isWikiWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { useDragContext } from './SortableWorkspaceSelectorList';
import { WorkspaceSelectorBase } from './WorkspaceSelectorBase';
const DragOverlayContainer = styled(Box)`
position: relative;
border-radius: 4px;
transition: background-color 0.15s ease;
`;
const WorkspaceDropZone = styled('div')<{ $bottom?: boolean; $center?: boolean }>`
position: absolute;
left: 0;
right: 0;
pointer-events: auto;
z-index: 2;
background: transparent;
`;
export interface ISortableItemProps {
index: number;
showSideBarIcon: boolean;
@ -24,15 +40,30 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
const { t } = useTranslation();
const { active, id, name, picturePath, pageType } = workspace;
const preference = usePreferenceObservable();
const dragContext = useDragContext();
const isWiki = isWikiWorkspace(workspace);
const hibernated = isWiki ? workspace.hibernated : false;
const transparentBackground = isWiki ? workspace.transparentBackground : false;
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
// Only pass groupId in data to keep the reference stable when workspaces$
// emits new objects with identical groupId values. Passing the whole
// workspace object caused dnd-kit useSortable to re-register on every
// emission, triggering an infinite render loop.
const sortableData = useMemo(() => ({ type: 'workspace' as const, groupId: workspace.groupId }), [workspace.groupId]);
const { attributes, listeners, setNodeRef, isDragging } = useSortable({
id,
data: sortableData,
});
const isDragOverTarget = dragContext.overId === id;
const dragIntent = isDragOverTarget ? dragContext.intent : null;
const isAnyDragActive = dragContext.activeId !== null;
const style = {
transform: CSS.Transform.toString(transform),
transition: transition ?? undefined,
transform: 'translate3d(0, 0, 0)',
transition: 'none',
opacity: isDragging ? 0 : undefined,
};
const [workspaceClickedLoading, workspaceClickedLoadingSetter] = useState(false);
const [, setLocation] = useLocation();
@ -65,6 +96,10 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
}, [isMiniWindow, preference?.tidgiMiniWindowFixedWorkspaceId, id, active]);
const onWorkspaceClick = useCallback(async () => {
if (isAnyDragActive) {
return;
}
workspaceClickedLoadingSetter(true);
try {
// Special "add" workspace always opens add workspace window
@ -96,7 +131,7 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
} finally {
workspaceClickedLoadingSetter(false);
}
}, [id, setLocation, workspace, isMiniWindow]);
}, [id, isAnyDragActive, isMiniWindow, setLocation, workspace]);
const onWorkspaceContextMenu = useCallback(
async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
@ -117,8 +152,21 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
},
[t, workspace],
);
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} onContextMenu={onWorkspaceContextMenu}>
<DragOverlayContainer
ref={(node) => {
setNodeRef(node as HTMLElement | null);
}}
style={style}
{...attributes}
{...listeners}
onContextMenu={onWorkspaceContextMenu}
data-testid={`workspace-item-${id}`}
>
<WorkspaceDropZone data-testid={`workspace-drop-zone-${id}-top`} style={{ top: 0, height: '33%', pointerEvents: 'none' }} />
<WorkspaceDropZone data-testid={`workspace-drop-zone-${id}-center`} $center style={{ top: '33%', height: '34%', pointerEvents: 'none' }} />
<WorkspaceDropZone data-testid={`workspace-drop-zone-${id}-bottom`} $bottom style={{ bottom: 0, height: '33%', pointerEvents: 'none' }} />
<WorkspaceSelectorBase
workspaceClickedLoading={workspaceClickedLoading}
restarting={workspace.metadata.isRestarting}
@ -135,7 +183,8 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
index={index}
hibernated={hibernated}
showSidebarTexts={showSidebarTexts}
dragIntent={dragIntent}
/>
</div>
</DragOverlayContainer>
);
}

View file

@ -52,6 +52,7 @@ from {background-color: #dddddd;}
`;
interface IAvatarProps {
$addAvatar?: boolean;
$dragIntent?: 'group' | 'ungroup' | 'reorder-before' | 'reorder-after' | null;
$highlightAdd?: boolean;
$large?: boolean;
$transparent?: boolean;
@ -69,6 +70,7 @@ const Avatar = styled('div', { shouldForwardProp: (property) => !/^\$/.test(Stri
flex-direction: column;
justify-content: center;
align-items: center;
transition: background-color 0.15s ease, outline 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
${is('$large')`
height: 44px;
width: 44px;
@ -90,6 +92,12 @@ const Avatar = styled('div', { shouldForwardProp: (property) => !/^\$/.test(Stri
${is('$addAvatar')`
background-color: transparent;
`}
${({ $dragIntent, theme }) =>
$dragIntent === 'group'
? `background-color: ${theme.palette.primary.light} !important; outline: 2px solid ${theme.palette.primary.main}; box-shadow: 0 0 0 4px ${theme.palette.primary.main}33; transform: scale(1.06);`
: $dragIntent === 'ungroup'
? `background-color: ${theme.palette.error.light} !important; outline: 2px solid ${theme.palette.error.main}; box-shadow: 0 0 0 4px ${theme.palette.error.main}33; transform: scale(1.06);`
: ''}
`;
const AvatarPicture = styled('img', { shouldForwardProp: (property) => !/^\$/.test(String(property)) })<{ $large?: boolean }>`
@ -123,6 +131,7 @@ interface Props {
active?: boolean;
badgeCount?: number;
customIcon?: React.ReactElement;
dragIntent?: 'group' | 'ungroup' | 'reorder-before' | 'reorder-after' | null;
hibernated?: boolean;
id: string;
index?: number;
@ -142,6 +151,7 @@ export function WorkspaceSelectorBase({
restarting: loading = false,
badgeCount = 0,
customIcon,
dragIntent = null,
hibernated = false,
showSideBarIcon = true,
id,
@ -161,6 +171,7 @@ export function WorkspaceSelectorBase({
$transparent={transparentBackground}
$addAvatar={id === 'add'}
$highlightAdd={index === 0}
$dragIntent={dragIntent}
id={id === 'add' ? 'add-workspace-button' : id === 'guide' ? 'guide-workspace-button' : `workspace-avatar-${id}`}
>
{id === 'add'
@ -191,6 +202,7 @@ export function WorkspaceSelectorBase({
onClick={workspaceClickedLoading ? () => {} : onClick}
data-testid={pageType ? `workspace-${pageType}` : `workspace-${id}`}
data-active={active ? 'true' : 'false'}
data-drag-intent={dragIntent ?? 'none'}
data-hibernated={hibernated ? 'true' : 'false'}
>
<Badge color='secondary' badgeContent={badgeCount} max={99}>

View file

@ -9,6 +9,7 @@ import { AsyncifyProxy } from 'electron-ipc-cat/common';
import { AgentBrowserServiceIPCDescriptor, type IAgentBrowserService } from '@services/agentBrowser/interface';
import { AgentDefinitionServiceIPCDescriptor, type IAgentDefinitionService } from '@services/agentDefinition/interface';
import { AgentInstanceServiceIPCDescriptor, type IAgentInstanceService } from '@services/agentInstance/interface';
import { AnalyticsServiceIPCDescriptor, type IAnalyticsService } from '@services/analytics/interface';
import { AuthenticationServiceIPCDescriptor, type IAuthenticationService } from '@services/auth/interface';
import { ContextServiceIPCDescriptor, type IContextService } from '@services/context/interface';
import { DatabaseServiceIPCDescriptor, type IDatabaseService } from '@services/database/interface';
@ -34,6 +35,7 @@ import { type IWorkspaceViewService, WorkspaceViewServiceIPCDescriptor } from '@
export const agentBrowser = createProxy<AsyncifyProxy<IAgentBrowserService>>(AgentBrowserServiceIPCDescriptor);
export const agentDefinition = createProxy<AsyncifyProxy<IAgentDefinitionService>>(AgentDefinitionServiceIPCDescriptor);
export const agentInstance = createProxy<AsyncifyProxy<IAgentInstanceService>>(AgentInstanceServiceIPCDescriptor);
export const analytics = createProxy<IAnalyticsService>(AnalyticsServiceIPCDescriptor);
export const auth = createProxy<IAuthenticationService>(AuthenticationServiceIPCDescriptor);
export const context = createProxy<IContextService>(ContextServiceIPCDescriptor);
export const deepLink = createProxy<IDeepLinkService>(DeepLinkServiceIPCDescriptor);
@ -60,6 +62,7 @@ export const descriptors = {
agentBrowser: AgentBrowserServiceIPCDescriptor,
agentDefinition: AgentDefinitionServiceIPCDescriptor,
agentInstance: AgentInstanceServiceIPCDescriptor,
analytics: AnalyticsServiceIPCDescriptor,
auth: AuthenticationServiceIPCDescriptor,
context: ContextServiceIPCDescriptor,
deepLink: DeepLinkServiceIPCDescriptor,

View file

@ -0,0 +1,387 @@
import { app } from 'electron';
import { inject, injectable } from 'inversify';
import { randomUUID } from 'node:crypto';
import { container } from '@services/container';
import type { IDatabaseService, ISettingFile } from '@services/database/interface';
import { logger } from '@services/libs/log';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import type { AnalyticsEventName, BuiltInAnalyticsEventName, IAnalyticsEventProperties, IAnalyticsService, PluginAnalyticsEventName } from './interface';
interface IAnalyticsSecretSettings {
deviceFirstLaunchDate?: string;
deviceLastLaunchDate?: string;
/**
* Stable random UUID generated once on first launch and persisted forever.
* Used as Rybbit `user_id` so events from the same installation are always
* grouped under the same user regardless of IP or User-Agent changes.
*/
deviceId?: string;
}
interface ITrackPayload {
site_id: string;
type: 'custom_event';
event_name: AnalyticsEventName;
properties?: Record<string, string | number | boolean>;
hostname: string;
pathname: string;
/** Stable per-installation UUID — maps to Rybbit identified_user_id */
user_id?: string;
}
const ANALYTICS_SETTINGS_KEY = 'analyticsSecrets';
const ANALYTICS_PATHNAME = '/desktop';
const DEFAULT_TIMEOUT_MS = 5000;
const ERROR_MESSAGE_MAX_LENGTH = 100;
/**
* Extract a privacy-safe summary from an Error for analytics.
* Strips file paths and truncates, keeping only the error name and the beginning of the message.
*/
export function sanitizeErrorMessage(error: Error): string {
const firstLine = (error.stack ?? error.message ?? '').split('\n')[0] ?? '';
// Remove " at function (path)" or " at path" patterns that appear in stack traces
let cleaned = firstLine.replace(/\s+at\s+.*$/i, '');
// Remove standalone parenthesized paths like (file:///path) or (I:\path) anywhere in the line
cleaned = cleaned.replace(/\s*\([^)]*(?:file:\/\/|[a-zA-Z]:\\|\/)[^)]*\)/g, '');
return cleaned.trim().slice(0, ERROR_MESSAGE_MAX_LENGTH);
}
const allowedPropertiesByEvent: Record<BuiltInAnalyticsEventName, ReadonlySet<string>> = {
'app.launched': new Set(['platform', 'version', 'firstLaunchDate', 'daysSinceLastLaunch', 'isFirstLaunch']),
'deep_link.opened': new Set(['resolvedWorkspace', 'fromPendingQueue']),
'error.report_requested': new Set(['errorName', 'errorMessage']),
'error.unhandled': new Set(['errorName', 'errorMessage', 'errorSource']),
'preferences.analytics_updated': new Set(['field', 'enabled']),
'settings.opened': new Set(['window']),
'sync.completed': new Set(['storage', 'commitOnly', 'hasChanges', 'force']),
'sync.failed': new Set(['storage', 'reason', 'commitOnly', 'force']),
'sync.triggered': new Set(['storage', 'commitOnly', 'force']),
'theme.changed': new Set(['themeSource', 'darkMode']),
'updater.check_failed': new Set(['allowPrerelease']),
'updater.check_started': new Set(['allowPrerelease']),
'updater.update_available': new Set(['allowPrerelease']),
'updater.update_not_available': new Set(['allowPrerelease']),
'workspace.activated': new Set(['isSubWiki']),
'workspace.created': new Set(['isSubWiki', 'hasGitUrl']),
'workspace.opened_in_new_window': new Set(['isSubWiki']),
};
const pluginAnalyticsEventPattern = /^plugin\.[a-z0-9]+(?:[-_][a-z0-9]+)*\.[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
const pluginEventNamePattern = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
const pluginPropertyKeyPattern = /^[a-z][a-z0-9_]{0,39}$/;
const maxPluginStringLength = 120;
@injectable()
export class AnalyticsService implements IAnalyticsService {
private readonly queuedEvents: Array<{ eventName: AnalyticsEventName; properties?: IAnalyticsEventProperties }> = [];
private readonly maxQueuedEvents = 100;
private flushInFlight: Promise<void> | undefined;
constructor(
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
) {
app.on('browser-window-focus', () => {
void this.flushQueue();
});
}
public async track(eventName: AnalyticsEventName, properties?: IAnalyticsEventProperties): Promise<void> {
if (!this.isTrackableEventName(eventName)) {
logger.warn('Analytics event rejected because eventName is invalid or unsupported', {
eventName,
function: 'track',
});
return;
}
const enabled = await this.isEnabled();
if (!enabled) {
return;
}
const sanitizedProperties = this.sanitizePropertiesForEvent(eventName, properties);
const sent = await this.sendEvent(eventName, sanitizedProperties);
if (!sent) {
this.enqueueEvent(eventName, sanitizedProperties);
}
}
public async trackPluginEvent(pluginId: string, eventName: string, properties?: IAnalyticsEventProperties): Promise<void> {
const normalizedPluginId = this.normalizePluginSegment(pluginId);
const normalizedEventName = this.normalizePluginSegment(eventName);
if (!normalizedPluginId || !normalizedEventName) {
logger.warn('Plugin analytics event rejected because pluginId or eventName is invalid', {
function: 'trackPluginEvent',
});
return;
}
const sanitizedProperties = this.sanitizePluginProperties(properties);
await this.track(
this.makePluginEventName(normalizedPluginId, normalizedEventName),
sanitizedProperties,
);
}
public async isEnabled(): Promise<boolean> {
const enabled = await this.preferenceService.get('analyticsEnabled');
if (!enabled) {
return false;
}
const analyticsHost = await this.preferenceService.get('analyticsHost');
const analyticsSiteId = await this.preferenceService.get('analyticsSiteId');
const analyticsApiKey = await this.preferenceService.get('analyticsApiKey');
return Boolean(analyticsHost.trim() && analyticsSiteId.trim() && analyticsApiKey.trim());
}
public async clearPendingEvents(): Promise<void> {
this.queuedEvents.length = 0;
}
public async getRetentionProperties(): Promise<IAnalyticsEventProperties | undefined> {
const enabled = await this.isEnabled();
if (!enabled) {
return undefined;
}
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const secrets = this.getAnalyticsSecrets(databaseService);
const now = new Date();
const todayDate = now.toISOString().slice(0, 10);
const isFirstLaunch = !secrets.deviceFirstLaunchDate;
const firstLaunchDate = secrets.deviceFirstLaunchDate ?? todayDate;
let daysSinceLastLaunch: number | undefined;
if (secrets.deviceLastLaunchDate) {
const lastDate = new Date(secrets.deviceLastLaunchDate);
daysSinceLastLaunch = Math.floor((now.getTime() - lastDate.getTime()) / 86_400_000);
}
const nextSecrets: IAnalyticsSecretSettings = {
...secrets,
deviceFirstLaunchDate: firstLaunchDate,
deviceLastLaunchDate: todayDate,
};
databaseService.setSetting(ANALYTICS_SETTINGS_KEY as keyof ISettingFile, nextSecrets as never);
await databaseService.immediatelyStoreSettingsToFile();
return {
firstLaunchDate,
isFirstLaunch,
...(daysSinceLastLaunch !== undefined ? { daysSinceLastLaunch } : {}),
} satisfies IAnalyticsEventProperties;
}
private getAnalyticsSecrets(databaseService: IDatabaseService): IAnalyticsSecretSettings {
const rawSettings = databaseService.getSetting(ANALYTICS_SETTINGS_KEY as keyof ISettingFile) as unknown;
return (rawSettings && typeof rawSettings === 'object' && !Array.isArray(rawSettings))
? (rawSettings as IAnalyticsSecretSettings)
: {};
}
private sanitizeProperties(properties?: IAnalyticsEventProperties): Record<string, string | number | boolean> | undefined {
if (!properties) {
return undefined;
}
const sanitizedEntries = Object.entries(properties).filter(([, value]) => (
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
));
if (sanitizedEntries.length === 0) {
return undefined;
}
return Object.fromEntries(sanitizedEntries) as Record<string, string | number | boolean>;
}
private sanitizePropertiesForEvent(eventName: AnalyticsEventName, properties?: IAnalyticsEventProperties): Record<string, string | number | boolean> | undefined {
if (eventName.startsWith('plugin.')) {
return this.sanitizePluginProperties(properties);
}
const sanitized = this.sanitizeProperties(properties);
if (!sanitized) {
return undefined;
}
const allowedProperties = allowedPropertiesByEvent[eventName as BuiltInAnalyticsEventName];
const filteredEntries = Object.entries(sanitized).filter(([key]) => allowedProperties.has(key));
if (filteredEntries.length === 0) {
return undefined;
}
return Object.fromEntries(filteredEntries) as Record<string, string | number | boolean>;
}
private isTrackableEventName(eventName: string): eventName is AnalyticsEventName {
return this.isBuiltInAnalyticsEventName(eventName) || pluginAnalyticsEventPattern.test(eventName);
}
private isBuiltInAnalyticsEventName(eventName: string): eventName is BuiltInAnalyticsEventName {
return Object.hasOwn(allowedPropertiesByEvent, eventName);
}
private sanitizePluginProperties(properties?: IAnalyticsEventProperties): Record<string, string | number | boolean> | undefined {
const sanitized = this.sanitizeProperties(properties);
if (!sanitized) {
return undefined;
}
const filteredEntries = Object.entries(sanitized)
.filter(([key]) => pluginPropertyKeyPattern.test(key))
.map(([key, value]) => {
if (typeof value === 'string') {
return [key, value.slice(0, maxPluginStringLength)] as const;
}
return [key, value] as const;
});
if (filteredEntries.length === 0) {
return undefined;
}
return Object.fromEntries(filteredEntries) as Record<string, string | number | boolean>;
}
private normalizePluginSegment(segment: string): string | undefined {
const normalized = segment.trim().toLowerCase();
if (!pluginEventNamePattern.test(normalized)) {
return undefined;
}
return normalized;
}
/**
* Construct a PluginAnalyticsEventName from already-validated segments.
* Callers must guarantee segments pass normalizePluginSegment first.
*/
private makePluginEventName(pluginId: string, eventName: string): PluginAnalyticsEventName {
return `plugin.${pluginId}.${eventName}`;
}
private buildPayload(eventName: AnalyticsEventName, properties?: Record<string, string | number | boolean>): Promise<ITrackPayload | undefined> {
return Promise.all([
this.preferenceService.get('analyticsHost'),
this.preferenceService.get('analyticsSiteId'),
]).then(([analyticsHost, analyticsSiteId]) => {
if (!analyticsHost.trim() || !analyticsSiteId.trim()) {
return undefined;
}
const deviceId = this.getOrCreateDeviceId();
return {
site_id: analyticsSiteId.trim(),
type: 'custom_event',
event_name: eventName,
properties,
hostname: this.getAnalyticsHostname(analyticsHost),
pathname: ANALYTICS_PATHNAME,
user_id: deviceId,
};
});
}
/**
* Return the persisted device UUID, creating and storing it on first call.
* Stored alongside other analytics secrets so it survives app updates.
*/
private getOrCreateDeviceId(): string {
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const secrets = this.getAnalyticsSecrets(databaseService);
if (secrets.deviceId) {
return secrets.deviceId;
}
const newId = randomUUID();
databaseService.setSetting(ANALYTICS_SETTINGS_KEY as keyof ISettingFile, { ...secrets, deviceId: newId } as never);
void databaseService.immediatelyStoreSettingsToFile();
return newId;
}
private getAnalyticsTrackUrl(analyticsHost: string): string {
const normalizedHost = analyticsHost.trim().replace(/\/+$/, '');
return normalizedHost.endsWith('/api') ? `${normalizedHost}/track` : `${normalizedHost}/api/track`;
}
private getAnalyticsHostname(analyticsHost: string): string {
try {
return new URL(analyticsHost).hostname;
} catch {
return 'desktop.tidgi';
}
}
private async sendEvent(eventName: AnalyticsEventName, properties?: Record<string, string | number | boolean>): Promise<boolean> {
try {
const [analyticsHost, payload] = await Promise.all([
this.preferenceService.get('analyticsHost'),
this.buildPayload(eventName, properties),
]);
if (!payload) {
return false;
}
const abortController = new AbortController();
const timeoutHandle = setTimeout(() => {
abortController.abort();
}, DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(this.getAnalyticsTrackUrl(analyticsHost), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: abortController.signal,
});
if (!response.ok) {
logger.warn('Analytics event rejected by server', { eventName, status: response.status });
return false;
}
return true;
} finally {
clearTimeout(timeoutHandle);
}
} catch (error) {
logger.debug('Analytics event delivery failed', { eventName, error });
return false;
}
}
private enqueueEvent(eventName: AnalyticsEventName, properties?: Record<string, string | number | boolean>): void {
this.queuedEvents.push({ eventName, properties });
if (this.queuedEvents.length > this.maxQueuedEvents) {
this.queuedEvents.shift();
}
}
private async flushQueue(): Promise<void> {
if (this.flushInFlight) {
return this.flushInFlight;
}
this.flushInFlight = (async () => {
while (this.queuedEvents.length > 0) {
const nextEvent = this.queuedEvents[0];
const sent = await this.sendEvent(nextEvent.eventName, this.sanitizePropertiesForEvent(nextEvent.eventName, nextEvent.properties));
if (!sent) {
break;
}
this.queuedEvents.shift();
}
})().finally(() => {
this.flushInFlight = undefined;
});
await this.flushInFlight;
}
}

View file

@ -0,0 +1,75 @@
import { AnalyticsChannel } from '@/constants/channels';
import { ProxyPropertyType } from 'electron-ipc-cat/common';
export type BuiltInAnalyticsEventName =
| 'app.launched'
| 'deep_link.opened'
| 'error.report_requested'
| 'error.unhandled'
| 'settings.opened'
| 'workspace.created'
| 'workspace.activated'
| 'workspace.opened_in_new_window'
| 'preferences.analytics_updated'
| 'sync.triggered'
| 'sync.completed'
| 'sync.failed'
| 'theme.changed'
| 'updater.check_started'
| 'updater.update_available'
| 'updater.update_not_available'
| 'updater.check_failed';
export type PluginAnalyticsEventName = `plugin.${string}.${string}`;
export type AnalyticsEventName = BuiltInAnalyticsEventName | PluginAnalyticsEventName;
export interface IAnalyticsEventProperties {
[key: string]: string | number | boolean | undefined;
}
/**
* Privacy-safe analytics service for tracking coarse-grained app usage.
* Only tracks high-level events with no PII or content.
*/
export interface IAnalyticsService {
/**
* Track a privacy-safe event. No-ops if analytics disabled or misconfigured.
* @param eventName Event name following taxonomy (e.g., 'app.launched', 'workspace.created')
* @param properties Optional properties (only primitives, no PII/content)
*/
track(eventName: AnalyticsEventName, properties?: IAnalyticsEventProperties): Promise<void>;
/**
* Track a coarse plugin-defined event through a guarded API intended for renderer code
* and TiddlyWiki plugins. The final event name is emitted as `plugin.<pluginId>.<eventName>`.
* This exists so plugin authors do not need to depend on TidGi core event taxonomy.
*/
trackPluginEvent(pluginId: string, eventName: string, properties?: IAnalyticsEventProperties): Promise<void>;
/**
* Check if analytics is currently enabled and properly configured
*/
isEnabled(): Promise<boolean>;
/**
* Drop any unsent queued events without changing preferences.
*/
clearPendingEvents(): Promise<void>;
/**
* Compute and persist device-level retention properties for the current launch.
* Returns properties to attach to 'app.launched', or undefined if disabled.
*/
getRetentionProperties(): Promise<IAnalyticsEventProperties | undefined>;
}
export const AnalyticsServiceIPCDescriptor = {
channel: AnalyticsChannel.name,
properties: {
clearPendingEvents: ProxyPropertyType.Function,
track: ProxyPropertyType.Function,
trackPluginEvent: ProxyPropertyType.Function,
isEnabled: ProxyPropertyType.Function,
},
};

View file

@ -2,14 +2,19 @@ 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 { ISyncableWikiConfig, IWorkspace } from '@services/workspaces/interface';
import type { ISyncableWikiConfig, IWorkspace, IWorkspaceGroup } from '@services/workspaces/interface';
import { ProxyPropertyType } from 'electron-ipc-cat/common';
import { DataSource } from 'typeorm';
export interface ISettingFile {
analyticsSecrets?: {
analyticsApiKey?: string;
analyticsDisclosureVersion?: number;
};
preferences: IPreferences;
userInfos: IUserInfos;
workspaces: Record<string, IWorkspace>;
workspaceGroups?: Record<string, IWorkspaceGroup>;
aiSettings?: AIGlobalSettings;
}

View file

@ -1,4 +1,6 @@
import { TIDGI_PROTOCOL_SCHEME } from '@/constants/protocol';
import type { IAnalyticsService } from '@services/analytics/interface';
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IWorkspaceService } from '@services/workspaces/interface';
@ -54,8 +56,9 @@ export class DeepLinkService implements IDeepLinkService {
* Handle link and open the workspace.
* @param requestUrl like `tidgi://lxqsftvfppu_z4zbaadc0/#:Index` or `tidgi://lxqsftvfppu_z4zbaadc0/#%E6%96%B0%E6%9D%A1%E7%9B%AE`
*/
private readonly deepLinkHandler: (requestUrl: string) => Promise<void> = async (requestUrl) => {
private readonly deepLinkHandler: (requestUrl: string, fromPendingQueue?: boolean) => Promise<void> = async (requestUrl, fromPendingQueue = false) => {
logger.info(`Receiving deep link`, { requestUrl, function: 'deepLinkHandler' });
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
try {
// hostname is workspace id or name
const { hostname, hash, pathname } = new URL(requestUrl);
@ -93,6 +96,10 @@ export class DeepLinkService implements IDeepLinkService {
}
logger.info(`Open deep link`, { workspaceId: workspace.id, tiddlerName, function: 'deepLinkHandler' });
void analyticsService.track('deep_link.opened', {
resolvedWorkspace: true,
fromPendingQueue,
});
await this.workspaceService.openWorkspaceTiddler(workspace, tiddlerName);
} catch (error) {
logger.error(`Invalid URL`, { requestUrl, error, function: 'deepLinkHandler' });
@ -107,7 +114,7 @@ export class DeepLinkService implements IDeepLinkService {
const url = this.pendingDeepLink;
this.pendingDeepLink = undefined;
logger.info(`Processing pending deep link`, { url, function: 'processPendingDeepLink' });
await this.deepLinkHandler(url);
await this.deepLinkHandler(url, true);
}
}

View file

@ -0,0 +1,35 @@
// @vitest-environment node
import { exec as gitExec } from 'dugite';
import { hasGit } from 'git-sync-js/dist/src/inspect.js';
import { mkdtemp, rm } from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('git-sync-js repo detection compatibility', () => {
it('treats Windows path format differences and benign stderr as a valid git repository', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'tidgi-git-detect-'));
try {
const initResult = await gitExec(['init', '--initial-branch=main'], tempRoot);
expect(initResult.exitCode).toBe(0);
const originalTrace = process.env.GIT_TRACE;
process.env.GIT_TRACE = '1';
try {
const posixStylePath = tempRoot.replaceAll('\\', '/');
await expect(hasGit(posixStylePath)).resolves.toBe(true);
} finally {
if (originalTrace === undefined) {
delete process.env.GIT_TRACE;
} else {
process.env.GIT_TRACE = originalTrace;
}
}
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
});
});

View file

@ -9,6 +9,7 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { AgentBrowserService } from '@services/agentBrowser';
import { AgentDefinitionService } from '@services/agentDefinition';
import { AgentInstanceService } from '@services/agentInstance';
import { AnalyticsService } from '@services/analytics';
import { Authentication } from '@services/auth';
import { ContextService } from '@services/context';
import { DatabaseService } from '@services/database';
@ -34,6 +35,7 @@ import { WorkspaceView } from '@services/workspacesView';
import { AgentBrowserServiceIPCDescriptor, type IAgentBrowserService } from '@services/agentBrowser/interface';
import { AgentDefinitionServiceIPCDescriptor, type IAgentDefinitionService } from '@services/agentDefinition/interface';
import { AgentInstanceServiceIPCDescriptor, type IAgentInstanceService } from '@services/agentInstance/interface';
import { AnalyticsServiceIPCDescriptor, type IAnalyticsService } from '@services/analytics/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import { AuthenticationServiceIPCDescriptor } from '@services/auth/interface';
import type { IContextService } from '@services/context/interface';
@ -83,6 +85,7 @@ export function bindServiceAndProxy(): void {
container.bind<IAgentBrowserService>(serviceIdentifier.AgentBrowser).to(AgentBrowserService).inSingletonScope();
container.bind<IAgentDefinitionService>(serviceIdentifier.AgentDefinition).to(AgentDefinitionService).inSingletonScope();
container.bind<IAgentInstanceService>(serviceIdentifier.AgentInstance).to(AgentInstanceService).inSingletonScope();
container.bind<IAnalyticsService>(serviceIdentifier.Analytics).to(AnalyticsService).inSingletonScope();
container.bind<IAuthenticationService>(serviceIdentifier.Authentication).to(Authentication).inSingletonScope();
container.bind<IContextService>(serviceIdentifier.Context).to(ContextService).inSingletonScope();
container.bind<IDatabaseService>(serviceIdentifier.Database).to(DatabaseService).inSingletonScope();
@ -109,6 +112,7 @@ export function bindServiceAndProxy(): void {
const agentBrowserService = container.get<IAgentBrowserService>(serviceIdentifier.AgentBrowser);
const agentDefinitionService = container.get<IAgentDefinitionService>(serviceIdentifier.AgentDefinition);
const agentInstanceService = container.get<IAgentInstanceService>(serviceIdentifier.AgentInstance);
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const authService = container.get<IAuthenticationService>(serviceIdentifier.Authentication);
const contextService = container.get<IContextService>(serviceIdentifier.Context);
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
@ -135,6 +139,7 @@ export function bindServiceAndProxy(): void {
registerProxy(agentBrowserService, AgentBrowserServiceIPCDescriptor);
registerProxy(agentDefinitionService, AgentDefinitionServiceIPCDescriptor);
registerProxy(agentInstanceService, AgentInstanceServiceIPCDescriptor);
registerProxy(analyticsService, AnalyticsServiceIPCDescriptor);
registerProxy(authService, AuthenticationServiceIPCDescriptor);
registerProxy(contextService, ContextServiceIPCDescriptor);
registerProxy(databaseService, DatabaseServiceIPCDescriptor);

View file

@ -1,4 +1,7 @@
import { LOG_FOLDER } from '@/constants/appPaths';
import { sanitizeErrorMessage } from '@services/analytics';
import type { IAnalyticsService } from '@services/analytics/interface';
import { container } from '@services/container';
import serviceIdentifier from '@services/serviceIdentifier';
import { app, shell } from 'electron';
import newGithubIssueUrl, { type Options as OpenNewGitHubIssueOptions } from 'new-github-issue-url';
@ -58,6 +61,11 @@ Locale: ${app.getLocale()}
`.trim();
export function reportErrorToGithubWithTemplates(error: Error): void {
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('error.report_requested', {
errorName: error.name || 'Error',
errorMessage: sanitizeErrorMessage(error),
});
void import('@services/container')
.then(({ container }) => {
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);

View file

@ -4,9 +4,32 @@ import { app } from 'electron';
import semver from 'semver';
import type { IPreferences } from './interface';
// Allow E2E tests to inject analytics configuration via environment variables
function getAnalyticsEnvironmentOverrides(): { analyticsApiKey: string; analyticsEnabled: boolean; analyticsHost: string; analyticsSiteId: string } {
const analyticsHost = process.env.TIDGI_ANALYTICS_HOST ?? '';
if (analyticsHost) {
return {
analyticsApiKey: process.env.TIDGI_ANALYTICS_API_KEY ?? 'test-api-key',
analyticsEnabled: true,
analyticsHost,
analyticsSiteId: process.env.TIDGI_ANALYTICS_SITE_ID ?? 'test-site',
};
}
// site_id is safe to embed publicly — Rybbit's /api/track is a public endpoint
// that only needs site_id (same as the data-site-id in <script> tags on websites).
// API keys are only for reading data via the dashboard API, not for writing events.
return { analyticsApiKey: '', analyticsEnabled: true, analyticsHost: 'https://analytics.tidgi.fun', analyticsSiteId: '189dd97a8d37' };
}
const analyticsEnvironment = getAnalyticsEnvironmentOverrides();
export const defaultPreferences: IPreferences = {
allowPrerelease: Boolean(semver.prerelease(app.getVersion())),
alwaysOnTop: false,
analyticsApiKey: analyticsEnvironment.analyticsApiKey,
analyticsEnabled: analyticsEnvironment.analyticsEnabled,
analyticsHost: analyticsEnvironment.analyticsHost,
analyticsSiteId: analyticsEnvironment.analyticsSiteId,
askForDownloadPath: true,
disableAntiAntiLeech: false,
disableAntiAntiLeechForUrls: [],

View file

@ -15,6 +15,39 @@ export const privacySection: ISectionDefinition = {
zod: z.boolean(),
},
{ type: 'divider' },
{
type: 'preference-boolean',
key: 'analyticsEnabled',
titleKey: 'Preference.AnalyticsEnabled',
descriptionKey: 'Preference.AnalyticsEnabledDescription',
needsRestart: false,
zod: z.boolean(),
},
{
type: 'preference-text',
key: 'analyticsHost',
titleKey: 'Preference.AnalyticsHost',
descriptionKey: 'Preference.AnalyticsHostDescription',
needsRestart: false,
zod: z.string(),
},
{
type: 'preference-text',
key: 'analyticsSiteId',
titleKey: 'Preference.AnalyticsSiteId',
descriptionKey: 'Preference.AnalyticsSiteIdDescription',
needsRestart: false,
zod: z.string(),
},
{
type: 'preference-text',
key: 'analyticsApiKey',
titleKey: 'Preference.AnalyticsApiKey',
descriptionKey: 'Preference.AnalyticsApiKeyDescription',
needsRestart: false,
zod: z.string(),
},
{ type: 'divider' },
{
type: 'preference-boolean',
key: 'ignoreCertificateErrors',

View file

@ -25,6 +25,7 @@ import type {
} from './types';
import { updatesSection } from './updates';
import { wikiSection } from './wiki';
import { workspaceGroupsSection } from './workspaceGroups';
/**
* Ordered list of all sections. Display order matches array order.
@ -39,6 +40,7 @@ export const allSections: ISectionDefinition[] = [
notificationsSection,
systemSection,
languagesSection,
workspaceGroupsSection,
developersSection,
downloadsSection,
networkSection,

View file

@ -56,6 +56,16 @@ export const stringPreferenceItemSchema = definitionBaseSchema.extend({
});
export type IStringPreferenceItem = z.infer<typeof stringPreferenceItemSchema>;
export const textPreferenceItemSchema = definitionBaseSchema.extend({
type: z.literal('preference-text'),
key: z.string() as z.ZodType<keyof IPreferences>,
multiline: z.boolean().optional(),
needsRestart: z.boolean().optional(),
sideEffectId: z.string().optional(),
zod: z.custom<z.ZodString | z.ZodOptional<z.ZodString>>(),
});
export type ITextPreferenceItem = z.infer<typeof textPreferenceItemSchema>;
export const stringArrayPreferenceItemSchema = definitionBaseSchema.extend({
type: z.literal('preference-string-array'),
key: z.string() as z.ZodType<keyof IPreferences>,
@ -88,6 +98,7 @@ export const preferenceItemDefinitionSchema = z.discriminatedUnion('type', [
enumPreferenceItemSchema,
numberPreferenceItemSchema,
stringPreferenceItemSchema,
textPreferenceItemSchema,
stringArrayPreferenceItemSchema,
actionItemSchema,
customItemSchema,

View file

@ -0,0 +1,16 @@
import FolderIcon from '@mui/icons-material/Folder';
import type { ISectionDefinition } from './types';
export const workspaceGroupsSection: ISectionDefinition = {
id: 'workspaceGroups',
titleKey: 'WorkspaceGroup.ManageGroups',
Icon: FolderIcon,
items: [
{
type: 'custom',
componentId: 'workspaceGroups.management',
titleKey: 'WorkspaceGroup.ManageGroups',
descriptionKey: 'WorkspaceGroup.ManageGroupsDescription',
},
],
};

View file

@ -2,6 +2,7 @@ import { dialog, nativeTheme } from 'electron';
import { injectable } from 'inversify';
import { BehaviorSubject } from 'rxjs';
import type { IAnalyticsService } from '@services/analytics/interface';
import { container } from '@services/container';
import type { IDatabaseService } from '@services/database/interface';
import { i18n } from '@services/libs/i18n';
@ -83,6 +84,15 @@ export class Preference implements IPreferenceService {
* @param preference new preference settings
*/
private async reactWhenPreferencesChanged<K extends keyof IPreferences>(key: K, value: IPreferences[K]): Promise<void> {
// Track analytics preference changes
if (key === 'analyticsEnabled' || key === 'analyticsHost' || key === 'analyticsSiteId') {
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('preferences.analytics_updated', {
field: key,
enabled: key === 'analyticsEnabled' ? Boolean(value) : undefined,
});
}
// maybe pauseNotificationsBySchedule or pauseNotifications or ...
if (key.startsWith('pauseNotifications')) {
const notificationService = container.get<INotificationService>(serviceIdentifier.NotificationService);

View file

@ -14,6 +14,10 @@ export interface IPreferences {
aiGenerateBackupTitleTimeout: number;
allowPrerelease: boolean;
alwaysOnTop: boolean;
analyticsApiKey: string;
analyticsEnabled: boolean;
analyticsHost: string;
analyticsSiteId: string;
askForDownloadPath: boolean;
disableAntiAntiLeech: boolean;
disableAntiAntiLeechForUrls: string[];
@ -70,6 +74,7 @@ export enum PreferenceSections {
wiki = 'wiki',
externalAPI = 'externalAPI',
aiAgent = 'aiAgent',
workspaceGroups = 'workspaceGroups',
}
/**

View file

@ -2,6 +2,7 @@ export default {
AgentBrowser: Symbol.for('AgentBrowser'),
AgentDefinition: Symbol.for('AgentDefinition'),
AgentInstance: Symbol.for('AgentInstance'),
Analytics: Symbol.for('Analytics'),
Authentication: Symbol.for('Authentication'),
Context: Symbol.for('Context'),
Database: Symbol.for('Database'),

View file

@ -1,6 +1,7 @@
import { inject, injectable } from 'inversify';
import { WikiChannel } from '@/constants/channels';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { ICommitAndSyncConfigs, IGitService } from '@services/git/interface';
@ -9,7 +10,6 @@ import { logger } from '@services/libs/log';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import { SupportedStorageServices } from '@services/types';
import type { IViewService } from '@services/view/interface';
import type { IWikiService } from '@services/wiki/interface';
import type { IWorkspace, IWorkspaceService } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
@ -34,7 +34,7 @@ export class Sync implements ISyncService {
// Get Layer 3 services
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const gitService = container.get<IGitService>(serviceIdentifier.Git);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
@ -45,23 +45,37 @@ export class Sync implements ISyncService {
const defaultCommitMessage = i18n.t('LOG.CommitMessage');
const commitMessage = useAICommitMessage ? undefined : (overrideCommitMessage ?? defaultCommitMessage);
const localCommitMessage = useAICommitMessage ? undefined : overrideCommitMessage;
const { force = false } = options ?? {};
const syncOnlyWhenNoDraft = await this.preferenceService.get('syncOnlyWhenNoDraft');
const mainWorkspace = isSubWiki ? workspaceService.getMainWorkspace(workspace) : undefined;
const analyticsBaseProperties = {
storage: storageService,
commitOnly: storageService === SupportedStorageServices.local,
force,
};
if (isSubWiki && mainWorkspace === undefined) {
logger.error(`Main workspace not found for sub workspace ${id}`, { function: 'syncWikiIfNeeded' });
return;
}
const idToUse = isSubWiki ? mainWorkspace!.id : id;
const { force = false } = options ?? {};
// we can only run filter on main wiki (tw don't know what is sub-wiki)
// Skip draft check when user explicitly triggers sync (force=true), or when syncOnlyWhenNoDraft is disabled.
if (!force && syncOnlyWhenNoDraft && !(await this.checkCanSyncDueToNoDraft(idToUse))) {
await wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, idToUse, [i18n.t('Preference.SyncOnlyWhenNoDraft')]);
void analyticsService.track('sync.failed', {
...analyticsBaseProperties,
reason: 'draft_blocked',
});
return;
}
void analyticsService.track('sync.triggered', analyticsBaseProperties);
if (storageService === SupportedStorageServices.local) {
// for local workspace, commitOnly, no sync and no force pull.
await gitService.commitAndSync(workspace, { dir: wikiFolderLocation, commitOnly: true, commitMessage: localCommitMessage });
const hasChanges = await gitService.commitAndSync(workspace, { dir: wikiFolderLocation, commitOnly: true, commitMessage: localCommitMessage });
void analyticsService.track('sync.completed', {
...analyticsBaseProperties,
hasChanges,
});
} else if (
typeof gitUrl === 'string' &&
userInfo !== undefined
@ -75,7 +89,6 @@ export class Sync implements ISyncService {
// Skip restart if file system watch is enabled - the watcher will handle file changes automatically
if (hasChanges && !workspace.enableFileSystemWatch) {
await workspaceViewService.restartWorkspaceViewService(idToUse);
await viewService.reloadViewsWebContents(idToUse);
}
} else if (workspace.syncSubWikis !== false) {
// sync all sub workspace (can be disabled via syncSubWikis setting)
@ -99,9 +112,12 @@ export class Sync implements ISyncService {
// Skip restart if file system watch is enabled - the watcher will handle file changes automatically
if ((hasChanges || subHasChange) && !workspace.enableFileSystemWatch) {
await workspaceViewService.restartWorkspaceViewService(id);
await viewService.reloadViewsWebContents(id);
}
}
void analyticsService.track('sync.completed', {
...analyticsBaseProperties,
hasChanges,
});
} else {
// cloud workspace but missing gitUrl or userInfo - log and notify instead of silently doing nothing
const reason = typeof gitUrl !== 'string' ? 'missing gitUrl' : 'missing userInfo (not authenticated)';
@ -109,6 +125,10 @@ export class Sync implements ISyncService {
await wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, idToUse, [
`${i18n.t('Log.SynchronizationFailed')} (${reason})`,
]);
void analyticsService.track('sync.failed', {
...analyticsBaseProperties,
reason: typeof gitUrl !== 'string' ? 'missing_git_url' : 'missing_user_info',
});
}
}

View file

@ -3,6 +3,7 @@ import { inject, injectable } from 'inversify';
import { BehaviorSubject } from 'rxjs';
import { WikiChannel } from '@/constants/channels';
import type { IAnalyticsService } from '@services/analytics/interface';
import { container } from '@services/container';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
@ -58,6 +59,11 @@ export class ThemeService implements IThemeService {
nativeTheme.themeSource = themeSource;
await this.preferenceService.set('themeSource', themeSource);
this.updateThemeSubject({ shouldUseDarkColors: this.shouldUseDarkColorsSync() });
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('theme.changed', {
themeSource,
darkMode: this.shouldUseDarkColorsSync(),
});
await this.updateActiveWikiTheme();
}

View file

@ -5,6 +5,7 @@ import fetch from 'node-fetch';
import { BehaviorSubject } from 'rxjs';
import semver from 'semver';
import type { IAnalyticsService } from '@services/analytics/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
import { logger } from '@services/libs/log';
@ -44,6 +45,7 @@ export class Updater implements IUpdaterService {
public async checkForUpdates(): Promise<void> {
logger.debug('Checking for updates...');
this.setMetaData({ status: IUpdaterStatus.checkingForUpdate });
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
await menuService.insertMenu('TidGi', [
{
@ -55,6 +57,7 @@ export class Updater implements IUpdaterService {
let latestVersion: string;
let latestReleasePageUrl: string;
const allowPrerelease = await this.preferenceService.get('allowPrerelease');
void analyticsService.track('updater.check_started', { allowPrerelease });
try {
const latestReleaseData = await (allowPrerelease
? fetch('https://api.github.com/repos/tiddly-gittly/TidGi-Desktop/releases?per_page=1')
@ -70,6 +73,7 @@ export class Updater implements IUpdaterService {
latestReleasePageUrl = latestReleaseData.html_url;
} catch (fetchError) {
logger.error('Fetching latest release failed', { fetchError });
void analyticsService.track('updater.check_failed', { allowPrerelease });
this.setMetaData({
status: 'error' as IUpdaterStatus,
info: { errorMessage: (fetchError as Error).message },
@ -94,6 +98,7 @@ export class Updater implements IUpdaterService {
const hasNewRelease = semver.gt(latestVersion, currentVersion);
logger.debug('Compare version', { currentVersion, isLatestRelease: hasNewRelease });
if (hasNewRelease) {
void analyticsService.track('updater.update_available', { allowPrerelease });
this.setMetaData({ status: IUpdaterStatus.updateAvailable, info: { version: latestVersion, latestReleasePageUrl } });
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
await menuService.insertMenu('TidGi', [
@ -106,6 +111,7 @@ export class Updater implements IUpdaterService {
},
]);
} else {
void analyticsService.track('updater.update_not_available', { allowPrerelease });
this.setMetaData({ status: IUpdaterStatus.updateNotAvailable, info: { version: latestVersion } });
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
await menuService.insertMenu('TidGi', [

View file

@ -261,7 +261,10 @@ export class Wiki implements IWikiService {
logger.debug(`wikiWorker initialized`, { function: 'Wiki.startWiki' });
this.wikiWorkers[workspaceID] = { proxy: worker, nativeWorker: wikiWorker, detachWorker };
this.wikiWorkerStartedEventTarget.dispatchEvent(new Event(wikiWorkerStartedEventName(workspaceID)));
void worker.notifyServicesReady();
// Notify worker that services are ready before subscribing to startNodeJSWiki
// This ensures the worker doesn't start sending messages before we're subscribed
await worker.notifyServicesReady();
const loggerMeta = { worker: 'NodeJSWiki', homePath: wikiFolderLocation, workspaceID };
@ -658,7 +661,7 @@ export class Wiki implements IWikiService {
this.logProgress(i18n.t('AddWorkspace.SubWikiCreationCompleted'));
}
public async removeWiki(wikiPath: string, _mainWikiToUnLink?: string, _onlyRemoveLink = false): Promise<void> {
public async removeWiki(wikiPath: string): Promise<void> {
// Sub-wiki configuration is now handled by FileSystemAdaptor - no symlinks to manage
// Just remove the wiki folder itself
await shell.trashItem(wikiPath);

View file

@ -56,7 +56,7 @@ export interface IWikiService {
*/
getWorkersInfo(): Promise<IWorkerInfo[]>;
packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise<void>;
removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink?: boolean): Promise<void>;
removeWiki(wikiPath: string): Promise<void>;
restartWiki(workspace: IWorkspace): Promise<void>;
setAllWikiStartLockOff(): void;
setWikiLanguage(workspaceID: string, tiddlywikiLanguageName: string): Promise<void>;

View file

@ -1,6 +1,6 @@
import { isHtmlWiki } from '@/constants/fileNames';
import { remove } from 'fs-extra';
import { TiddlyWiki } from 'tiddlywiki';
import { loadTiddlyWikiModule } from './loadTiddlyWikiModule';
export async function extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath: string, constants: { TIDDLY_WIKI_BOOT_PATH: string }): Promise<void> {
// tiddlywiki --load ./mywiki.html --savewikifolder ./mywikifolder
@ -8,6 +8,7 @@ export async function extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath:
// . /mywikifolder is the path where the tiddlder and plugins folders are stored
const { TIDDLY_WIKI_BOOT_PATH } = constants;
try {
const { TiddlyWiki } = await loadTiddlyWikiModule(TIDDLY_WIKI_BOOT_PATH);
const wikiInstance = TiddlyWiki();
wikiInstance.boot.argv = ['--load', htmlWikiPath, '--savewikifolder', saveWikiFolderPath, 'explodePlugins=no'];
await new Promise<void>((resolve, reject) => {
@ -36,6 +37,7 @@ export async function packetHTMLFromWikiFolder(folderWikiPath: string, pathOfNew
// tiddlywiki ./mywikifolder --rendertiddler '$:/core/save/all' mywiki.html text/plain
// . /mywikifolder is the path to the wiki folder, which generally contains the tiddlder and plugins directories
const { TIDDLY_WIKI_BOOT_PATH } = constants;
const { TiddlyWiki } = await loadTiddlyWikiModule(TIDDLY_WIKI_BOOT_PATH);
const wikiInstance = TiddlyWiki();
// a .html file path should be provided, but if provided a folder path, we can add /index.html to fix it.
wikiInstance.boot.argv = [folderWikiPath, '--rendertiddler', '$:/core/save/all', isHtmlWiki(pathOfNewHTML) ? pathOfNewHTML : `${pathOfNewHTML}/index.html`, 'text/plain'];

View file

@ -0,0 +1,20 @@
import path from 'path';
export function authTokenIsProvided(providedToken: string | undefined): providedToken is string {
return typeof providedToken === 'string' && providedToken.length > 0;
}
/**
* Dynamically load the TiddlyWiki module from wiki-local installation if available,
* otherwise fall back to the built-in version shipped with TidGi.
* Must be dynamic because static `import { TiddlyWiki } from 'tiddlywiki'`
* always resolves to the built-in version at module load time.
*/
export async function loadTiddlyWikiModule(TIDDLY_WIKI_BOOT_PATH: string) {
const tiddlyWikiPackagePath = path.resolve(TIDDLY_WIKI_BOOT_PATH, '..');
try {
return await import(tiddlyWikiPackagePath) as typeof import('tiddlywiki');
} catch {
return await import('tiddlywiki') as typeof import('tiddlywiki');
}
}

View file

@ -12,16 +12,39 @@ import type { Server } from 'node:http';
import inspector from 'node:inspector';
import path from 'path';
import { Observable } from 'rxjs';
import { TiddlyWiki } from 'tiddlywiki';
import { IWikiMessage, WikiControlActions } from '../interface';
import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer';
import type { IStartNodeJSWikiConfigs } from '../wikiWorker';
import { setWikiInstance } from './globals';
import { ipcServerRoutes } from './ipcServerRoutes';
import { authTokenIsProvided, loadTiddlyWikiModule } from './loadTiddlyWikiModule';
import { createLoadWikiTiddlersWithSubWikis } from './loadWikiTiddlersWithSubWikis';
import { authTokenIsProvided } from './wikiWorkerUtilities';
export function startNodeJSWiki(configs: IStartNodeJSWikiConfigs): Observable<IWikiMessage> {
type BootContext = Pick<
IStartNodeJSWikiConfigs,
| 'constants'
| 'enableHTTPAPI'
| 'authToken'
| 'excludedPlugins'
| 'homePath'
| 'https'
| 'readOnlyMode'
| 'rootTiddler'
| 'useWikiFolderAsTiddlersPath'
| 'shouldUseDarkColors'
| 'subWikis'
| 'tiddlyWikiHost'
| 'tiddlyWikiPort'
| 'tokenAuth'
| 'userName'
| 'workspace'
>;
async function bootWiki(
configs: BootContext,
observer: { next: (value: IWikiMessage) => void },
fullBootArgv: string[],
): Promise<void> {
const {
enableHTTPAPI,
authToken,
@ -29,12 +52,10 @@ export function startNodeJSWiki(configs: IStartNodeJSWikiConfigs): Observable<IW
excludedPlugins = [],
homePath,
https,
isDev,
openDebugger,
readOnlyMode,
rootTiddler = '$:/core/save/all',
useWikiFolderAsTiddlersPath = false,
shouldUseDarkColors,
useWikiFolderAsTiddlersPath = false,
subWikis = [],
tiddlyWikiHost = defaultServerIP,
tiddlyWikiPort = 5112,
@ -42,6 +63,143 @@ export function startNodeJSWiki(configs: IStartNodeJSWikiConfigs): Observable<IW
userName,
workspace,
} = configs;
// Log which TiddlyWiki version is being used (local vs built-in)
const isUsingLocalTiddlyWiki = TIDDLY_WIKI_BOOT_PATH.includes(path.join(homePath, 'node_modules'));
void native.logFor(
workspace.name,
'info',
`Starting TiddlyWiki from ${isUsingLocalTiddlyWiki ? 'wiki-local installation' : 'built-in installation'}: ${TIDDLY_WIKI_BOOT_PATH}`,
);
const { TiddlyWiki } = await loadTiddlyWikiModule(TIDDLY_WIKI_BOOT_PATH);
const wikiInstance = TiddlyWiki();
setWikiInstance(wikiInstance);
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const pluginPaths = [
path.resolve(homePath, 'plugins'),
TIDDLYWIKI_BUILT_IN_PLUGINS_PATH,
];
process.env.TIDDLYWIKI_PLUGIN_PATH = pluginPaths.join(pathSeparator);
process.env.TIDDLYWIKI_THEME_PATH = path.resolve(homePath, 'themes');
if (subWikis.length > 0) {
wikiInstance.loadWikiTiddlers = createLoadWikiTiddlersWithSubWikis(
wikiInstance,
homePath,
subWikis,
{ allowLoadingWithoutWikiInfo: useWikiFolderAsTiddlersPath },
workspace.name,
native,
);
}
wikiInstance.boot.extraPlugins = [
readOnlyMode === true ? undefined : 'plugins/linonetwo/watch-filesystem-adaptor',
'plugins/linonetwo/tidgi-ipc-syncadaptor',
'plugins/linonetwo/tidgi-ipc-syncadaptor-ui',
enableHTTPAPI ? 'plugins/tiddlywiki/tiddlyweb' : undefined,
].filter(Boolean) as string[];
const readonlyArguments = readOnlyMode === true
? ['gzip=yes', 'readers=(anon)', `writers=${userName || nanoid()}`, `username=${userName}`, `password=${nanoid()}`]
: [];
const infoTiddlerText = `exports.getInfoTiddlerFields = () => [
{title: "$:/info/tidgi/readOnlyMode", text: "${readOnlyMode === true ? 'yes' : 'no'}"},
{title: "$:/info/tidgi/workspaceID", text: ${JSON.stringify(workspace.id)}},
{title: "$:/info/tidgi/useWikiFolderAsTiddlersPath", text: "${useWikiFolderAsTiddlersPath ? 'yes' : 'no'}"},
]`;
wikiInstance.preloadTiddler({
title: '$:/core/modules/info/tidgi-server.js',
text: infoTiddlerText,
type: 'application/javascript',
'module-type': 'info',
});
let tokenAuthenticateArguments: string[] = [`anon-username=${userName}`];
if (tokenAuth === true) {
if (authTokenIsProvided(authToken)) {
tokenAuthenticateArguments = [`authenticated-user-header=${getTidGiAuthHeaderWithToken(authToken)}`, `readers=${userName}`, `writers=${userName}`];
} else {
observer.next({ type: 'control', actions: WikiControlActions.error, message: 'tokenAuth is true, but authToken is empty, this can be a bug.', argv: fullBootArgv });
}
}
const httpsArguments = https?.enabled && https.tlsKey && https.tlsCert
? [`tls-key=${https.tlsKey}`, `tls-cert=${https.tlsCert}`]
: [];
const excludePluginsArguments = readOnlyMode === true
? [
'--setfield',
excludedPlugins.map((pluginOrTiddlerTitle) => pluginOrTiddlerTitle.includes('[') && pluginOrTiddlerTitle.includes(']') ? pluginOrTiddlerTitle : `[[${pluginOrTiddlerTitle}]]`)
.join(' '),
'text',
'',
'text/plain',
]
: [];
const argv = enableHTTPAPI
? [
homePath,
'--listen',
`port=${tiddlyWikiPort}`,
`host=${tiddlyWikiHost}`,
`root-tiddler=${rootTiddler}`,
...httpsArguments,
...readonlyArguments,
...tokenAuthenticateArguments,
...excludePluginsArguments,
]
: [homePath, '--version'];
wikiInstance.boot.argv = [...argv];
fullBootArgv.length = 0;
fullBootArgv.push(...argv);
type TidgiContainer = { tidgi?: { service?: TidgiService } };
const wikiInstanceWithTidgi = wikiInstance as unknown as (typeof wikiInstance & TidgiContainer);
wikiInstanceWithTidgi.tidgi = wikiInstanceWithTidgi.tidgi ?? {};
wikiInstanceWithTidgi.tidgi.service = service as unknown as TidgiService;
wikiInstance.hooks.addHook('th-server-command-post-start', function(_server: unknown, nodeServer: Server) {
nodeServer.on('error', function(error: Error) {
observer.next({ type: 'control', actions: WikiControlActions.error, message: error.message, argv: fullBootArgv });
});
nodeServer.on('listening', function() {
observer.next({
type: 'control',
actions: WikiControlActions.listening,
message:
`Tiddlywiki listening at http://${tiddlyWikiHost}:${tiddlyWikiPort} (webview uri ip may be different, being nativeService.getLocalHostUrlWithActualInfo(appUrl, workspace.id)) with args ${
fullBootArgv.join(' ')
}`,
argv: fullBootArgv,
});
});
});
wikiInstance.boot.startup({ bootPath: TIDDLY_WIKI_BOOT_PATH });
ipcServerRoutes.setConfig({ readOnlyMode, shouldUseDarkColors });
ipcServerRoutes.setHomePath(homePath);
ipcServerRoutes.setWikiInstance(wikiInstance);
ipcServerRoutes.setSubWikiPaths(subWikis.map(subWiki => subWiki.wikiFolderLocation));
wikiOperationsInWikiWorker.setWikiInstance(wikiInstance);
observer.next({
type: 'control',
actions: WikiControlActions.booted,
message: `Tiddlywiki booted with args ${fullBootArgv.join(' ')}`,
argv: fullBootArgv,
});
}
export function startNodeJSWiki(configs: IStartNodeJSWikiConfigs): Observable<IWikiMessage> {
const { isDev, openDebugger, workspace } = configs;
const bootContext: BootContext = configs;
const fullBootArgv: string[] = [];
return new Observable<IWikiMessage>((observer) => {
if (openDebugger === true) {
inspector.open();
@ -52,222 +210,46 @@ export function startNodeJSWiki(configs: IStartNodeJSWikiConfigs): Observable<IW
// Wait for services to be ready before using intercept with logFor
onWorkerServicesReady(() => {
void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady', configs as unknown as Record<string, unknown>);
const textDecoder = new TextDecoder();
intercept(
(newStdOut: string | Uint8Array) => {
const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'info', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace));
});
return message;
},
(newStdError: string | Uint8Array) => {
const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError);
// Send to main process logger if services are ready
void native.logFor(workspace.name, 'error', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stderr to main process:', error, message);
});
// Detect critical plugin loading errors that can cause white screen
// These errors occur during TiddlyWiki boot module execution
if (
message.includes('Error executing boot module') ||
message.includes('Cannot find module')
) {
observer.next({
type: 'control',
source: 'plugin-error',
actions: WikiControlActions.error,
message,
argv: [],
setTimeout(() => {
const textDecoder = new TextDecoder();
intercept(
(newStdOut: string | Uint8Array) => {
const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut);
void native.logFor(workspace.name, 'info', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace));
});
}
return message;
},
);
return message;
},
(newStdError: string | Uint8Array) => {
const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError);
void native.logFor(workspace.name, 'error', message).catch((error: unknown) => {
console.error('[intercept] Failed to send stderr to main process:', error, message);
});
if (
message.includes('Error executing boot module') ||
message.includes('Cannot find module')
) {
observer.next({
type: 'control',
source: 'plugin-error',
actions: WikiControlActions.error,
message,
argv: [],
});
}
return message;
},
);
}, 50);
});
let fullBootArgv: string[] = [];
// mark isDev as used to satisfy lint when not needed directly
void isDev;
observer.next({ type: 'control', actions: WikiControlActions.start, argv: fullBootArgv });
try {
// Log which TiddlyWiki version is being used (local vs built-in)
const isUsingLocalTiddlyWiki = TIDDLY_WIKI_BOOT_PATH.includes(path.join(homePath, 'node_modules'));
void native.logFor(
workspace.name,
'info',
`Starting TiddlyWiki from ${isUsingLocalTiddlyWiki ? 'wiki-local installation' : 'built-in installation'}: ${TIDDLY_WIKI_BOOT_PATH}`,
);
const wikiInstance = TiddlyWiki();
setWikiInstance(wikiInstance);
/**
* Set plugin search paths. When wiki uses local TiddlyWiki installation,
* we still need to include TidGi's built-in plugins path so our custom plugins can be found.
* Path separator is ':' on Unix and ';' on Windows.
*/
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const pluginPaths = [
path.resolve(homePath, 'plugins'),
TIDDLYWIKI_BUILT_IN_PLUGINS_PATH,
];
process.env.TIDDLYWIKI_PLUGIN_PATH = pluginPaths.join(pathSeparator);
process.env.TIDDLYWIKI_THEME_PATH = path.resolve(homePath, 'themes');
/**
* Hook loadWikiTiddlers to inject sub-wiki tiddlers after main wiki is loaded.
*/
if (subWikis.length > 0) {
wikiInstance.loadWikiTiddlers = createLoadWikiTiddlersWithSubWikis(
wikiInstance,
homePath,
subWikis,
{ allowLoadingWithoutWikiInfo: useWikiFolderAsTiddlersPath },
workspace.name,
native,
);
}
// don't add `+` prefix to plugin name here. `+` only used in args[0], but we are not prepend this list to the args list.
wikiInstance.boot.extraPlugins = [
/**
* add tiddly filesystem back if is not readonly https://github.com/Jermolene/TiddlyWiki5/issues/4484#issuecomment-596779416
* Enhanced filesystem adaptor that routes tiddlers to sub-wikis based on tags.
* Replaces the complex string manipulation of $:/config/FileSystemPaths with direct IPC calls to workspace service.
* Only enabled in non-readonly mode since it handles filesystem operations.
*/
readOnlyMode === true ? undefined : 'plugins/linonetwo/watch-filesystem-adaptor',
/**
* Install $:/plugins/linonetwo/tidgi instead of +plugins/tiddlywiki/tiddlyweb to speedup (without JSON.parse) and fix http errors when network change.
* See scripts/compilePlugins.mjs for how it is built.
*/
'plugins/linonetwo/tidgi-ipc-syncadaptor',
'plugins/linonetwo/tidgi-ipc-syncadaptor-ui',
enableHTTPAPI ? 'plugins/tiddlywiki/tiddlyweb' : undefined, // we use $:/plugins/linonetwo/tidgi instead
// 'plugins/linonetwo/watch-fs',
].filter(Boolean) as string[];
/**
* Make wiki readonly if readonly is true. This is normally used for server mode, so also enable gzip.
*
* The principle is to configure anonymous reads, but writes require a login, and then give an unguessable random password here.
*
* @url https://wiki.zhiheng.io/static/TiddlyWiki%253A%2520Readonly%2520for%2520Node.js%2520Server.html
*/
const readonlyArguments = readOnlyMode === true ? ['gzip=yes', 'readers=(anon)', `writers=${userName || nanoid()}`, `username=${userName}`, `password=${nanoid()}`] : [];
// Preload workspace ID for filesystem adaptor
const infoTiddlerText = `exports.getInfoTiddlerFields = () => [
{title: "$:/info/tidgi/readOnlyMode", text: "${readOnlyMode === true ? 'yes' : 'no'}"},
{title: "$:/info/tidgi/workspaceID", text: ${JSON.stringify(workspace.id)}},
{title: "$:/info/tidgi/useWikiFolderAsTiddlersPath", text: "${useWikiFolderAsTiddlersPath ? 'yes' : 'no'}"},
]`;
wikiInstance.preloadTiddler({
title: '$:/core/modules/info/tidgi-server.js',
text: infoTiddlerText,
type: 'application/javascript',
'module-type': 'info',
});
/**
* Use authenticated-user-header to provide `TIDGI_AUTH_TOKEN_HEADER` as header key to receive a value as username (we use it as token).
*
* For example, when server starts with `"readers=s0me7an6om3ey" writers=s0me7an6om3ey" authenticated-user-header=x-tidgi-auth-token`, only when other app query with header `x-tidgi-auth-token: s0me7an6om3ey`, can it get access to the wiki.
*
* When this is not enabled, provide a `anon-username` for any users.
*
* @url https://github.com/Jermolene/TiddlyWiki5/discussions/7469
*/
let tokenAuthenticateArguments: string[] = [`anon-username=${userName}`];
if (tokenAuth === true) {
if (authTokenIsProvided(authToken)) {
tokenAuthenticateArguments = [`authenticated-user-header=${getTidGiAuthHeaderWithToken(authToken)}`, `readers=${userName}`, `writers=${userName}`];
} else {
observer.next({ type: 'control', actions: WikiControlActions.error, message: 'tokenAuth is true, but authToken is empty, this can be a bug.', argv: fullBootArgv });
}
}
const httpsArguments = https?.enabled && https.tlsKey && https.tlsCert
? [`tls-key=${https.tlsKey}`, `tls-cert=${https.tlsCert}`]
: [];
/**
* Set excluded plugins or tiddler content to empty string.
* Should disable plugins/tiddlywiki/filesystem and $:/plugins/linonetwo/watch-filesystem-adaptor (so only work in readonly mode), otherwise will write empty string to tiddlers.
* @url https://github.com/linonetwo/wiki/blob/8f1f091455eec23a9f016d6972b7f38fe85efde1/tiddlywiki.info#LL35C1-L39C20
*/
const excludePluginsArguments = readOnlyMode === true
? [
'--setfield',
excludedPlugins.map((pluginOrTiddlerTitle) =>
// allows filter like `[is[binary]] [type[application/msword]] -[type[application/pdf]]`, but also auto add `[[]]` to plugin title to be like `[[$:/plugins/tiddlywiki/filesystem]]`
pluginOrTiddlerTitle.includes('[') && pluginOrTiddlerTitle.includes(']') ? pluginOrTiddlerTitle : `[[${pluginOrTiddlerTitle}]]`
).join(' '),
'text',
'',
'text/plain',
]
: [];
fullBootArgv = enableHTTPAPI
? [
homePath,
'--listen',
`port=${tiddlyWikiPort}`,
`host=${tiddlyWikiHost}`,
`root-tiddler=${rootTiddler}`,
...httpsArguments,
...readonlyArguments,
...tokenAuthenticateArguments,
...excludePluginsArguments,
]
: [homePath, '--version'];
wikiInstance.boot.argv = [...fullBootArgv];
/**
* Attach service proxies to `$tw.tidgi.service` so that TiddlyWiki route modules
* (which run in vm.runInContext sandbox) can access them.
* The sandbox injects `$tw` but NOT `globalThis` or `global`,
* so `$tw.tidgi.service` is the only way for plugins to reach IPC service proxies.
*/
type TidgiContainer = { tidgi?: { service?: TidgiService } };
const wikiInstanceWithTidgi = wikiInstance as unknown as (typeof wikiInstance & TidgiContainer);
wikiInstanceWithTidgi.tidgi = wikiInstanceWithTidgi.tidgi ?? {};
wikiInstanceWithTidgi.tidgi.service = service as unknown as TidgiService;
wikiInstance.hooks.addHook('th-server-command-post-start', function(_server: unknown, nodeServer: Server) {
nodeServer.on('error', function(error: Error) {
observer.next({ type: 'control', actions: WikiControlActions.error, message: error.message, argv: fullBootArgv });
});
nodeServer.on('listening', function() {
observer.next({
type: 'control',
actions: WikiControlActions.listening,
message:
`Tiddlywiki listening at http://${tiddlyWikiHost}:${tiddlyWikiPort} (webview uri ip may be different, being nativeService.getLocalHostUrlWithActualInfo(appUrl, workspace.id)) with args ${
wikiInstance === undefined ? '(wikiInstance is undefined)' : fullBootArgv.join(' ')
}`,
argv: fullBootArgv,
});
});
});
wikiInstance.boot.startup({ bootPath: TIDDLY_WIKI_BOOT_PATH });
// after setWikiInstance, ipc server routes will start serving content
ipcServerRoutes.setConfig({ readOnlyMode, shouldUseDarkColors });
ipcServerRoutes.setHomePath(homePath);
ipcServerRoutes.setWikiInstance(wikiInstance);
ipcServerRoutes.setSubWikiPaths(subWikis.map(subWiki => subWiki.wikiFolderLocation));
wikiOperationsInWikiWorker.setWikiInstance(wikiInstance);
observer.next({
type: 'control',
actions: WikiControlActions.booted,
message: `Tiddlywiki booted with args ${wikiInstance === undefined ? '(wikiInstance is undefined)' : fullBootArgv.join(' ')}`,
argv: fullBootArgv,
});
} catch (error) {
bootWiki(bootContext, observer, fullBootArgv).catch((error: unknown) => {
const message = `Tiddlywiki booted failed with error ${(error as Error).message} ${(error as Error).stack ?? ''}`;
observer.next({ type: 'control', source: 'try catch', actions: WikiControlActions.error, message, argv: fullBootArgv });
}
});
});
}

View file

@ -1,3 +0,0 @@
export function authTokenIsProvided(providedToken: string | undefined): providedToken is string {
return typeof providedToken === 'string' && providedToken.length > 0;
}

View file

@ -75,6 +75,7 @@ describe('WikiEmbeddingService Integration Tests', () => {
excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins,
enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch,
hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused,
useTidgiConfigSync: true,
storageService: SupportedStorageServices.local,
});

View file

@ -75,7 +75,7 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
if (!isWikiWorkspace(newWorkspace)) {
throw new Error('initWikiGitTransaction can only be called with wiki workspaces');
}
const { gitUrl, storageService, wikiFolderLocation, isSubWiki, id: workspaceID, mainWikiToLink } = newWorkspace;
const { gitUrl, storageService, wikiFolderLocation, isSubWiki, id: workspaceID } = newWorkspace;
try {
const previousActiveId = workspaceService.getActiveWorkspaceSync()?.id;
await workspaceService.setActiveWorkspace(newWorkspace.id, previousActiveId);
@ -110,11 +110,7 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
await workspaceService.remove(workspaceID);
try {
if (!isSubWiki) {
await wikiService.removeWiki(wikiFolderLocation);
} else if (typeof mainWikiToLink === 'string') {
await wikiService.removeWiki(wikiFolderLocation, mainWikiToLink);
}
await wikiService.removeWiki(wikiFolderLocation);
} catch (error_: unknown) {
throw new InitWikiGitRevertError(String(error_));
}
@ -211,9 +207,9 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
throw new Error(`Need to get workspace with id ${workspaceID} but failed`);
}
if (!isWikiWorkspace(workspace)) {
throw new Error('removeWikiGitTransaction can only be called with wiki workspaces');
throw new Error('removeWorkspace can only be called with wiki workspaces');
}
const { isSubWiki, mainWikiToLink, wikiFolderLocation, id, name } = workspace;
const { isSubWiki, wikiFolderLocation, id, name } = workspace;
const { response } = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: [i18n.t('WorkspaceSelector.RemoveWorkspace'), i18n.t('WorkspaceSelector.RemoveWorkspaceAndDelete'), i18n.t('Cancel')],
@ -233,10 +229,7 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
logger.error(error.message, { error });
});
if (isSubWiki) {
if (mainWikiToLink === null) {
throw new Error(`workspace.mainWikiToLink is null in WikiGitWorkspace.removeWorkspace ${JSON.stringify(workspace)}`);
}
await wikiService.removeWiki(wikiFolderLocation, mainWikiToLink, onlyRemoveWorkspace);
await wikiService.removeWiki(wikiFolderLocation);
// Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin
} else {
// is main wiki, also delete all sub wikis

View file

@ -80,8 +80,8 @@ export const windowDimension: Record<WindowNames, { height?: number; width?: num
height: 800,
},
[WindowNames.preferences]: {
width: 840,
height: 700,
width: 960,
height: 800,
},
[WindowNames.notifications]: {
width: 400,

View file

@ -7,6 +7,7 @@ import serviceIdentifier from '@services/serviceIdentifier';
import { windowDimension, WindowMeta, WindowNames } from '@services/windows/WindowProperties';
import { Channels, MetaDataChannel, ViewChannel, WindowChannel } from '@/constants/channels';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import type { IViewService } from '@services/view/interface';
import type { IWorkspaceService } from '@services/workspaces/interface';
@ -281,6 +282,14 @@ export class Window implements IWindowService {
}
}
windowWithBrowserViewState?.manage(newWindow);
// When runOnBackground=true the main window is hidden rather than destroyed, so 'closed' never
// fires and electron-window-state never writes the state file. Save explicitly on 'hide'.
if (windowName === WindowNames.main && windowWithBrowserViewState !== undefined) {
const stateReference = windowWithBrowserViewState;
newWindow.on('hide', () => {
stateReference.saveState(newWindow);
});
}
if (isWindowWithBrowserView) {
const activeWorkspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace();
const viewService = container.get<IViewService>(serviceIdentifier.View);
@ -299,6 +308,12 @@ export class Window implements IWindowService {
await workspaceViewService.refreshActiveWorkspaceView();
}
}
// Track analytics event when preferences window is opened
if (windowName === WindowNames.preferences) {
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('settings.opened', { window: 'preferences' });
}
if (returnWindow === true) {
return newWindow;
}

View file

@ -0,0 +1,253 @@
import { SupportedStorageServices } from '@services/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Workspace } from '../index';
import { type IWikiWorkspace, wikiWorkspaceDefaultValues } from '../interface';
// Mock registerMenu to avoid side effects
vi.mock('../registerMenu', () => ({
registerMenu: vi.fn(),
}));
// Mock tidgi config utilities
const mockWriteTidgiConfig = vi.fn();
const mockReadTidgiConfig = vi.fn();
const mockReadTidgiConfigSync = vi.fn();
const mockExtractSyncableConfig = vi.fn();
const mockRemoveSyncableFields = vi.fn();
vi.mock('../../database/configSetting', () => ({
writeTidgiConfig: (...args: unknown[]) => mockWriteTidgiConfig(...args) as Promise<void>,
readTidgiConfig: (...args: unknown[]) => mockReadTidgiConfig(...args) as Promise<Record<string, unknown> | undefined>,
readTidgiConfigSync: (...args: unknown[]) => mockReadTidgiConfigSync(...args) as Record<string, unknown> | undefined,
extractSyncableConfig: (...args: unknown[]) => mockExtractSyncableConfig(...args) as Record<string, unknown>,
removeSyncableFields: (...args: unknown[]) => mockRemoveSyncableFields(...args) as Record<string, unknown>,
mergeWithSyncedConfig: (local: unknown, synced: unknown) => ({ ...(local as object), ...(synced as object) }),
getTidgiConfigPath: (wikiFolderLocation: string) => `${wikiFolderLocation}/tidgi.config.json`,
hasTidgiConfig: vi.fn(),
initTidgiConfigLogger: vi.fn(),
TIDGI_CONFIG_FILE: 'tidgi.config.json',
TIDGI_CONFIG_VERSION: 1,
}));
// Mock container to control database service and avoid missing bindings
const mockGetSetting = vi.fn();
const mockSetSetting = vi.fn();
const mockImmediatelyStoreSettingsToFile = vi.fn();
vi.mock('@services/container', async () => {
const actual = await vi.importActual<typeof import('@services/container')>('@services/container');
return Object.assign({}, actual, {
container: Object.assign(Object.create(Object.getPrototypeOf(actual.container)), actual.container, {
get: vi.fn((identifier: symbol) => {
const description = identifier.toString();
if (description.includes('Database')) {
return {
getSetting: mockGetSetting,
setSetting: mockSetSetting,
immediatelyStoreSettingsToFile: mockImmediatelyStoreSettingsToFile,
};
}
if (description.includes('MenuService')) {
return {
buildMenu: vi.fn().mockResolvedValue(undefined),
insertMenu: vi.fn().mockResolvedValue(undefined),
};
}
if (description.includes('Authentication')) {
return {
generateOneTimeAdminAuthTokenForWorkspaceSync: vi.fn().mockReturnValue('mock-token'),
};
}
if (description.includes('WorkspaceView')) {
return {
setActiveWorkspaceView: vi.fn().mockResolvedValue(undefined),
};
}
if (description.includes('Analytics')) {
return {
track: vi.fn().mockResolvedValue(undefined),
identify: vi.fn().mockResolvedValue(undefined),
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return actual.container.get(identifier);
}),
}),
});
});
function createWorkspace(overrides: Partial<IWikiWorkspace>): IWikiWorkspace {
return {
...wikiWorkspaceDefaultValues,
id: 'workspace-1',
name: 'Workspace 1',
wikiFolderLocation: '/tmp/workspace-1',
isSubWiki: false,
mainWikiID: null,
mainWikiToLink: null,
pageType: null,
picturePath: null,
homeUrl: 'tidgi://workspace-1',
gitUrl: null,
storageService: SupportedStorageServices.local,
tagNames: [],
userName: 'tester',
...overrides,
};
}
function createWorkspaceService(workspace: IWikiWorkspace): Workspace {
const service = new Workspace();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).workspaces = { [workspace.id]: workspace };
return service;
}
describe('Workspace useTidgiConfigSync', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetSetting.mockReturnValue({});
mockWriteTidgiConfig.mockResolvedValue(undefined);
mockExtractSyncableConfig.mockImplementation((workspace: IWikiWorkspace) => ({
id: workspace.id,
name: workspace.name,
readOnlyMode: workspace.readOnlyMode,
}));
mockRemoveSyncableFields.mockImplementation((workspace: IWikiWorkspace) => {
const { name, readOnlyMode, ...rest } = workspace as unknown as Record<string, unknown>;
void name;
void readOnlyMode;
return rest;
});
});
describe('create', () => {
it('should set useTidgiConfigSync to true by default when creating workspace', async () => {
const service = new Workspace();
mockGetSetting.mockReturnValue({});
const newWorkspace = await service.create({
name: 'Test Wiki',
wikiFolderLocation: '/tmp/test-wiki',
isSubWiki: false,
mainWikiToLink: null,
mainWikiID: null,
tagNames: [],
port: 5212,
storageService: SupportedStorageServices.local,
readOnlyMode: false,
tokenAuth: false,
enableFileSystemWatch: false,
gitUrl: null,
});
expect((newWorkspace as IWikiWorkspace).useTidgiConfigSync).toBe(true);
});
it('should set useTidgiConfigSync to false when useTidgiConfig is false', async () => {
const service = new Workspace();
mockGetSetting.mockReturnValue({});
const newWorkspace = await service.create({
name: 'Test Wiki',
wikiFolderLocation: '/tmp/test-wiki',
isSubWiki: false,
mainWikiToLink: null,
mainWikiID: null,
tagNames: [],
port: 5212,
storageService: SupportedStorageServices.local,
readOnlyMode: false,
tokenAuth: false,
enableFileSystemWatch: false,
gitUrl: null,
useTidgiConfig: false,
});
expect((newWorkspace as IWikiWorkspace).useTidgiConfigSync).toBe(false);
});
});
describe('set', () => {
it('should write tidgi.config.json and strip syncable fields from settings.json when useTidgiConfigSync is true and tidgi.config.json exists', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: true });
const service = createWorkspaceService(workspace);
mockReadTidgiConfigSync.mockReturnValue({ version: 1, name: 'Workspace 1' });
await service.set(workspace.id, { ...workspace, name: 'Updated Name' });
expect(mockWriteTidgiConfig).toHaveBeenCalledWith(workspace.wikiFolderLocation, expect.any(Object));
expect(mockRemoveSyncableFields).toHaveBeenCalled();
expect(mockSetSetting).toHaveBeenCalledWith('workspaces', expect.any(Object));
});
it('should NOT write tidgi.config.json and should keep syncable fields in settings.json when useTidgiConfigSync is false', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: false, readOnlyMode: true });
const service = createWorkspaceService(workspace);
mockReadTidgiConfigSync.mockReturnValue({ version: 1, name: 'Workspace 1' });
await service.set(workspace.id, { ...workspace, name: 'Updated Name' });
expect(mockWriteTidgiConfig).not.toHaveBeenCalled();
expect(mockRemoveSyncableFields).not.toHaveBeenCalled();
// Verify settings.json receives the full workspace including syncable fields
const setSettingCall = mockSetSetting.mock.calls[0];
expect(setSettingCall[0]).toBe('workspaces');
const savedWorkspace = setSettingCall[1][workspace.id] as IWikiWorkspace;
expect(savedWorkspace.name).toBe('Updated Name');
expect(savedWorkspace.readOnlyMode).toBe(true);
});
it('should NOT write tidgi.config.json even when syncable fields changed if useTidgiConfigSync is false', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: false });
const service = createWorkspaceService(workspace);
mockReadTidgiConfigSync.mockReturnValue(undefined);
const updatedWorkspace = { ...workspace, readOnlyMode: true, name: 'Changed Name' };
await service.set(workspace.id, updatedWorkspace);
expect(mockWriteTidgiConfig).not.toHaveBeenCalled();
});
});
describe('sanitizeWorkspace', () => {
it('should read tidgi.config.json during initial load when useTidgiConfigSync is true', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: true });
const service = createWorkspaceService(workspace);
mockReadTidgiConfigSync.mockReturnValue({ version: 1, name: 'Synced Name' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (service as any).sanitizeWorkspace(workspace, true);
expect(mockReadTidgiConfigSync).toHaveBeenCalledWith(workspace.wikiFolderLocation);
expect(result.name).toBe('Synced Name');
});
it('should NOT read tidgi.config.json during initial load when useTidgiConfigSync is false', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: false, name: 'Local Name' });
const service = createWorkspaceService(workspace);
mockReadTidgiConfigSync.mockReturnValue({ version: 1, name: 'Synced Name' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (service as any).sanitizeWorkspace(workspace, true);
expect(mockReadTidgiConfigSync).not.toHaveBeenCalled();
expect(result.name).toBe('Local Name');
});
it('should not read tidgi.config.json during runtime updates regardless of useTidgiConfigSync', async () => {
const workspace = createWorkspace({ useTidgiConfigSync: true });
const service = createWorkspaceService(workspace);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).sanitizeWorkspace(workspace, false);
expect(mockReadTidgiConfigSync).not.toHaveBeenCalled();
});
});
});

View file

@ -25,14 +25,6 @@ export const miscSection: IGenericSectionDefinition = {
descriptionKey: 'EditWorkspace.DisableAudio',
},
{ type: 'divider' },
{
type: 'preference-boolean',
key: 'enableFileSystemWatch',
titleKey: 'EditWorkspace.EnableFileSystemWatchTitle',
descriptionKey: 'EditWorkspace.EnableFileSystemWatchDescription',
needsRestart: true,
},
{ type: 'divider' },
{
type: 'custom',
componentId: 'workspace.lastUrl',

View file

@ -19,6 +19,14 @@ export const saveAndSyncSection: IGenericSectionDefinition = {
needsRestart: true,
},
{ type: 'divider' },
{
type: 'preference-boolean',
key: 'enableFileSystemWatch',
titleKey: 'EditWorkspace.EnableFileSystemWatchTitle',
descriptionKey: 'EditWorkspace.EnableFileSystemWatchDescription',
needsRestart: true,
},
{ type: 'divider' },
{
type: 'custom',
componentId: 'workspace.storageServiceSwitch',

View file

@ -19,6 +19,7 @@ import { WindowNames } from '@services/windows/WindowProperties';
import type { IWorkspaceViewService } from '@services/workspacesView/interface';
import type { MenuItemConstructorOptions } from 'electron';
import type { FlatNamespace, TFunction } from 'i18next';
import { nanoid } from 'nanoid';
import type { _DefaultNamespace } from 'react-i18next/TransWithoutContext';
import type { IWorkspace, IWorkspaceService } from './interface';
import { isWikiWorkspace } from './interface';
@ -36,7 +37,10 @@ interface IWorkspaceMenuRequiredServices {
wiki: Pick<IWikiService, 'wikiOperationInBrowser' | 'wikiOperationInServer'>;
wikiGitWorkspace: Pick<IWikiGitWorkspaceService, 'removeWorkspace'>;
window: Pick<IWindowService, 'open' | 'get'>;
workspace: Pick<IWorkspaceService, 'getActiveWorkspace' | 'getSubWorkspacesAsList' | 'openWorkspaceTiddler'>;
workspace: Pick<
IWorkspaceService,
'getActiveWorkspace' | 'getSubWorkspacesAsList' | 'getWorkspacesAsList' | 'openWorkspaceTiddler' | 'getGroupsAsList' | 'setGroup' | 'moveWorkspaceToGroup' | 'removeGroup'
>;
workspaceView: Pick<
IWorkspaceViewService,
| 'wakeUpWorkspaceView'
@ -104,6 +108,49 @@ export async function getSimplifiedWorkspaceMenuTemplate(
},
});
// Workspace group management
const groups = await service.workspace.getGroupsAsList();
if (workspace.groupId) {
// Workspace is in a group - show "Remove from Group"
template.push({
label: t('WorkspaceGroup.RemoveFromGroup'),
click: async () => {
// Pass autoDisband=false so right-click removal never auto-deletes the group.
// Only dragging out the last workspace should truly cancel a group.
await service.workspace.moveWorkspaceToGroup(id, null, false);
},
});
} else {
// Workspace is not in a group - show "Create Group" and "Move to Group" (if groups exist)
template.push({
label: t('WorkspaceGroup.CreateGroup'),
click: async () => {
const newGroupId = nanoid();
const ungroupedWorkspaces = (await service.workspace.getWorkspacesAsList()).filter(workspaceToCheck => !workspaceToCheck.pageType && !workspaceToCheck.groupId);
const maxUngroupedOrder = ungroupedWorkspaces.reduce((maxOrder, workspaceToCheck) => Math.max(maxOrder, workspaceToCheck.order ?? 0), -1);
await service.workspace.setGroup(newGroupId, {
id: newGroupId,
name: t('WorkspaceGroup.DefaultGroupName', { number: groups.length + 1 }),
collapsed: false,
order: Math.max(maxUngroupedOrder + groups.length + 1, groups.length),
});
await service.workspace.moveWorkspaceToGroup(id, newGroupId);
},
});
if (groups.length > 0) {
template.push({
label: t('WorkspaceGroup.MoveToGroup'),
submenu: groups.map((group) => ({
label: group.name,
click: async () => {
await service.workspace.moveWorkspaceToGroup(id, group.id);
},
})),
});
}
}
// View git history (always visible for wiki workspaces)
template.push({
label: t('WorkspaceSelector.ViewGitHistory'),

View file

@ -1,7 +1,7 @@
import useObservable from 'beautiful-react-hooks/useObservable';
import { useMemo, useState } from 'react';
import { map } from 'rxjs/operators';
import type { IWorkspace, IWorkspacesWithMetadata, IWorkspaceWithMetadata } from './interface';
import type { IWorkspace, IWorkspaceGroup, IWorkspacesWithMetadata, IWorkspaceWithMetadata } from './interface';
import { workspaceSorter } from './utilities';
export function useWorkspacesListObservable(): IWorkspaceWithMetadata[] | undefined {
@ -25,3 +25,23 @@ export function useWorkspaceObservable(id: string): IWorkspace | undefined {
useObservable(workspace$, workspaceSetter);
return workspace;
}
export function useWorkspaceGroupsObservable(): Record<string, IWorkspaceGroup> | undefined {
const [groups, groupsSetter] = useState<Record<string, IWorkspaceGroup> | undefined>();
const groups$ = useMemo(() => window.observables.workspace.groups$, []);
useObservable(groups$, groupsSetter);
return groups;
}
export function useWorkspaceGroupsListObservable(): IWorkspaceGroup[] | undefined {
const [groups, groupsSetter] = useState<IWorkspaceGroup[] | undefined>();
const groupsList$ = useMemo(
() =>
window.observables.workspace.groups$.pipe(
map<Record<string, IWorkspaceGroup> | undefined, IWorkspaceGroup[]>((groups) => Object.values(groups ?? {}).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))),
),
[],
);
useObservable(groupsList$, groupsSetter);
return groups;
}

View file

@ -11,6 +11,7 @@ import { map } from 'rxjs/operators';
import { WikiChannel } from '@/constants/channels';
import { defaultCreatedPageTypes, PageType } from '@/constants/pageTypes';
import { getDefaultTidGiUrl } from '@/constants/urls';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IDatabaseService } from '@services/database/interface';
@ -25,6 +26,7 @@ import type {
INewWikiWorkspaceConfig,
IWikiWorkspace,
IWorkspace,
IWorkspaceGroup,
IWorkspaceMetaData,
IWorkspaceService,
IWorkspacesWithMetadata,
@ -50,6 +52,8 @@ export class Workspace implements IWorkspaceService {
await registerMenu();
}
private previousWorkspacesWithMetadata: IWorkspacesWithMetadata | undefined;
public getWorkspacesWithMetadata(): IWorkspacesWithMetadata {
return mapValues(this.getWorkspacesSync(), (workspace: IWorkspace, id): IWorkspaceWithMetadata => {
// Only wiki workspaces can have metadata, dedicated workspaces are filtered out
@ -61,7 +65,16 @@ export class Workspace implements IWorkspaceService {
}
public updateWorkspaceSubject(): void {
this.workspaces$.next(this.getWorkspacesWithMetadata());
const next = this.getWorkspacesWithMetadata();
// Skip emission when nothing actually changed to break infinite render loops
// caused by unstable object references in renderer-side dnd-kit hooks.
if (this.previousWorkspacesWithMetadata !== undefined && isEqual(this.previousWorkspacesWithMetadata, next)) {
return;
}
this.previousWorkspacesWithMetadata = next;
this.workspaces$.next(next);
// Also initialize groups observable
this.getGroupsSync();
}
/**
@ -207,7 +220,6 @@ export class Workspace implements IWorkspaceService {
public async set(id: string, workspace: IWorkspace, immediate?: boolean, skipUiUpdate = false): Promise<void> {
const workspaces = this.getWorkspacesSync();
const workspaceToSave = this.sanitizeWorkspace(workspace);
await this.reactBeforeWorkspaceChanged(workspaceToSave);
// Capture previous in-memory state for precise syncable-field diffing.
const previousWorkspace = workspaces[id];
@ -215,8 +227,10 @@ export class Workspace implements IWorkspaceService {
// Transactional persistence: write to disk first, then update memory/UI only on success.
// This prevents false "saved" feedback when disk writes fail.
// Write tidgi.config.json only when syncable fields actually changed.
if (isWikiWorkspace(workspaceToSave)) {
const shouldSyncToTidgiConfig = isWikiWorkspace(workspaceToSave) && workspaceToSave.useTidgiConfigSync;
// Write tidgi.config.json only when syncable fields actually changed AND workspace uses tidgi.config.json sync.
if (shouldSyncToTidgiConfig) {
const newSyncableConfig = extractSyncableConfig(workspaceToSave);
const previousSyncableConfig = previousWorkspace !== undefined && isWikiWorkspace(previousWorkspace)
? extractSyncableConfig(previousWorkspace)
@ -227,10 +241,11 @@ export class Workspace implements IWorkspaceService {
}
}
// Persist to settings.json first, stripping syncable fields when tidgi.config.json exists.
// Persist to settings.json first, stripping syncable fields when tidgi.config.json exists AND workspace uses it.
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const currentSettingsWorkspaces = databaseService.getSetting('workspaces') ?? {};
currentSettingsWorkspaces[id] = isWikiWorkspace(workspaceToSave) && readTidgiConfigSync(workspaceToSave.wikiFolderLocation) !== undefined
const hasTidgiConfigFile = isWikiWorkspace(workspaceToSave) && readTidgiConfigSync(workspaceToSave.wikiFolderLocation) !== undefined;
currentSettingsWorkspaces[id] = shouldSyncToTidgiConfig && hasTidgiConfigFile
? removeSyncableFields(workspaceToSave) as IWorkspace
: workspaceToSave;
databaseService.setSetting('workspaces', currentSettingsWorkspaces);
@ -304,8 +319,10 @@ export class Workspace implements IWorkspaceService {
// Read syncable config from tidgi.config.json if it exists
// Only apply synced config during initial load, not during updates
// (to avoid overwriting user's changes with old file content)
// Skip tidgi.config.json if workspace is configured to not use it (e.g. secondary workspace pointing to same wiki folder)
let workspaceWithSyncedConfig = workspaceToSanitize;
if (applySyncedConfig) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
if (applySyncedConfig && workspaceToSanitize.useTidgiConfigSync !== false) {
try {
const syncedConfig = readTidgiConfigSync(workspaceToSanitize.wikiFolderLocation);
if (syncedConfig) {
@ -381,34 +398,6 @@ export class Workspace implements IWorkspaceService {
return result;
}
/**
* Do some side effect before config change, update other services or filesystem, with new and old values
* This happened after values sanitized
* @param newWorkspaceConfig new workspace settings
*/
private async reactBeforeWorkspaceChanged(newWorkspaceConfig: IWorkspace): Promise<void> {
if (!isWikiWorkspace(newWorkspaceConfig)) return;
const existedWorkspace = this.getSync(newWorkspaceConfig.id);
const { id, tagNames } = newWorkspaceConfig;
// when update tagNames of subWiki
if (
existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && tagNames.length > 0 &&
JSON.stringify(existedWorkspace.tagNames) !== JSON.stringify(tagNames)
) {
const { mainWikiToLink } = existedWorkspace;
if (typeof mainWikiToLink !== 'string') {
throw new TypeError(
`mainWikiToLink is null in reactBeforeWorkspaceChanged when try to updateSubWikiPluginContent, workspacesID: ${id}\n${
JSON.stringify(
this.workspaces,
)
}`,
);
}
}
}
public async getByWikiFolderLocation(wikiFolderLocation: string): Promise<IWorkspace | undefined> {
return (await this.getWorkspacesAsList()).find((workspace) => isWikiWorkspace(workspace) && workspace.wikiFolderLocation === wikiFolderLocation);
}
@ -553,21 +542,14 @@ export class Workspace implements IWorkspaceService {
/**
* Compute the order for a newly created wiki workspace so it appears at
* the TOP of the regular-workspace section (before page workspaces).
* Shifts all existing non-page workspaces down by 1 to make room.
* the BOTTOM of the regular-workspace section (after existing page workspaces).
*/
private async getNextInsertOrder(): Promise<number> {
const all = await this.getWorkspacesAsList();
const regularWorkspaces = all.filter(w => !w.pageType);
if (regularWorkspaces.length === 0) return 0;
const minOrder = Math.min(...regularWorkspaces.map(w => w.order));
// Shift every existing workspace's order up by 1
for (const ws of all) {
if (ws.order >= minOrder) {
await this.set(ws.id, { ...ws, order: ws.order + 1 });
}
}
return minOrder;
const maxOrder = Math.max(...regularWorkspaces.map(w => w.order));
return maxOrder + 1;
}
public async create(newWorkspaceConfig: INewWikiWorkspaceConfig): Promise<IWorkspace> {
@ -608,11 +590,19 @@ export class Workspace implements IWorkspaceService {
lastNodeJSArgv: [],
order: typeof workspaceConfig.order === 'number' ? workspaceConfig.order : await this.getNextInsertOrder(),
picturePath: null,
useTidgiConfigSync: useTidgiConfig,
};
await this.set(newID, newWorkspace, true);
logger.info(`[test-id-WORKSPACE_CREATED] Workspace created`, { workspaceId: newID, workspaceName: newWorkspace.name, wikiFolderLocation: newWorkspace.wikiFolderLocation });
// Track workspace creation event
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('workspace.created', {
isSubWiki: newWorkspace.isSubWiki ?? false,
hasGitUrl: Boolean(newWorkspace.gitUrl),
});
return newWorkspace;
}
@ -756,4 +746,157 @@ export class Workspace implements IWorkspaceService {
}
return workspaceToken === token;
}
// Workspace group methods
private groups: Record<string, IWorkspaceGroup> | undefined;
public groups$ = new BehaviorSubject<Record<string, IWorkspaceGroup> | undefined>(undefined);
private previousGroups: Record<string, IWorkspaceGroup> | undefined;
private emitGroups(next: Record<string, IWorkspaceGroup> | undefined): void {
// Always emit when the reference is identical so that in-place mutations
// (e.g. groups[id] = group) are not swallowed. Only skip when the
// reference differs but the deep content is the same.
if (next !== undefined && this.previousGroups === next) {
this.groups$.next(next);
return;
}
if (this.previousGroups !== undefined && next !== undefined && isEqual(this.previousGroups, next)) {
return;
}
this.previousGroups = next;
this.groups$.next(next);
}
private normalizeLegacyGroupOrders(groups: Record<string, IWorkspaceGroup>): Record<string, IWorkspaceGroup> {
const groupList = Object.values(groups);
if (groupList.length === 0) {
return groups;
}
const sortedGroupOrders = groupList
.map(group => group.order ?? 0)
.sort((left, right) => left - right);
const isLegacyDenseOrder = sortedGroupOrders.every((order, index) => order === index);
if (!isLegacyDenseOrder) {
return groups;
}
const ungroupedWorkspaces = Object.values(this.getWorkspacesSync()).filter(workspace => !workspace.groupId);
if (ungroupedWorkspaces.length === 0) {
return groups;
}
const maxUngroupedOrder = Math.max(...ungroupedWorkspaces.map(workspace => workspace.order ?? 0));
let hasChanges = false;
const normalizedGroups = { ...groups };
[...groupList]
.sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
.forEach((group, index) => {
const nextOrder = maxUngroupedOrder + index + 1;
if (group.order !== nextOrder) {
normalizedGroups[group.id] = { ...group, order: nextOrder };
hasChanges = true;
}
});
if (!hasChanges) {
return groups;
}
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
databaseService.setSetting('workspaceGroups', normalizedGroups);
return normalizedGroups;
}
private getGroupsSync(): Record<string, IWorkspaceGroup> {
if (this.groups === undefined) {
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
const groupsFromDisk = databaseService.getSetting('workspaceGroups') ?? {};
if (typeof groupsFromDisk === 'object' && !Array.isArray(groupsFromDisk)) {
this.groups = this.normalizeLegacyGroupOrders(groupsFromDisk);
} else {
this.groups = {};
}
// Initialize the observable with current groups
this.emitGroups(this.groups);
}
return this.groups;
}
public async getGroups(): Promise<Record<string, IWorkspaceGroup>> {
return this.getGroupsSync();
}
public async getGroupsAsList(): Promise<IWorkspaceGroup[]> {
const groups = this.getGroupsSync();
return Object.values(groups).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
public async getGroup(id: string): Promise<IWorkspaceGroup | undefined> {
const groups = this.getGroupsSync();
return groups[id];
}
public async setGroup(id: string, group: IWorkspaceGroup): Promise<void> {
const groups = this.getGroupsSync();
groups[id] = group;
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
databaseService.setSetting('workspaceGroups', groups);
this.groups = groups;
this.emitGroups(groups);
}
public async removeGroup(id: string): Promise<void> {
const groups = this.getGroupsSync();
delete groups[id];
const databaseService = container.get<IDatabaseService>(serviceIdentifier.Database);
databaseService.setSetting('workspaceGroups', groups);
this.groups = groups;
this.emitGroups(groups);
// Move workspaces in this group to ungrouped
const workspaces = this.getWorkspacesSync();
const workspacesToUpdate: Record<string, IWorkspace> = {};
for (const [workspaceId, workspace] of Object.entries(workspaces)) {
if (workspace.groupId === id) {
workspacesToUpdate[workspaceId] = { ...workspace, groupId: null };
}
}
if (Object.keys(workspacesToUpdate).length > 0) {
await this.setWorkspaces(workspacesToUpdate);
}
}
public async moveWorkspaceToGroup(workspaceId: string, groupId: string | null, autoDisband = true): Promise<void> {
const workspace = await this.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace ${workspaceId} not found`);
}
const oldGroupId = workspace.groupId;
await this.update(workspaceId, { groupId });
// Auto-disband old group only when explicitly allowed (e.g. drag operations).
// Right-click or settings removal should not trigger auto-disband,
// matching the requirement that only dragging out the last workspace truly cancels a group.
if (autoDisband && oldGroupId) {
await this.disbandGroupIfEmpty(oldGroupId);
}
}
/**
* Disband group if it has zero workspaces left.
* Groups are only removed when they become completely empty,
* not when dropping from 21 workspaces.
*/
private async disbandGroupIfEmpty(groupId: string): Promise<void> {
const workspaces = this.getWorkspacesSync();
const workspacesInGroup = Object.values(workspaces).filter(w => w.groupId === groupId);
if (workspacesInGroup.length === 0) {
await this.removeGroup(groupId);
}
}
}

View file

@ -32,6 +32,7 @@ export const localOnlyFields = [
'wikiFolderLocation',
'pageType',
'port',
'useTidgiConfigSync',
] as const;
/**
@ -79,6 +80,7 @@ export const localConfigDefaultValues = {
picturePath: null as string | null,
pageType: null as PageType.wiki | null,
port: 5212,
useTidgiConfigSync: true,
} as const;
/**
@ -115,6 +117,10 @@ export interface IDedicatedWorkspace {
* workspace icon's path in file system
*/
picturePath: string | null;
/**
* Optional group ID this workspace belongs to. Null/undefined means ungrouped.
*/
groupId?: string | null;
}
/**
@ -187,6 +193,12 @@ export interface IWikiWorkspace extends IDedicatedWorkspace {
* Localhost tiddlywiki server port
*/
port: number;
/**
* Whether to sync workspace configuration to tidgi.config.json in the wiki folder.
* When false, all config is stored locally in settings.json and tidgi.config.json is not read or written.
* This allows multiple workspaces to point to the same wiki folder without config conflicts.
*/
useTidgiConfigSync: boolean;
/**
* Make wiki readonly if readonly is true. This is normally used for server mode, so also enable gzip.
*
@ -279,6 +291,22 @@ export function isDedicatedWorkspace(workspace: IWorkspace): workspace is IDedic
return !isWikiWorkspace(workspace);
}
/**
* Workspace group for organizing multiple workspaces
*/
export interface IWorkspaceGroup {
id: string;
name: string;
/**
* Display order of this group in the sidebar
*/
order: number;
/**
* Whether this group is collapsed in the sidebar
*/
collapsed: boolean;
}
export interface IWorkspaceMetaData {
badgeCount?: number;
/**
@ -305,7 +333,7 @@ export type IWorkspacesWithMetadata = Record<string, IWorkspaceWithMetadata>;
*/
export type INewWikiWorkspaceConfig =
& SetOptional<
Omit<IWikiWorkspace, 'active' | 'hibernated' | 'id' | 'lastUrl' | 'syncOnInterval' | 'syncOnStartup'>,
Omit<IWikiWorkspace, 'active' | 'hibernated' | 'id' | 'lastUrl' | 'syncOnInterval' | 'syncOnStartup' | 'useTidgiConfigSync'>,
| 'homeUrl'
| 'transparentBackground'
| 'picturePath'
@ -417,6 +445,15 @@ export interface IWorkspaceService {
updateWorkspaceSubject(): void;
workspaceDidFailLoad(id: string): Promise<boolean>;
workspaces$: BehaviorSubject<IWorkspacesWithMetadata | undefined>;
// Workspace group methods
getGroups(): Promise<Record<string, IWorkspaceGroup>>;
getGroupsAsList(): Promise<IWorkspaceGroup[]>;
getGroup(id: string): Promise<IWorkspaceGroup | undefined>;
setGroup(id: string, group: IWorkspaceGroup): Promise<void>;
removeGroup(id: string): Promise<void>;
moveWorkspaceToGroup(workspaceId: string, groupId: string | null, autoDisband?: boolean): Promise<void>;
groups$: BehaviorSubject<Record<string, IWorkspaceGroup> | undefined>;
}
export const WorkspaceServiceIPCDescriptor = {
channel: WorkspaceChannel.name,
@ -454,6 +491,13 @@ export const WorkspaceServiceIPCDescriptor = {
updateWorkspaceSubject: ProxyPropertyType.Value$,
workspaceDidFailLoad: ProxyPropertyType.Function,
workspaces$: ProxyPropertyType.Value$,
getGroups: ProxyPropertyType.Function,
getGroupsAsList: ProxyPropertyType.Function,
getGroup: ProxyPropertyType.Function,
setGroup: ProxyPropertyType.Function,
removeGroup: ProxyPropertyType.Function,
moveWorkspaceToGroup: ProxyPropertyType.Function,
groups$: ProxyPropertyType.Value$,
},
};

View file

@ -4,6 +4,7 @@ import { inject, injectable } from 'inversify';
import { WikiChannel } from '@/constants/channels';
import { WikiCreationMethod } from '@/constants/wikiCreation';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import { container } from '@services/container';
import type { IContextService } from '@services/context/interface';
@ -252,6 +253,10 @@ export class WorkspaceView implements IWorkspaceViewService {
windowName: WindowNames.secondary,
uri: uriToOpen,
});
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('workspace.opened_in_new_window', {
isSubWiki: isWikiWorkspace(workspace) ? (workspace.isSubWiki ?? false) : false,
});
}
public async updateLastUrl(
@ -375,6 +380,12 @@ export class WorkspaceView implements IWorkspaceViewService {
// later process will use the current active workspace
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id);
// Track workspace activation event
const analyticsService = container.get<IAnalyticsService>(serviceIdentifier.Analytics);
void analyticsService.track('workspace.activated', {
isSubWiki: isWikiWorkspace(newWorkspace) ? (newWorkspace.isSubWiki ?? false) : false,
});
// 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.
@ -540,17 +551,24 @@ export class WorkspaceView implements IWorkspaceViewService {
isLoading: false,
isRestarting: true,
});
await container.get<IWikiService>(serviceIdentifier.Wiki).stopWiki(workspaceToRestart.id);
await this.initializeWorkspaceView(workspaceToRestart, { syncImmediately: false });
if (await container.get<IWorkspaceService>(serviceIdentifier.Workspace).workspaceDidFailLoad(workspaceToRestart.id)) {
logger.warn('skip because workspaceDidFailLoad', { function: 'restartWorkspaceViewService' });
return;
try {
await container.get<IWikiService>(serviceIdentifier.Wiki).stopWiki(workspaceToRestart.id);
await this.initializeWorkspaceView(workspaceToRestart, { syncImmediately: false });
if (await container.get<IWorkspaceService>(serviceIdentifier.Workspace).workspaceDidFailLoad(workspaceToRestart.id)) {
logger.warn('skip because workspaceDidFailLoad', { function: 'restartWorkspaceViewService' });
return;
}
await container.get<IViewService>(serviceIdentifier.View).reloadViewsWebContents(workspaceToRestart.id);
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.generalNotification, workspaceToRestart.id, [
i18n.t('ContextMenu.RestartServiceComplete'),
]);
} catch (error) {
logger.error('restartWorkspaceViewService failed', { function: 'restartWorkspaceViewService', error, workspaceId: workspaceToRestart.id });
throw error;
} finally {
// Ensure isRestarting is always reset even if restart fails
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).updateMetaData(workspaceToRestart.id, { isRestarting: false });
}
await container.get<IViewService>(serviceIdentifier.View).reloadViewsWebContents(workspaceToRestart.id);
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.generalNotification, workspaceToRestart.id, [
i18n.t('ContextMenu.RestartServiceComplete'),
]);
await container.get<IWorkspaceService>(serviceIdentifier.Workspace).updateMetaData(workspaceToRestart.id, { isRestarting: false });
}
public async restartAllWorkspaceView(): Promise<void> {

View file

@ -1,6 +1,7 @@
import type { IAgentBrowserService } from '@services/agentBrowser/interface';
import type { IAgentDefinitionService } from '@services/agentDefinition/interface';
import type { IAgentInstanceService } from '@services/agentInstance/interface';
import type { IAnalyticsService } from '@services/analytics/interface';
import type { IAuthenticationService } from '@services/auth/interface';
import type { IContextService } from '@services/context/interface';
import type { IDatabaseService } from '@services/database/interface';
@ -28,6 +29,7 @@ export type TidgiService = {
agentBrowser: IAgentBrowserService;
agentDefinition: IAgentDefinitionService;
agentInstance: IAgentInstanceService;
analytics: IAnalyticsService;
auth: IAuthenticationService;
context: IContextService;
database: IDatabaseService;

View file

@ -1,13 +1,17 @@
import { useTranslation } from 'react-i18next';
import { Alert, LinearProgress, Snackbar, Typography } from '@mui/material';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { CloseButton, ReportErrorFabButton, WikiLocation } from './FormComponents';
import { useCloneWiki, useValidateCloneWiki } from './useCloneWiki';
import type { IWikiWorkspaceFormProps } from './useForm';
import { useWikiCreationProgress } from './useIndicator';
export function CloneWikiDoneButton(
{ form, isCreateMainWorkspace, errorInWhichComponentSetter, useTidgiConfig }: IWikiWorkspaceFormProps & { useTidgiConfig: boolean },
{ form, isCreateMainWorkspace, errorInWhichComponentSetter, useTidgiConfig, selectedImportConfig }: IWikiWorkspaceFormProps & {
useTidgiConfig: boolean;
selectedImportConfig?: Partial<ISyncableWikiConfig>;
},
): React.JSX.Element {
const { t } = useTranslation();
const [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter] = useValidateCloneWiki(
@ -15,7 +19,7 @@ export function CloneWikiDoneButton(
form,
errorInWhichComponentSetter,
);
const onSubmit = useCloneWiki(isCreateMainWorkspace, form, useTidgiConfig, wikiCreationMessageSetter, hasErrorSetter, errorInWhichComponentSetter);
const onSubmit = useCloneWiki(isCreateMainWorkspace, form, useTidgiConfig, wikiCreationMessageSetter, hasErrorSetter, errorInWhichComponentSetter, selectedImportConfig);
const [logPanelOpened, logPanelSetter, inProgressOrError] = useWikiCreationProgress(wikiCreationMessageSetter, wikiCreationMessage, hasError);
if (hasError) {
return (

View file

@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Alert, LinearProgress, Snackbar, Typography } from '@mui/material';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { useTranslation } from 'react-i18next';
import { CloseButton, ReportErrorFabButton, WikiLocation } from './FormComponents';
import { useExistedWiki, useValidateExistedWiki } from './useExistedWiki';
import type { IWikiWorkspaceFormProps } from './useForm';
@ -12,8 +11,14 @@ export function ExistedWikiDoneButton({
isCreateMainWorkspace,
isCreateSyncedWorkspace,
useTidgiConfig,
selectedImportConfig,
errorInWhichComponentSetter,
}: IWikiWorkspaceFormProps & { isCreateMainWorkspace: boolean; isCreateSyncedWorkspace: boolean; useTidgiConfig: boolean }): React.JSX.Element {
}: IWikiWorkspaceFormProps & {
isCreateMainWorkspace: boolean;
isCreateSyncedWorkspace: boolean;
useTidgiConfig: boolean;
selectedImportConfig?: Partial<ISyncableWikiConfig>;
}): React.JSX.Element {
const { t } = useTranslation();
const [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter] = useValidateExistedWiki(
isCreateMainWorkspace,
@ -21,7 +26,16 @@ export function ExistedWikiDoneButton({
form,
errorInWhichComponentSetter,
);
const onSubmit = useExistedWiki(isCreateMainWorkspace, isCreateSyncedWorkspace, form, useTidgiConfig, wikiCreationMessageSetter, hasErrorSetter, errorInWhichComponentSetter);
const onSubmit = useExistedWiki(
isCreateMainWorkspace,
isCreateSyncedWorkspace,
form,
useTidgiConfig,
wikiCreationMessageSetter,
hasErrorSetter,
errorInWhichComponentSetter,
selectedImportConfig,
);
const [logPanelOpened, logPanelSetter, inProgressOrError] = useWikiCreationProgress(wikiCreationMessageSetter, wikiCreationMessage, hasError);
if (hasError) {
return (

View file

@ -0,0 +1,120 @@
import { Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, List, ListItem, Typography } from '@mui/material';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface IImportConfigDialogProps {
open: boolean;
wikiFolderLocation: string | undefined;
onClose: () => void;
onConfirm: (selectedConfig: Partial<ISyncableWikiConfig>) => void;
}
function formatConfigValue(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'string') return value;
if (Array.isArray(value)) return `[${value.join(', ')}]`;
if (typeof value === 'object') return JSON.stringify(value);
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(value);
}
export function ImportConfigDialog({ open, wikiFolderLocation, onClose, onConfirm }: IImportConfigDialogProps): React.JSX.Element {
const { t } = useTranslation();
const [config, configSetter] = useState<Partial<ISyncableWikiConfig> | undefined>(undefined);
const [loading, loadingSetter] = useState(false);
const [error, errorSetter] = useState<string | undefined>(undefined);
const [selectedKeys, selectedKeysSetter] = useState<Set<string>>(new Set());
useEffect(() => {
if (!open || !wikiFolderLocation) return;
loadingSetter(true);
errorSetter(undefined);
selectedKeysSetter(new Set());
void (async () => {
try {
const wikiConfig = await window.service.database.readWikiConfig(wikiFolderLocation);
configSetter(wikiConfig);
} catch (error_) {
errorSetter((error_ as Error).message);
} finally {
loadingSetter(false);
}
})();
}, [open, wikiFolderLocation]);
const handleToggle = (key: string) => {
selectedKeysSetter((previous) => {
const next = new Set(previous);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const handleConfirm = () => {
if (!config) return;
const selectedConfig: Partial<ISyncableWikiConfig> = {};
for (const key of selectedKeys) {
if (key in config) {
(selectedConfig as Record<string, unknown>)[key] = config[key as keyof ISyncableWikiConfig];
}
}
onConfirm(selectedConfig);
};
const configEntries = config ? Object.entries(config).filter(([key]) => key !== 'id' && key !== '$schema' && key !== 'version') : [];
return (
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
<DialogTitle>{t('AddWorkspace.SelectConfigToImport')}</DialogTitle>
<DialogContent dividers>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
<CircularProgress />
</div>
)}
{error && <Typography color='error'>{error}</Typography>}
{!loading && !error && configEntries.length === 0 && <Typography>{t('AddWorkspace.NoTidgiConfigFound')}</Typography>}
{!loading && !error && configEntries.length > 0 && (
<List dense>
{configEntries.map(([key, value]) => (
<ListItem key={key} disablePadding>
<FormControlLabel
control={
<Checkbox
checked={selectedKeys.has(key)}
onChange={() => {
handleToggle(key);
}}
/>
}
label={
<Typography variant='body2' component='span'>
<strong>{key}</strong>: {formatConfigValue(value)}
</Typography>
}
/>
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button
onClick={handleConfirm}
variant='contained'
disabled={loading || selectedKeys.size === 0}
>
{t('Confirm')}
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -6,6 +6,7 @@ import {
AccordionSummary,
AppBar,
Box,
Button,
Checkbox,
FormControlLabel,
Paper as PaperRaw,
@ -32,8 +33,10 @@ import { IErrorInWhichComponent, useWikiWorkspaceForm } from './useForm';
import { TokenForm } from '@/components/TokenForm';
import { usePromiseValue } from '@/helpers/useServiceValue';
import { IPossibleWindowMeta, WindowMeta, WindowNames } from '@services/windows/WindowProperties';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { CreateWorkspaceTabs } from './constants';
import { GitRepoUrlForm } from './GitRepoUrlForm';
import { ImportConfigDialog } from './ImportConfigDialog';
import { ImportHtmlWikiDoneButton } from './ImportHtmlWikiDoneButton';
import { ImportHtmlWikiForm } from './ImportHtmlWikiForm';
@ -89,10 +92,27 @@ export default function AddWorkspace(): React.JSX.Element {
const isCreateSyncedWorkspace = currentTab === CreateWorkspaceTabs.CloneOnlineWiki;
const [isCreateMainWorkspace, isCreateMainWorkspaceSetter] = useState(true);
const [useTidgiConfig, useTidgiConfigSetter] = useState(true);
const [selectedImportConfig, selectedImportConfigSetter] = useState<Partial<ISyncableWikiConfig> | undefined>(undefined);
const [importConfigDialogOpen, importConfigDialogOpenSetter] = useState(false);
const form = useWikiWorkspaceForm();
const [errorInWhichComponent, errorInWhichComponentSetter] = useState<IErrorInWhichComponent>({});
const workspaceList = usePromiseValue(async () => await window.service.workspace.getWorkspacesAsList());
// Keep imported config scoped to the current import flow so it cannot bleed into another tab.
useEffect(() => {
selectedImportConfigSetter(undefined);
}, [currentTab]);
// When user explicitly disables tidgi.config sync, discard any config that was
// eagerly loaded while the checkbox was still checked. Otherwise the imported
// workspace accidentally inherits synced fields (e.g. name, readOnlyMode) even
// though it should be local-only.
useEffect(() => {
if (!useTidgiConfig) {
selectedImportConfigSetter(undefined);
}
}, [useTidgiConfig]);
// update storageProviderSetter to local based on isCreateSyncedWorkspace. Other services value will be changed by TokenForm
const { storageProvider, storageProviderSetter, wikiFolderName } = form;
useEffect(() => {
@ -162,6 +182,7 @@ export default function AddWorkspace(): React.JSX.Element {
control={
<Checkbox
checked={useTidgiConfig}
data-testid='use-tidgi-config-checkbox'
onChange={(event) => {
useTidgiConfigSetter(event.target.checked);
}}
@ -170,9 +191,36 @@ export default function AddWorkspace(): React.JSX.Element {
label={t('AddWorkspace.UseTidgiConfigWhenImport')}
/>
<Typography variant='body2'>{t('AddWorkspace.UseTidgiConfigWhenImportDescription')}</Typography>
{!useTidgiConfig && (
<Button
variant='outlined'
size='small'
onClick={() => {
importConfigDialogOpenSetter(true);
}}
disabled={!form.wikiFolderLocation}
sx={{ mt: 1 }}
>
{selectedImportConfig && Object.keys(selectedImportConfig).length > 0
? t('AddWorkspace.ImportConfigSelected', { count: Object.keys(selectedImportConfig).length })
: t('AddWorkspace.SelectConfigToImport')}
</Button>
)}
</TidgiConfigImportOptions>
)}
<ImportConfigDialog
open={importConfigDialogOpen}
wikiFolderLocation={form.wikiFolderLocation}
onClose={() => {
importConfigDialogOpenSetter(false);
}}
onConfirm={(config) => {
selectedImportConfigSetter(config);
importConfigDialogOpenSetter(false);
}}
/>
{currentTab === CreateWorkspaceTabs.CreateNewWiki && (
<TabPanel>
<Container>
@ -185,7 +233,7 @@ export default function AddWorkspace(): React.JSX.Element {
<TabPanel>
<Container>
<CloneWikiForm {...formProps} />
<CloneWikiDoneButton {...formProps} useTidgiConfig={useTidgiConfig} />
<CloneWikiDoneButton {...formProps} useTidgiConfig={useTidgiConfig} selectedImportConfig={selectedImportConfig} />
</Container>
</TabPanel>
)}
@ -198,7 +246,7 @@ export default function AddWorkspace(): React.JSX.Element {
useTidgiConfig={useTidgiConfig}
isCreateMainWorkspaceSetter={isCreateMainWorkspaceSetter}
/>
<ExistedWikiDoneButton {...formProps} isCreateSyncedWorkspace={isCreateSyncedWorkspace} useTidgiConfig={useTidgiConfig} />
<ExistedWikiDoneButton {...formProps} isCreateSyncedWorkspace={isCreateSyncedWorkspace} useTidgiConfig={useTidgiConfig} selectedImportConfig={selectedImportConfig} />
</Container>
</TabPanel>
)}

View file

@ -1,4 +1,5 @@
import { WikiCreationMethod } from '@/constants/wikiCreation';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { callWikiInitialization } from './useCallWikiInitialization';
@ -61,13 +62,14 @@ export function useCloneWiki(
wikiCreationMessageSetter: (m: string) => void,
hasErrorSetter: (m: boolean) => void,
errorInWhichComponentSetter: (errors: IErrorInWhichComponent) => void,
selectedImportConfig?: Partial<ISyncableWikiConfig>,
): () => Promise<void> {
const { t } = useTranslation();
const onSubmit = useCallback(async () => {
wikiCreationMessageSetter(t('AddWorkspace.Processing'));
try {
const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, true, { useTidgiConfig });
const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, true, { useTidgiConfig, selectedImportConfig });
if (isCreateMainWorkspace) {
await window.service.wiki.cloneWiki(form.parentFolderLocation, form.wikiFolderName, form.gitRepoUrl, form.gitUserInfo!);
} else {
@ -84,7 +86,7 @@ export function useCloneWiki(
updateErrorInWhichComponentSetterByErrorMessage(t, (error as Error).message, errorInWhichComponentSetter);
hasErrorSetter(true);
}
}, [wikiCreationMessageSetter, t, form, isCreateMainWorkspace, useTidgiConfig, errorInWhichComponentSetter, hasErrorSetter]);
}, [wikiCreationMessageSetter, t, form, isCreateMainWorkspace, useTidgiConfig, selectedImportConfig, errorInWhichComponentSetter, hasErrorSetter]);
return onSubmit;
}

View file

@ -1,4 +1,5 @@
import { WikiCreationMethod } from '@/constants/wikiCreation';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { callWikiInitialization } from './useCallWikiInitialization';
@ -59,12 +60,13 @@ export function useExistedWiki(
wikiCreationMessageSetter: (m: string) => void,
hasErrorSetter: (m: boolean) => void,
errorInWhichComponentSetter: (errors: IErrorInWhichComponent) => void,
selectedImportConfig?: Partial<ISyncableWikiConfig>,
): () => Promise<void> {
const { t } = useTranslation();
const onSubmit = useCallback(async () => {
wikiCreationMessageSetter(t('AddWorkspace.Processing'));
const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, isCreateSyncedWorkspace, { useTidgiConfig });
const newWorkspaceConfig = workspaceConfigFromForm(form, isCreateMainWorkspace, isCreateSyncedWorkspace, { useTidgiConfig, selectedImportConfig });
if (!form.wikiFolderLocation) {
throw new Error(t('AddWorkspace.MainWorkspaceLocation') + t('AddWorkspace.NotFilled'));
}
@ -90,7 +92,7 @@ export function useExistedWiki(
updateErrorInWhichComponentSetterByErrorMessage(t, (error as Error).message, errorInWhichComponentSetter);
hasErrorSetter(true);
}
}, [wikiCreationMessageSetter, t, form, isCreateMainWorkspace, isCreateSyncedWorkspace, useTidgiConfig, errorInWhichComponentSetter, hasErrorSetter]);
}, [wikiCreationMessageSetter, t, form, isCreateMainWorkspace, isCreateSyncedWorkspace, useTidgiConfig, selectedImportConfig, errorInWhichComponentSetter, hasErrorSetter]);
return onSubmit;
}

View file

@ -6,6 +6,7 @@ import { useStorageServiceUserInfoObservable } from '@services/auth/hooks';
import { SupportedStorageServices } from '@services/types';
import type { INewWikiWorkspaceConfig, IWikiWorkspace } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
import type { ISyncableWikiConfig } from '@services/workspaces/syncableConfig';
import type { INewWikiRequiredFormData } from './useNewWiki';
type IMainWikiInfo = Pick<IWikiWorkspace, 'wikiFolderLocation' | 'port' | 'id'>;
@ -155,7 +156,7 @@ export function workspaceConfigFromForm(
form: INewWikiRequiredFormData,
isCreateMainWorkspace: boolean,
isCreateSyncedWorkspace: boolean,
options?: { useTidgiConfig?: boolean },
options?: { useTidgiConfig?: boolean; selectedImportConfig?: Partial<ISyncableWikiConfig> },
): INewWikiWorkspaceConfig {
return {
gitUrl: isCreateSyncedWorkspace ? form.gitRepoUrl : null,
@ -171,6 +172,7 @@ export function workspaceConfigFromForm(
tokenAuth: false,
enableFileSystemWatch: false,
useTidgiConfig: options?.useTidgiConfig,
...(options?.selectedImportConfig as Partial<INewWikiWorkspaceConfig> | undefined),
// Additional fields will be set with default values in `sanitizeWorkspace`, see also `INewWikiWorkspaceConfig`
};
}

View file

@ -19,6 +19,7 @@ import type {
ISectionDefinition,
IStringArrayPreferenceItem,
IStringPreferenceItem,
ITextPreferenceItem,
PlatformCondition,
PreferenceItemDefinition,
} from '@services/preferences/definitions/types';
@ -208,7 +209,7 @@ function StringItem({
onNeedsRestart,
query = '',
}: {
item: IStringPreferenceItem;
item: IStringPreferenceItem | ITextPreferenceItem;
onNeedsRestart: () => void;
preference: IPreferences;
query?: string;
@ -342,14 +343,18 @@ function ItemRenderer({
return <NumberItem item={item} preference={preference} onNeedsRestart={onNeedsRestart} query={query} />;
case 'preference-string':
return <StringItem item={item} preference={preference} onNeedsRestart={onNeedsRestart} query={query} />;
case 'preference-text':
return <StringItem item={item} preference={preference} onNeedsRestart={onNeedsRestart} query={query} />;
case 'preference-string-array':
return <StringArrayItem item={item} preference={preference} onNeedsRestart={onNeedsRestart} query={query} />;
case 'action':
return <ActionItem item={item} query={query} />;
case 'custom':
// In search mode: show a read-only info card so the user knows where to find it.
// In normal mode: render the registered custom component.
if (query) {
const Component = getCustomComponent(item.componentId);
if (Component) {
return <Component onNeedsRestart={onNeedsRestart} />;
}
const primaryText = i18next.t(item.titleKey, item.ns ? { ns: item.ns } : undefined);
const secondaryText = item.descriptionKey ? i18next.t(item.descriptionKey, item.ns ? { ns: item.ns } : undefined) : undefined;
return (
@ -362,6 +367,8 @@ function ItemRenderer({
);
}
return <CustomItemWrapper item={item} onNeedsRestart={onNeedsRestart} />;
default:
return null;
}
}

View file

@ -30,6 +30,7 @@ export function SearchBar({ value, onChange, inputRef }: SearchBarProps): React.
onChange={(event) => {
onChange(event.target.value);
}}
data-testid='preferences-search-input'
placeholder={t('Preference.SearchPlaceholder')}
variant='outlined'
size='small'

View file

@ -15,8 +15,10 @@ interface SectionSideBarProps {
const SideBar = styled('div')`
position: fixed;
width: 200px;
height: 100vh;
background-color: ${({ theme }) => theme.palette.background.default};
color: ${({ theme }) => theme.palette.text.primary};
overflow-y: auto;
`;
const ListItemIcon = styled(ListItemIconRaw)`
color: ${({ theme }) => theme.palette.text.primary};

View file

@ -1,5 +1,6 @@
import { Switch } from '@mui/material';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import { renderTimeViewClock } from '@mui/x-date-pickers/timeViewRenderers';
import { useTranslation } from 'react-i18next';
import { ListItemText } from '@/components/ListItem';
@ -32,6 +33,10 @@ export function NotificationScheduleItem(): React.JSX.Element | null {
await window.service.window.updateWindowMeta(WindowNames.preferences, { preventClosingWindow: true });
}}
disabled={!preference.pauseNotificationsBySchedule}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock,
}}
/>
<TimePicker
label='to'
@ -48,6 +53,10 @@ export function NotificationScheduleItem(): React.JSX.Element | null {
await window.service.window.updateWindowMeta(WindowNames.preferences, { preventClosingWindow: true });
}}
disabled={!preference.pauseNotificationsBySchedule}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock,
}}
/>
</TimePickerContainer>
({window.Intl.DateTimeFormat().resolvedOptions().timeZone})

View file

@ -0,0 +1,265 @@
import AddIcon from '@mui/icons-material/Add';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import FolderIcon from '@mui/icons-material/Folder';
import { Autocomplete, Box, Button, Chip, Divider, IconButton, TextField, Typography } from '@mui/material';
import type { ICustomItemProps } from '@services/preferences/definitions/types';
import { useWorkspaceGroupsListObservable, useWorkspacesListObservable } from '@services/workspaces/hooks';
import type { IWorkspace, IWorkspaceGroup } from '@services/workspaces/interface';
import { isWikiWorkspace } from '@services/workspaces/interface';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListItem, ListItemText } from '@/components/ListItem';
export function WorkspaceGroupsItem(_props: ICustomItemProps): React.JSX.Element {
const { t } = useTranslation();
const groups = useWorkspaceGroupsListObservable() ?? [];
const workspaces = useWorkspacesListObservable() ?? [];
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [newGroupName, setNewGroupName] = useState('');
useEffect(() => {
if (editingGroupId !== null && !groups.some(group => group.id === editingGroupId)) {
setEditingGroupId(null);
setEditingName('');
}
}, [groups, editingGroupId]);
const wikiWorkspaces = workspaces.filter(isWikiWorkspace);
const createGroup = useCallback(async () => {
const trimmedName = newGroupName.trim();
if (!trimmedName) return;
const ungroupedWikiWorkspaces = wikiWorkspaces.filter(workspace => !workspace.groupId);
const maxUngroupedOrder = ungroupedWikiWorkspaces.reduce((maxOrder, workspace) => Math.max(maxOrder, workspace.order ?? 0), -1);
const nextGroupOrder = Math.max(maxUngroupedOrder + groups.length + 1, groups.length);
const newGroup: IWorkspaceGroup = {
id: nanoid(),
name: trimmedName,
order: nextGroupOrder,
collapsed: false,
};
await window.service.workspace.setGroup(newGroup.id, newGroup);
setNewGroupName('');
}, [groups.length, newGroupName, wikiWorkspaces]);
const saveGroupName = useCallback(async (group: IWorkspaceGroup) => {
const trimmedName = editingName.trim();
if (!trimmedName) return;
await window.service.workspace.setGroup(group.id, { ...group, name: trimmedName });
setEditingGroupId(null);
setEditingName('');
}, [editingName]);
const deleteGroup = useCallback(async (group: IWorkspaceGroup) => {
const confirmed = await window.service.native.showElectronMessageBox({
type: 'question',
buttons: [t('Confirm'), t('Cancel')],
message: t('WorkspaceGroup.DeleteGroupConfirm', { groupName: group.name }),
cancelId: 1,
});
if (confirmed?.response === 0) {
await window.service.workspace.removeGroup(group.id);
}
}, [t]);
const syncGroupMembership = useCallback(async (groupId: string, selectedWorkspaces: IWorkspace[]) => {
const currentGroupMembers = wikiWorkspaces.filter(workspace => workspace.groupId === groupId);
const currentIds = new Set(currentGroupMembers.map(workspace => workspace.id));
const selectedIds = new Set(selectedWorkspaces.map(workspace => workspace.id));
await Promise.all(
currentGroupMembers
.filter(workspace => !selectedIds.has(workspace.id))
.map(workspace => window.service.workspace.moveWorkspaceToGroup(workspace.id, null, false)),
);
await Promise.all(
selectedWorkspaces
.filter(workspace => !currentIds.has(workspace.id))
.map(workspace => window.service.workspace.moveWorkspaceToGroup(workspace.id, groupId)),
);
}, [wikiWorkspaces]);
return (
<>
<ListItem>
<ListItemText primary={t('WorkspaceGroup.ManageGroups')} secondary={t('WorkspaceGroup.ManageGroupsDescription')} />
</ListItem>
<ListItem sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 1.5 }}>
<TextField
fullWidth
size='small'
value={newGroupName}
label={t('WorkspaceGroup.CreateGroup')}
placeholder={t('WorkspaceGroup.GroupName')}
onChange={(event) => {
setNewGroupName(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void createGroup();
}
}}
/>
<Box sx={{ alignSelf: 'flex-end' }}>
<Button
size='small'
startIcon={<AddIcon />}
onClick={() => {
void createGroup();
}}
data-testid='create-group-button'
>
{t('WorkspaceGroup.CreateGroup')}
</Button>
</Box>
</ListItem>
<Divider />
{groups.length === 0
? (
<ListItem>
<ListItemText secondary={t('WorkspaceGroup.ManageGroupsDescription')} />
</ListItem>
)
: groups.map((group, index) => {
const workspacesInGroup = wikiWorkspaces.filter(workspace => workspace.groupId === group.id);
const availableWorkspaces = wikiWorkspaces.filter(workspace => workspace.groupId !== group.id);
const isEditing = editingGroupId === group.id;
return (
<Box key={group.id} data-testid={`group-management-item-${group.id}`}>
{index > 0 && <Divider />}
<ListItem sx={{ alignItems: 'flex-start', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', gap: 1 }}>
<FolderIcon fontSize='small' color='action' />
{isEditing
? (
<TextField
autoFocus
fullWidth
size='small'
label={t('WorkspaceGroup.GroupName')}
value={editingName}
onChange={(event) => {
setEditingName(event.target.value);
}}
onBlur={() => {
void saveGroupName(group);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
void saveGroupName(group);
} else if (event.key === 'Escape') {
setEditingGroupId(null);
setEditingName('');
}
}}
/>
)
: (
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant='body2' sx={{ fontWeight: 500 }}>
{group.name}
</Typography>
<Typography variant='caption' color='text.secondary'>
{t('WorkspaceGroup.WorkspaceCount', { count: workspacesInGroup.length })}
</Typography>
</Box>
)}
{isEditing
? (
<>
<IconButton
size='small'
onClick={() => {
void saveGroupName(group);
}}
data-testid={`save-group-${group.id}`}
>
<CheckIcon fontSize='small' />
</IconButton>
<IconButton
size='small'
onClick={() => {
setEditingGroupId(null);
setEditingName('');
}}
data-testid={`cancel-edit-group-${group.id}`}
>
<CloseIcon fontSize='small' />
</IconButton>
</>
)
: (
<>
<IconButton
size='small'
onClick={() => {
setEditingGroupId(group.id);
setEditingName(group.name);
}}
data-testid={`edit-group-${group.id}`}
>
<EditIcon fontSize='small' />
</IconButton>
<IconButton
size='small'
onClick={() => {
void deleteGroup(group);
}}
data-testid={`delete-group-${group.id}`}
>
<DeleteIcon fontSize='small' />
</IconButton>
</>
)}
</Box>
<Autocomplete
multiple
fullWidth
options={availableWorkspaces}
value={workspacesInGroup}
getOptionLabel={(workspace) => workspace.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
filterSelectedOptions
renderValue={(value, getItemProps) =>
value.map((workspace, tagIndex) => (
<Chip
variant='outlined'
size='small'
label={workspace.name}
{...getItemProps({ index: tagIndex })}
/>
))}
renderInput={(parameters) => (
<TextField
{...parameters}
label={t('WorkspaceGroup.AddWorkspaces')}
placeholder={t('WorkspaceGroup.SearchWorkspace')}
size='small'
/>
)}
onChange={(_event, newValue) => {
void syncGroupMembership(group.id, newValue);
}}
/>
</ListItem>
</Box>
);
})}
</>
);
}

View file

@ -12,6 +12,7 @@ import { NotificationScheduleItem } from './customItems/NotificationScheduleItem
import { OpenAtLoginItem } from './customItems/OpenAtLoginItem';
import { SpellcheckLanguagesItem } from './customItems/SpellcheckLanguagesItem';
import { WikiUserNameItem } from './customItems/WikiUserNameItem';
import { WorkspaceGroupsItem } from './customItems/WorkspaceGroupsItem';
// ─── Lazy-loaded section-level custom components (very complex sections) ──
const LazyExternalAPISection = lazy(() => import('./sections/ExternalAPI').then((m) => ({ default: m.ExternalAPI })));
@ -57,4 +58,5 @@ export function registerCustomSections(): void {
registerCustomComponent('notifications.schedule', NotificationScheduleItem);
registerCustomComponent('notifications.test', NotificationTestItem);
registerCustomComponent('notifications.helpText', NotificationHelpTextItem);
registerCustomComponent('workspaceGroups.management', WorkspaceGroupsItem);
}

View file

@ -4,6 +4,7 @@ import semver from 'semver';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Divider, List, ListItemButton, Switch } from '@mui/material';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import { renderTimeViewClock } from '@mui/x-date-pickers/timeViewRenderers';
import { ListItem, ListItemText } from '@/components/ListItem';
import { usePromiseValue } from '@/helpers/useServiceValue';
@ -59,6 +60,10 @@ export function Notifications(props: ICustomSectionProps): React.JSX.Element {
await window.service.window.updateWindowMeta(WindowNames.preferences, { preventClosingWindow: true });
}}
disabled={!preference.pauseNotificationsBySchedule}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock,
}}
/>
<TimePicker
label='to'
@ -73,6 +78,10 @@ export function Notifications(props: ICustomSectionProps): React.JSX.Element {
await window.service.window.updateWindowMeta(WindowNames.preferences, { preventClosingWindow: true });
}}
disabled={!preference.pauseNotificationsBySchedule}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock,
}}
/>
</TimePickerContainer>
({window.Intl.DateTimeFormat().resolvedOptions().timeZone})

View file

@ -1,5 +1,6 @@
import { Divider, List, Switch, TextField } from '@mui/material';
import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import { renderTimeViewClock } from '@mui/x-date-pickers/timeViewRenderers';
import { useTranslation } from 'react-i18next';
import { TokenForm } from '../../../components/TokenForm';
@ -68,10 +69,10 @@ export function Sync(props: ICustomSectionProps): React.JSX.Element {
onChange={async (date) => {
if (date === null) throw new Error(`date is null`);
// Extract hours, minutes, seconds from the date and convert to milliseconds
// This is timezone-independent because we're just extracting time components
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
// Must use UTC methods because the Date was constructed with Date.UTC
const hours = date.getUTCHours();
const minutes = date.getUTCMinutes();
const seconds = date.getUTCSeconds();
const intervalMs = (hours * 60 * 60 + minutes * 60 + seconds) * 1000;
await window.service.preference.set('syncDebounceInterval', intervalMs);
props.onNeedsRestart();
@ -82,6 +83,11 @@ export function Sync(props: ICustomSectionProps): React.JSX.Element {
onOpen={async () => {
await window.service.window.updateWindowMeta(WindowNames.preferences, { preventClosingWindow: true });
}}
viewRenderers={{
hours: renderTimeViewClock,
minutes: renderTimeViewClock,
seconds: renderTimeViewClock,
}}
/>
</TimePickerContainer>
</ListItem>