mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2026-05-10 22:31:05 -07:00
Merge a9f192b642 into def9e38f81
This commit is contained in:
commit
9aa997eaab
96 changed files with 5441 additions and 814 deletions
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -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/**
|
||||
|
|
|
|||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -77,3 +77,5 @@ tsconfig.test.json.tsbuildinfo
|
|||
/test-artifacts
|
||||
/test-artifacts-ci
|
||||
test-artifacts-ci.zip
|
||||
cucumber-report.json
|
||||
.codenomad/
|
||||
|
|
|
|||
|
|
@ -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
123
docs/Analytics.md
Normal 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.
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
132
docs/features/WorkspaceGrouping.md
Normal file
132
docs/features/WorkspaceGrouping.md
Normal 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)
|
||||
|
|
@ -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* | |
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
84
features/stepDefinitions/analytics.ts
Normal file
84
features/stepDefinitions/analytics.ts
Normal 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)}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
607
features/stepDefinitions/workspaceGroup.ts
Normal file
607
features/stepDefinitions/workspaceGroup.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
136
features/supports/mockAnalytics.ts
Normal file
136
features/supports/mockAnalytics.ts
Normal 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' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
81
features/workspaceGroup.feature
Normal file
81
features/workspaceGroup.feature
Normal 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"
|
||||
|
|
@ -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 TidGi’s 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"
|
||||
|
|
|
|||
|
|
@ -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": "同步成功"
|
||||
|
|
|
|||
|
|
@ -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
10
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
110
scripts/end-to-end-calibration-preflight.ts
Normal file
110
scripts/end-to-end-calibration-preflight.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ Object.defineProperty(window, 'observables', {
|
|||
},
|
||||
workspace: {
|
||||
workspaces$: new BehaviorSubject([]).asObservable(),
|
||||
groups$: new BehaviorSubject({}).asObservable(),
|
||||
},
|
||||
updater: {
|
||||
updaterMetaData$: new BehaviorSubject(undefined).asObservable(),
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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')} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
34
src/main.ts
34
src/main.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
387
src/services/analytics/index.ts
Normal file
387
src/services/analytics/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/services/analytics/interface.ts
Normal file
75
src/services/analytics/interface.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
35
src/services/git/__tests__/gitSyncRepoDetection.test.ts
Normal file
35
src/services/git/__tests__/gitSyncRepoDetection.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
16
src/services/preferences/definitions/workspaceGroups.ts
Normal file
16
src/services/preferences/definitions/workspaceGroups.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
20
src/services/wiki/wikiWorker/loadTiddlyWikiModule.ts
Normal file
20
src/services/wiki/wikiWorker/loadTiddlyWikiModule.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export function authTokenIsProvided(providedToken: string | undefined): providedToken is string {
|
||||
return typeof providedToken === 'string' && providedToken.length > 0;
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ describe('WikiEmbeddingService Integration Tests', () => {
|
|||
excludedPlugins: wikiWorkspaceDefaultValues.excludedPlugins,
|
||||
enableFileSystemWatch: wikiWorkspaceDefaultValues.enableFileSystemWatch,
|
||||
hibernateWhenUnused: wikiWorkspaceDefaultValues.hibernateWhenUnused,
|
||||
useTidgiConfigSync: true,
|
||||
storageService: SupportedStorageServices.local,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
253
src/services/workspaces/__tests__/useTidgiConfigSync.test.ts
Normal file
253
src/services/workspaces/__tests__/useTidgiConfigSync.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 2→1 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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$,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
2
src/types/tidgi-tw.d.ts
vendored
2
src/types/tidgi-tw.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
120
src/windows/AddWorkspace/ImportConfigDialog.tsx
Normal file
120
src/windows/AddWorkspace/ImportConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
265
src/windows/Preferences/customItems/WorkspaceGroupsItem.tsx
Normal file
265
src/windows/Preferences/customItems/WorkspaceGroupsItem.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue