Feat/mini window (#642)

* feat: new config for tidgi mini window

* chore: upgrade electron-forge

* fix: use 汉语 和 漢語

* feat: shortcut to open mini window

* test: TidGiMenubarWindow

* feat: allow updateWindowProperties on the fly

* fix: wrong icon path

* fix: log not showing error message and stack

* refactor: directly log error when using logger.error

* feat: shortcut to open window

* fix: menubar not closed

* test: e2e for menubar

* test: keyboard shortcut

* test: wiki web content, and refactor to files

* test: update command

* Update Testing.md

* test: menubar settings about menubarSyncWorkspaceWithMainWindow, menubarFixedWorkspaceId

* test: simplify menubar test and cleanup test config

* fix: view missing when execute several test all together

* refactor: use hook to cleanup menubar setting

* refactor: I clear test ai settings to before hook

* Add option to show title bar on menubar window

Introduces a new preference 'showMenubarWindowTitleBar' allowing users to toggle the title bar visibility on the menubar window. Updates related services, interfaces, and UI components to support this feature, and adds corresponding localization strings for English and Chinese.

* refactor: tidgiminiwindow

* refactor: preference keys to right order

* Refactor window dimension checks to use constants

Replaces hardcoded window dimensions with values from windowDimension and WindowNames constants for improved maintainability and consistency in window identification and checks.

* I cleanup test wiki

* Update defaultPreferences.ts

* test: mini window workspace switch

* fix: image broken by ai, and lint

* fix: can't switch to mini window

* refactor: useless todo

* Update index.ts

* refactor: reuse serialize-error

* Update index.ts

* Update testKeyboardShortcuts.ts

* refactor: dup logic

* Update ui.ts

* fix: electron-ipc-cat
This commit is contained in:
lin onetwo 2025-10-21 20:07:04 +08:00 committed by GitHub
parent fa9751e5ea
commit 19ef74a4a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
111 changed files with 4588 additions and 884 deletions

View file

@ -0,0 +1,4 @@
---
applyTo: '**/*.feature,features/**'
---
Read docs/Testing.md for commands you can use. Don't guess shell commands!

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -11,14 +11,14 @@ pnpm test
# Run unit tests only
pnpm test:unit
# Run E2E tests (requires prepare packaged app, but only when you modify code in ./src)
# Run E2E tests (requires prepare packaged app, but only when you modify code in ./src) Don't need to run this if you only modify .feature file or step definition ts files.
pnpm run test:prepare-e2e
# (When only modify tests in ./features folder, and you have packaged app before, only need to run this.)
pnpm test:e2e
# Or run a specific e2e test by using same `@xxx` as in the `.feature` file.
pnpm test:e2e --tags="@smoke"
# Or run a single e2e
pnpm test:e2e --name "Wiki-search tool usage"
# Or run a single e2e test by `--name`
pnpm test:e2e --name "Wiki-search tool usage" # Not `-- --name` , and not `name`, is is just `--name` and have "" around the value, not omitting `--name`
# Run with coverage
pnpm test:unit -- --coverage
@ -232,6 +232,14 @@ When('(Dont do this) I click on a specific button and wait for 2 seconds.', asyn
## Testing Library Best Practices
**Important Testing Rules:**
- **Do NOT simplify tests** - write comprehensive, professional unit tests
- **Can add test-ids** when accessibility queries aren't practical
- **Do NOT be lazy** - fix ALL tests until `pnpm test:unit` passes completely
- **Do NOT summarize** until ALL unit tests pass
- **Focus on professional, fix all seemly complex unit tests** before moving to E2E
### Query Priority (use in this order)
1. Accessible queries - `getByRole`, `getByLabelText`, `getByPlaceholderText`
@ -323,6 +331,7 @@ This warning occurs when React components perform asynchronous state updates dur
- Components with `useEffect` that fetch data on mount
- Async API calls that update component state
- Timers or intervals that trigger state changes
- **RxJS Observable subscriptions** that trigger state updates
**Solution**: Wait for async operations to complete using helper functions:
@ -342,12 +351,62 @@ it('should test feature', async () => {
await renderAsyncComponent();
// Now safe to interact without warnings
});
// For tests that trigger state updates, wait for UI to stabilize
it('should update when data changes', async () => {
render(<Component />);
// Trigger update
someObservable.next(newData);
// Wait for UI to reflect the change
await waitFor(() => {
expect(screen.getByText('Updated Content')).toBeInTheDocument();
});
});
```
Avoid explicitly using `act()` - React Testing Library handles most cases automatically when using proper async patterns.
**Critical**: To avoid act warnings with RxJS Observables:
1. **Never call `.next()` on BehaviorSubject during test execution** - Set all data before rendering
2. **Don't trigger Observable updates via mocked APIs** - Test the component's configuration, not the full update cycle
3. **For loading state tests** - Unmount immediately after assertion to prevent subsequent updates
4. **Follow the Main component test pattern** - Create Observables at file scope, never update them in tests
**Example of correct Observable testing:**
```typescript
// ❌ Wrong: Updating Observable during test
it('should update when data changes', async () => {
render(<Component />);
preferencesSubject.next({ setting: false }); // This causes act warnings!
await waitFor(...);
});
// ✅ Correct: Observable created at file scope, never updated
const preferencesSubject = new BehaviorSubject({ setting: true });
describe('Component', () => {
it('should render correctly', async () => {
const { unmount } = render(<Component />);
expect(screen.getByText('Content')).toBeInTheDocument();
// Optional: unmount() if testing transient state
});
});
```
### E2E test open production app
See User profile section above, we need to set `NODE_ENV` as `test` to open with correct profile.
This is done by using `EnvironmentPlugin` in [webpack.plugins.js](../webpack.plugins.js). Note that EsbuildPlugin's `define` doesn't work, it won't set env properly.
### E2E test hang, and refused to exit until ctrl+C
Check `features/stepDefinitions/application.ts` to see if `After` step includes all clean up steps, like closing all windows instances before closing the app, and stop all utility servers.
### Global shortcut not working
See `src/helpers/testKeyboardShortcuts.ts`

View file

@ -13,7 +13,7 @@ Add your language, make it looks like:
"ja": "日本語",
"ru": "русский",
"vi": "Tiếng Việt",
"zh-Hans": "汉"
"zh-Hans": "汉"
}
```

View file

@ -3,14 +3,21 @@ Feature: TidGi Default Wiki
I want app auto create a default wiki workspace for me
So that I can start using wiki immediately
@wiki
Scenario: Application has default wiki workspace
Background:
# Note: tests expect the test wiki parent folder to exist. Run the preparation step before E2E:
# cross-env NODE_ENV=test pnpm dlx tsx scripts/developmentMkdir.ts
Given I cleanup test wiki
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
@wiki
Scenario: Application has default wiki workspace
Then I should see "page body and wiki workspace" elements with selectors:
| body |
| div[data-testid^='workspace-']:has-text('wiki') |
And the window title should contain ""
@wiki @browser-view
Scenario: Default wiki workspace displays TiddlyWiki content in browser view
And the browser view should be loaded and visible
And I should see " TiddlyWiki" in the browser view content

View file

@ -4,7 +4,6 @@ Feature: TidGi Preference
So that I can customize its behavior and improve my experience
Background:
Given I clear test ai settings
Given I launch the TidGi application
And I wait for the page to load completely
And I should see a "page body" element with selector "body"

View file

@ -291,9 +291,11 @@ Given('I add test ai settings', function() {
fs.writeJsonSync(settingsPath, { ...existing, aiSettings: newAi } as ISettingFile, { spaces: 2 });
});
Given('I clear test ai settings', function() {
function clearAISettings() {
if (!fs.existsSync(settingsPath)) return;
const parsed = fs.readJsonSync(settingsPath) as ISettingFile;
const cleaned = omit(parsed, ['aiSettings']);
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 });
});
}
export { clearAISettings };

View file

@ -3,10 +3,30 @@ import fs from 'fs-extra';
import path from 'path';
import { _electron as electron } from 'playwright';
import type { ElectronApplication, Page } from 'playwright';
import { isMainWindowPage, PageType } from '../../src/constants/pageTypes';
import { windowDimension, WindowNames } from '../../src/services/windows/WindowProperties';
import { MockOpenAIServer } from '../supports/mockOpenAI';
import { logsDirectory, makeSlugPath, screenshotsDirectory } from '../supports/paths';
import { getPackedAppPath } from '../supports/paths';
import { clearAISettings } from './agent';
import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow';
// Helper function to check if window type is valid and return the corresponding WindowNames
export function checkWindowName(windowType: string): WindowNames {
// Exact match - windowType must be a valid WindowNames enum key
if (windowType in WindowNames) {
return (WindowNames as Record<string, WindowNames>)[windowType];
}
throw new Error(`Window type "${windowType}" is not a valid WindowNames. Check the WindowNames enum in WindowProperties.ts. Available: ${Object.keys(WindowNames).join(', ')}`);
}
// Helper function to get window dimensions and ensure they are valid
export function checkWindowDimension(windowName: WindowNames): { width: number; height: number } {
const targetDimensions = windowDimension[windowName];
if (!targetDimensions.width || !targetDimensions.height) {
throw new Error(`Window "${windowName}" does not have valid dimensions defined in windowDimension`);
}
return targetDimensions as { width: number; height: number };
}
export class ApplicationWorld {
app: ElectronApplication | undefined;
@ -14,43 +34,141 @@ export class ApplicationWorld {
currentWindow: Page | undefined; // New state-managed current window
mockOpenAIServer: MockOpenAIServer | undefined;
// Helper method to check if window is visible
async isWindowVisible(page: Page): Promise<boolean> {
if (!this.app) return false;
try {
const browserWindow = await this.app.browserWindow(page);
return await browserWindow.evaluate((win: Electron.BrowserWindow) => win.isVisible());
} catch {
return false;
}
}
// Helper method to wait for window with retry logic
async waitForWindowCondition(
windowType: string,
condition: (window: Page | undefined, isVisible: boolean) => boolean,
maxAttempts: number = 3,
retryInterval: number = 250,
): Promise<boolean> {
if (!this.app) return false;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const targetWindow = await this.findWindowByType(windowType);
const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false;
if (condition(targetWindow, visible)) {
return true;
}
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
return false;
}
// Helper method to find window by type - strict WindowNames matching
async findWindowByType(windowType: string): Promise<Page | undefined> {
if (!this.app) return undefined;
// Validate window type first
const windowName = checkWindowName(windowType);
const pages = this.app.windows();
if (windowName === WindowNames.main) {
// Main window is the first/primary window, typically showing guide, agent, help, or wiki pages
// It's the window that opens on app launch
const allWindows = pages.filter(page => !page.isClosed());
if (allWindows.length > 0) {
// Return the first window (main window is always the first one created)
return allWindows[0];
}
return undefined;
} else if (windowName === WindowNames.tidgiMiniWindow) {
// Special handling for tidgi mini window
// First try to find by Electron window dimensions (more reliable than title)
const windowDimensions = checkWindowDimension(windowName);
try {
const electronWindowInfo = await this.app.evaluate(
async ({ BrowserWindow }, size: { width: number; height: number }) => {
const allWindows = BrowserWindow.getAllWindows();
const tidgiMiniWindow = allWindows.find(win => {
const bounds = win.getBounds();
return bounds.width === size.width && bounds.height === size.height;
});
return tidgiMiniWindow ? { id: tidgiMiniWindow.id } : null;
},
windowDimensions,
);
if (electronWindowInfo) {
// Found by dimensions, now match with Playwright page
const allWindows = pages.filter(page => !page.isClosed());
for (const page of allWindows) {
try {
// Try to match by checking if this page belongs to the found electron window
// For now, use title as fallback verification
const title = await page.title();
if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) {
return page;
}
} catch {
continue;
}
}
}
} catch {
// If Electron API fails, fallback to title matching
}
// Fallback: Match by window title
const allWindows = pages.filter(page => !page.isClosed());
for (const page of allWindows) {
try {
const title = await page.title();
if (title.includes('太记小窗') || title.includes('TidGi Mini Window') || title.includes('TidGiMiniWindow')) {
return page;
}
} catch {
// Page might be closed or not ready, continue to next
continue;
}
}
return undefined;
} else {
// For regular windows (preferences, about, addWorkspace, etc.)
return pages.find(page => {
if (page.isClosed()) return false;
const url = page.url() || '';
// Match exact route paths: /#/windowType or ending with /windowType
return url.includes(`#/${windowType}`) || url.endsWith(`/${windowType}`);
});
}
}
async getWindow(windowType: string = 'main'): Promise<Page | undefined> {
if (!this.app) return undefined;
for (let attempt = 0; attempt < 3; attempt++) {
const pages = this.app.windows();
const extractFragment = (url: string) => {
if (!url) return '';
const afterHash = url.includes('#') ? url.split('#').slice(1).join('#') : '';
// remove leading slashes or colons like '/preferences' or ':Index'
return afterHash.replace(/^[:/]+/, '').split(/[/?#]/)[0] || '';
};
if (windowType === 'main') {
const mainWindow = pages.find(page => {
const pageType = extractFragment(page.url());
// file:///C:/Users/linonetwo/Documents/repo-c/TidGi-Desktop/out/TidGi-win32-x64/resources/app.asar/.webpack/renderer/main_window/index.html#/guide
// file:///...#/guide or tidgi://.../#:Index based on different workspace
return isMainWindowPage(pageType as PageType | undefined);
});
if (mainWindow) return mainWindow;
} else if (windowType === 'current') {
if (this.currentWindow) return this.currentWindow;
} else {
// match windows more flexibly by checking the full URL and fragment for the windowType
const specificWindow = pages.find(page => {
const rawUrl = page.url() || '';
const frag = extractFragment(rawUrl);
// Case-insensitive full-url match first (handles variants like '#:Index' or custom schemes)
if (rawUrl.toLowerCase().includes(windowType.toLowerCase())) return true;
// Fallback to fragment inclusion
return frag.toLowerCase().includes(windowType.toLowerCase());
});
if (specificWindow) return specificWindow;
// Special case for 'current' window
if (windowType === 'current') {
return this.currentWindow;
}
// If window not found, wait 1 second and retry (except for the last attempt)
// Use the findWindowByType method with retry logic
for (let attempt = 0; attempt < 3; attempt++) {
try {
const window = await this.findWindowByType(windowType);
if (window) return window;
} catch (error) {
// If it's an invalid window type error, throw immediately
if (error instanceof Error && error.message.includes('is not a valid WindowNames')) {
throw error;
}
}
// If window not found, wait and retry (except for the last attempt)
if (attempt < 2) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
@ -64,7 +182,7 @@ setWorldConstructor(ApplicationWorld);
// setDefaultTimeout(50000);
Before(function(this: ApplicationWorld) {
Before(function(this: ApplicationWorld, { pickle }) {
// Create necessary directories under userData-test/logs to match appPaths in dev/test
if (!fs.existsSync(logsDirectory)) {
fs.mkdirSync(logsDirectory, { recursive: true });
@ -74,11 +192,29 @@ Before(function(this: ApplicationWorld) {
if (!fs.existsSync(screenshotsDirectory)) {
fs.mkdirSync(screenshotsDirectory, { recursive: true });
}
if (pickle.tags.some((tag) => tag.name === '@setup')) {
clearAISettings();
}
if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) {
clearTidgiMiniWindowSettings();
}
});
After(async function(this: ApplicationWorld) {
After(async function(this: ApplicationWorld, { pickle }) {
if (this.app) {
try {
// Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C
const allWindows = this.app.windows();
for (const window of allWindows) {
try {
if (!window.isClosed()) {
await window.close();
}
} catch (error) {
console.error('Error closing window:', error);
}
}
await this.app.close();
} catch (error) {
console.error('Error during cleanup:', error);
@ -87,6 +223,12 @@ After(async function(this: ApplicationWorld) {
this.mainWindow = undefined;
this.currentWindow = undefined;
}
if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) {
clearTidgiMiniWindowSettings();
}
if (pickle.tags.some((tag) => tag.name === '@setup')) {
clearAISettings();
}
});
AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) {
@ -125,7 +267,7 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result })
/**
* Typical steps like:
* - I add test ai settings
* - I cleanup test wiki
* - I cleanup test wiki so it could create a new one on start
* - I clear test ai settings
*/
if (!pageToUse || pageToUse.isClosed()) {
@ -208,7 +350,7 @@ When('I launch the TidGi application', async function(this: ApplicationWorld) {
this.currentWindow = this.mainWindow;
} catch (error) {
throw new Error(
`Failed to launch TidGi application: ${error as Error}. You should run \`pnpm run package\` before running the tests to ensure the app is built, and build with binaries like "dugite" and "tiddlywiki", see scripts/afterPack.js for more details.`,
`Failed to launch TidGi application: ${error as Error}. You should run \`pnpm run test:prepare-e2e\` before running the tests to ensure the app is built, and build with binaries like "dugite" and "tiddlywiki", see scripts/afterPack.js for more details.`,
);
}
});

View file

@ -0,0 +1,103 @@
import { Then } from '@cucumber/cucumber';
import { getDOMContent, getTextContent, isLoaded } from '../supports/webContentsViewHelper';
import type { ApplicationWorld } from './application';
// Constants for retry logic
const MAX_ATTEMPTS = 3;
const RETRY_INTERVAL_MS = 500;
Then('I should see {string} in the browser view content', async function(this: ApplicationWorld, expectedText: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
// Retry logic to check for expected text in content
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const content = await getTextContent(this.app);
if (content && content.includes(expectedText)) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
// Final attempt to get content for error message
const finalContent = await getTextContent(this.app);
throw new Error(
`Expected text "${expectedText}" not found in browser view content after ${MAX_ATTEMPTS} attempts. Actual content: ${
finalContent ? finalContent.substring(0, 200) + '...' : 'null'
}`,
);
});
Then('I should see {string} in the browser view DOM', async function(this: ApplicationWorld, expectedText: string) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
// Retry logic to check for expected text in DOM
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const domContent = await getDOMContent(this.app);
if (domContent && domContent.includes(expectedText)) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
// Final attempt to get DOM content for error message
const finalDomContent = await getDOMContent(this.app);
throw new Error(
`Expected text "${expectedText}" not found in browser view DOM after ${MAX_ATTEMPTS} attempts. Actual DOM: ${
finalDomContent ? finalDomContent.substring(0, 200) + '...' : 'null'
}`,
);
});
Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) {
if (!this.app) {
throw new Error('Application not launched');
}
if (!this.currentWindow) {
throw new Error('No current window available');
}
// Retry logic to check if browser view is loaded
const maxAttempts = MAX_ATTEMPTS;
const retryInterval = RETRY_INTERVAL_MS;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const isLoadedResult = await isLoaded(this.app);
if (isLoadedResult) {
return; // Success, exit early
}
// Wait before retrying (except for the last attempt)
if (attempt < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
throw new Error(`Browser view is not loaded or visible after ${MAX_ATTEMPTS} attempts`);
});

View file

@ -0,0 +1,74 @@
import { Given } from '@cucumber/cucumber';
import fs from 'fs-extra';
import { omit } from 'lodash';
import path from 'path';
import type { ISettingFile } from '../../src/services/database/interface';
import { settingsPath } from '../supports/paths';
Given('I configure tidgi mini window with shortcut', async function() {
let existing = {} as ISettingFile;
if (await fs.pathExists(settingsPath)) {
existing = await fs.readJson(settingsPath) as ISettingFile;
} else {
// ensure settings directory exists so writeJsonSync won't fail
await fs.ensureDir(path.dirname(settingsPath));
}
// Convert CommandOrControl to platform-specific format
const isWindows = process.platform === 'win32';
const isLinux = process.platform === 'linux';
let shortcut = 'CommandOrControl+Shift+M';
if (isWindows || isLinux) {
shortcut = 'Ctrl+Shift+M';
} else {
shortcut = 'Cmd+Shift+M';
}
const updatedPreferences = {
...existing.preferences,
tidgiMiniWindow: true,
keyboardShortcuts: {
...(existing.preferences?.keyboardShortcuts || {}),
'Window.toggleTidgiMiniWindow': shortcut,
},
};
const finalSettings = { ...existing, preferences: updatedPreferences } as ISettingFile;
await fs.writeJson(settingsPath, finalSettings, { spaces: 2 });
});
// Cleanup function to be called after tidgi mini window tests (after app closes)
function clearTidgiMiniWindowSettings() {
if (!fs.existsSync(settingsPath)) return;
const parsed = fs.readJsonSync(settingsPath) as ISettingFile;
// Remove tidgi mini window-related preferences to avoid affecting other tests
const cleanedPreferences = omit(parsed.preferences || {}, [
'tidgiMiniWindow',
'tidgiMiniWindowSyncWorkspaceWithMainWindow',
'tidgiMiniWindowFixedWorkspaceId',
'tidgiMiniWindowAlwaysOnTop',
'tidgiMiniWindowShowSidebar',
'tidgiMiniWindowShowTitleBar',
]);
// Also clean up the tidgi mini window shortcut from keyboardShortcuts
if (cleanedPreferences.keyboardShortcuts) {
cleanedPreferences.keyboardShortcuts = omit(cleanedPreferences.keyboardShortcuts, ['Window.toggleTidgiMiniWindow']);
}
// Reset active workspace to first wiki workspace to avoid agent workspace being active
const workspaces = parsed.workspaces || {};
const workspaceEntries = Object.entries(workspaces);
// Set all workspaces to inactive first
for (const [, workspace] of workspaceEntries) {
workspace.active = false;
}
// Find first non-page-type workspace (wiki) and activate it
const firstWikiWorkspace = workspaceEntries.find(([, workspace]) => !workspace.pageType);
if (firstWikiWorkspace) {
firstWikiWorkspace[1].active = true;
}
const cleaned = { ...parsed, preferences: cleanedPreferences, workspaces };
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 });
}
export { clearTidgiMiniWindowSettings };

View file

@ -6,12 +6,13 @@ When('I wait for {float} seconds', async function(this: ApplicationWorld, second
});
When('I wait for the page to load completely', async function(this: ApplicationWorld) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
await currentWindow?.waitForLoadState('networkidle', { timeout: 30000 });
});
Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
try {
await currentWindow?.waitForSelector(selector, { timeout: 10000 });
const isVisible = await currentWindow?.isVisible(selector);
@ -24,7 +25,7 @@ Then('I should see a(n) {string} element with selector {string}', async function
});
Then('I should see {string} elements with selectors:', async function(this: ApplicationWorld, elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
@ -57,7 +58,7 @@ Then('I should see {string} elements with selectors:', async function(this: Appl
});
Then('I should not see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
@ -67,7 +68,18 @@ Then('I should not see a(n) {string} element with selector {string}', async func
if (count > 0) {
const isVisible = await element.isVisible();
if (isVisible) {
throw new Error(`Element "${elementComment}" with selector "${selector}" should not be visible but was found`);
// Get parent element HTML for debugging
let parentHtml = '';
try {
const parent = element.locator('xpath=..');
parentHtml = await parent.evaluate((node) => node.outerHTML);
} catch {
parentHtml = 'Failed to get parent HTML';
}
throw new Error(
`Element "${elementComment}" with selector "${selector}" should not be visible but was found\n` +
`Parent element HTML:\n${parentHtml}`,
);
}
}
// Element not found or not visible - this is expected
@ -80,6 +92,48 @@ Then('I should not see a(n) {string} element with selector {string}', async func
}
});
Then('I should not see {string} elements with selectors:', async function(this: ApplicationWorld, elementDescriptions: string, dataTable: DataTable) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
const descriptions = elementDescriptions.split(' and ').map(d => d.trim());
const rows = dataTable.raw();
const errors: string[] = [];
if (descriptions.length !== rows.length) {
throw new Error(`Mismatch: ${descriptions.length} element descriptions but ${rows.length} selectors provided`);
}
// Check all elements
for (let index = 0; index < rows.length; index++) {
const [selector] = rows[index];
const elementComment = descriptions[index];
try {
const element = currentWindow.locator(selector).first();
const count = await element.count();
if (count > 0) {
const isVisible = await element.isVisible();
if (isVisible) {
errors.push(`Element "${elementComment}" with selector "${selector}" should not be visible but was found`);
}
}
// Element not found or not visible - this is expected
} catch (error) {
// If the error is our custom error, rethrow it
if (error instanceof Error && error.message.includes('should not be visible')) {
errors.push(error.message);
}
// Otherwise, element not found is expected - continue
}
}
if (errors.length > 0) {
throw new Error(`Failed to verify elements are not visible:\n${errors.join('\n')}`);
}
});
When('I click on a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const targetWindow = await this.getWindow('current');
@ -156,7 +210,7 @@ When('I right-click on a(n) {string} element with selector {string}', async func
});
When('I click all {string} elements matching selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const win = this.currentWindow || this.mainWindow;
const win = this.currentWindow;
if (!win) throw new Error('No active window available to click elements');
const locator = win.locator(selector);
@ -177,7 +231,7 @@ When('I click all {string} elements matching selector {string}', async function(
});
When('I type {string} in {string} element with selector {string}', async function(this: ApplicationWorld, text: string, elementComment: string, selector: string) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
@ -192,7 +246,7 @@ When('I type {string} in {string} element with selector {string}', async functio
});
When('I clear text in {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
@ -207,7 +261,7 @@ When('I clear text in {string} element with selector {string}', async function(t
});
When('the window title should contain {string}', async function(this: ApplicationWorld, expectedTitle: string) {
const currentWindow = this.currentWindow || this.mainWindow;
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
@ -258,3 +312,143 @@ When('I close {string} window', async function(this: ApplicationWorld, windowTyp
throw new Error(`Could not find ${windowType} window to close`);
}
});
When('I press the key combination {string}', async function(this: ApplicationWorld, keyCombo: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
// Convert CommandOrControl to platform-specific key
let platformKeyCombo = keyCombo;
if (keyCombo.includes('CommandOrControl')) {
// Prefer explicit platform detection: use 'Meta' only on macOS (darwin),
// otherwise default to 'Control'. This avoids assuming non-Windows/Linux
// is always macOS.
if (process.platform === 'darwin') {
platformKeyCombo = keyCombo.replace('CommandOrControl', 'Meta');
} else {
platformKeyCombo = keyCombo.replace('CommandOrControl', 'Control');
}
}
// Use dispatchEvent to trigger document-level keydown events
// This ensures the event is properly captured by React components listening to document events
// The testKeyboardShortcutFallback in test environment expects key to match the format used in shortcuts
await currentWindow.evaluate((keyCombo) => {
const parts = keyCombo.split('+');
let mainKey = parts[parts.length - 1];
const modifiers = parts.slice(0, -1);
// For single letter keys, match the case sensitivity used by the shortcut system
// Shift+Key -> uppercase, otherwise lowercase
if (mainKey.length === 1) {
mainKey = modifiers.includes('Shift') ? mainKey.toUpperCase() : mainKey.toLowerCase();
}
const event = new KeyboardEvent('keydown', {
key: mainKey,
code: mainKey.length === 1 ? `Key${mainKey.toUpperCase()}` : mainKey,
ctrlKey: modifiers.includes('Control'),
metaKey: modifiers.includes('Meta'),
shiftKey: modifiers.includes('Shift'),
altKey: modifiers.includes('Alt'),
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
}, platformKeyCombo);
});
When('I select {string} from MUI Select with test id {string}', async function(this: ApplicationWorld, optionValue: string, testId: string) {
const currentWindow = this.currentWindow;
if (!currentWindow) {
throw new Error('No current window is available');
}
try {
// Find the hidden input element with the test-id
const inputSelector = `input[data-testid="${testId}"]`;
await currentWindow.waitForSelector(inputSelector, { timeout: 10000 });
// Try to click using Playwright's click on the div with role="combobox"
// According to your HTML structure, the combobox is a sibling of the input
const clicked = await currentWindow.evaluate((testId) => {
const input = document.querySelector(`input[data-testid="${testId}"]`);
if (!input) return { success: false, error: 'Input not found' };
const parent = input.parentElement;
if (!parent) return { success: false, error: 'Parent not found' };
// Find all elements in parent
const combobox = parent.querySelector('[role="combobox"]');
if (!combobox) {
return {
success: false,
error: 'Combobox not found',
parentHTML: parent.outerHTML.substring(0, 500),
};
}
// Trigger both mousedown and click events
combobox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
combobox.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
(combobox as HTMLElement).click();
return { success: true };
}, testId);
if (!clicked.success) {
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: 5000 });
// Try to click on the option with the specified value (data-value attribute)
// If not found, try to find by text content
const optionClicked = await currentWindow.evaluate((optionValue) => {
// First try: Find by data-value attribute
const optionByValue = document.querySelector(`[role="option"][data-value="${optionValue}"]`);
if (optionByValue) {
(optionByValue as HTMLElement).click();
return { success: true, method: 'data-value' };
}
// Second try: Find by text content (case-insensitive)
const allOptions = Array.from(document.querySelectorAll('[role="option"]'));
const optionByText = allOptions.find(option => {
const text = option.textContent?.trim().toLowerCase();
return text === optionValue.toLowerCase();
});
if (optionByText) {
(optionByText as HTMLElement).click();
return { success: true, method: 'text-content' };
}
// Return available options for debugging
return {
success: false,
availableOptions: allOptions.map(opt => ({
text: opt.textContent?.trim(),
value: opt.getAttribute('data-value'),
})),
};
}, optionValue);
if (!optionClicked.success) {
throw new Error(
`Could not find option "${optionValue}". Available options: ${JSON.stringify(optionClicked.availableOptions)}`,
);
}
// Wait for the menu to close
await currentWindow.waitForSelector('[role="listbox"]', { state: 'hidden', timeout: 5000 });
} catch (error) {
throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`);
}
});

View file

@ -3,7 +3,7 @@ import fs from 'fs-extra';
import type { IWorkspace } from '../../src/services/workspaces/interface';
import { settingsPath, wikiTestWikiPath } from '../supports/paths';
When('I cleanup test wiki', async function() {
When('I cleanup test wiki so it could create a new one on start', async function() {
if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath);
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;

View file

@ -0,0 +1,207 @@
import { When } from '@cucumber/cucumber';
import type { ElectronApplication } from 'playwright';
import type { ApplicationWorld } from './application';
import { checkWindowDimension, checkWindowName } from './application';
// Constants for retry logic
const MAX_ATTEMPTS = 10;
const RETRY_INTERVAL_MS = 1000;
// Helper function to get browser view info from Electron window
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 }> {
return app.evaluate(async ({ BrowserWindow }, dimensions: { width: number; height: number }) => {
const windows = BrowserWindow.getAllWindows();
// Find the target window by dimensions
const targetWindow = windows.find(win => {
const bounds = win.getBounds();
return bounds.width === dimensions.width && bounds.height === dimensions.height;
});
if (!targetWindow) {
return { hasView: false };
}
// Get all child views (WebContentsView instances) attached to this specific window
if (targetWindow.contentView && 'children' in targetWindow.contentView) {
const views = targetWindow.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
const webContentsView = view as unknown as { getBounds: () => { x: number; y: number; width: number; height: number } };
const viewBounds = webContentsView.getBounds();
const windowContentBounds = targetWindow.getContentBounds();
return {
view: viewBounds,
windowContent: windowContentBounds,
hasView: true,
};
}
}
}
return { hasView: false };
}, dimensions);
}
When('I confirm the {string} window exists', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not launched');
}
const success = await this.waitForWindowCondition(
windowType,
(window) => window !== undefined && !window.isClosed(),
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window was not found or is closed`);
}
});
When('I confirm the {string} window visible', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not launched');
}
const success = await this.waitForWindowCondition(
windowType,
(window, isVisible) => window !== undefined && !window.isClosed() && isVisible,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window was not visible after ${MAX_ATTEMPTS} attempts`);
}
});
When('I confirm the {string} window not visible', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not launched');
}
const success = await this.waitForWindowCondition(
windowType,
(window, isVisible) => window !== undefined && !window.isClosed() && !isVisible,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window was visible or not found after ${MAX_ATTEMPTS} attempts`);
}
});
When('I confirm the {string} window does not exist', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not launched');
}
const success = await this.waitForWindowCondition(
windowType,
(window) => window === undefined,
MAX_ATTEMPTS,
RETRY_INTERVAL_MS,
);
if (!success) {
throw new Error(`${windowType} window still exists after ${MAX_ATTEMPTS} attempts`);
}
});
When('I confirm the {string} window browser view is positioned within visible window bounds', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not available');
}
const targetWindow = await this.findWindowByType(windowType);
if (!targetWindow || targetWindow.isClosed()) {
throw new Error(`Window "${windowType}" is not available or has been closed`);
}
// Get the window dimensions to identify it - must match a defined WindowNames
const windowName = checkWindowName(windowType);
const windowDimensions = checkWindowDimension(windowName);
// 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`);
}
// 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 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}}`,
);
}
});
When('I confirm the {string} window browser view is not positioned within visible window bounds', async function(this: ApplicationWorld, windowType: string) {
if (!this.app) {
throw new Error('Application is not available');
}
const targetWindow = await this.findWindowByType(windowType);
if (!targetWindow || targetWindow.isClosed()) {
throw new Error(`Window "${windowType}" is not available or has been closed`);
}
// Get the window dimensions to identify it - must match a defined WindowNames
const windowName = checkWindowName(windowType);
const windowDimensions = checkWindowDimension(windowName);
// 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;
}
// 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 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}}`,
);
}
});

View file

@ -40,7 +40,7 @@ export function getPackedAppPath(): string {
}
throw new Error(
`TidGi executable not found. Checked paths:\n${possiblePaths.join('\n')}\n\nYou should run \`pnpm run package:dev\` before running the tests to ensure the app is built.`,
`TidGi executable not found. Checked paths:\n${possiblePaths.join('\n')}\n\nYou should run \`pnpm run test:prepare-e2e\` before running the tests to ensure the app is built.`,
);
}

View file

@ -0,0 +1,151 @@
import type { ElectronApplication } from 'playwright';
/**
* Get text content from WebContentsView
* @param app Electron application instance
* @returns Promise<string | null> Returns text content or null
*/
export async function getTextContent(app: ElectronApplication): Promise<string | null> {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
try {
// Cast to WebContentsView type and execute JavaScript
const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise<string> } };
const content = await webContentsView.webContents.executeJavaScript(`
document.body.textContent || document.body.innerText || ''
`);
if (content && content.trim()) {
return content;
}
} catch {
// Continue to next view if this one fails
continue;
}
}
}
}
}
return null;
});
}
/**
* Get DOM content from WebContentsView
* @param app Electron application instance
* @returns Promise<string | null> Returns DOM content or null
*/
export async function getDOMContent(app: ElectronApplication): Promise<string | null> {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
try {
// Cast to WebContentsView type and execute JavaScript
const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise<string> } };
const content = await webContentsView.webContents.executeJavaScript(`
document.documentElement.outerHTML || ''
`);
if (content && content.trim()) {
return content;
}
} catch {
// Continue to next view if this one fails
continue;
}
}
}
}
}
return null;
});
}
/**
* Check if WebContentsView exists and is loaded
* @param app Electron application instance
* @returns Promise<boolean> Returns whether it exists and is loaded
*/
export async function isLoaded(app: ElectronApplication): Promise<boolean> {
return await app.evaluate(async ({ BrowserWindow }) => {
// Get all browser windows
const windows = BrowserWindow.getAllWindows();
for (const window of windows) {
// Get all child views (WebContentsView instances) attached to this window
if (window.contentView && 'children' in window.contentView) {
const views = window.contentView.children || [];
for (const view of views) {
// Type guard to check if view is a WebContentsView
if (view && view.constructor.name === 'WebContentsView') {
// If we found a WebContentsView, consider it loaded
return true;
}
}
}
}
return false;
});
}
/**
* Find specified text in WebContentsView
* @param app Electron application instance
* @param expectedText Text to search for
* @param contentType Content type: 'text' or 'dom'
* @returns Promise<boolean> Returns whether text was found
*/
export async function containsText(
app: ElectronApplication,
expectedText: string,
contentType: 'text' | 'dom' = 'text',
): Promise<boolean> {
const content = contentType === 'text'
? await getTextContent(app)
: await getDOMContent(app);
return content !== null && content.includes(expectedText);
}
/**
* Get WebContentsView content summary (for error messages)
* @param app Electron application instance
* @param contentType Content type: 'text' or 'dom'
* @param maxLength Maximum length, default 200
* @returns Promise<string> Returns content summary
*/
export async function getContentSummary(
app: ElectronApplication,
contentType: 'text' | 'dom' = 'text',
maxLength: number = 200,
): Promise<string> {
const content = contentType === 'text'
? await getTextContent(app)
: await getDOMContent(app);
if (!content) {
return 'null';
}
return content.length > maxLength
? content.substring(0, maxLength) + '...'
: content;
}

View file

@ -0,0 +1,38 @@
@tidgiminiwindow
Feature: TidGi Mini Window
As a user
I want to enable and use the TidGi mini window
So that I can quickly access TidGi from the system tray
Scenario: Enable tidgi mini window and test keyboard shortcut
Given 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
And I click on an "open preferences button" element with selector "#open-preferences-button"
And I switch to "preferences" window
When I click on a "tidgi mini window section" element with selector "[data-testid='preference-section-tidgiMiniWindow']"
And I confirm the "tidgiMiniWindow" window does not exist
When I click on an "attach to tidgi mini window switch" element with selector "[data-testid='attach-to-tidgi-mini-window-switch']"
And I confirm the "tidgiMiniWindow" window exists
And I confirm the "tidgiMiniWindow" window not visible
Then I should see "always on top toggle and workspace sync toggle" elements with selectors:
| [data-testid='tidgi-mini-window-always-on-top-switch'] |
| [data-testid='tidgi-mini-window-sync-workspace-switch'] |
Then I click on a "shortcut register button" element with selector "[data-testid='shortcut-register-button']"
And I press the key combination "CommandOrControl+Shift+M"
And I click on a "shortcut confirm button" element with selector "[data-testid='shortcut-confirm-button']"
And I close "preferences" window
Then I switch to "main" window
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window exists
And I confirm the "tidgiMiniWindow" window visible
And I confirm the "tidgiMiniWindow" window browser view is positioned within visible window bounds
And I switch to "tidgiMiniWindow" window
Then the browser view should be loaded and visible
And I should see " TiddlyWiki" in the browser view content
Then I switch to "main" window
When I press the key combination "CommandOrControl+Shift+M"
And I wait for 2 seconds
And I confirm the "tidgiMiniWindow" window exists
And I confirm the "tidgiMiniWindow" window not visible

View file

@ -0,0 +1,130 @@
@tidgiminiwindow
Feature: TidGi Mini Window Workspace Switching
As a user with tidgi mini window already enabled
I want to test tidgi mini window behavior with different workspace configurations
So that I can verify workspace switching and fixed workspace features
Background:
Given I configure tidgi mini window with shortcut
Then I launch the TidGi application
And I wait for the page to load completely
Then I switch to "main" window
Scenario: TidGi mini window syncs with main window switching to agent workspace
# Switch main window to agent workspace
When I click on an "agent workspace button" element with selector "[data-testid='workspace-agent']"
# Verify tidgi mini window exists in background (created but not visible)
And I wait for 0.2 seconds
Then I confirm the "tidgiMiniWindow" window exists
And I confirm the "tidgiMiniWindow" window not visible
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
And I confirm the "tidgiMiniWindow" window browser view is not positioned within visible window bounds
Then I switch to "tidgiMiniWindow" window
# In sync mode, browser view shows the current active workspace (agent), sidebar is hidden
And I should see a "new tab button" element with selector "[data-tab-id='new-tab-button']"
Scenario: TidGi mini window with fixed agent workspace shows no view and fixed wiki workspace shows browser view
# Configure fixed agent workspace through UI
And I click on an "open preferences button" element with selector "#open-preferences-button"
And I switch to "preferences" window
When I click on "tidgi mini window section and disable sync workspace switch" elements with selectors:
| [data-testid='preference-section-tidgiMiniWindow'] |
| [data-testid='tidgi-mini-window-sync-workspace-switch'] |
# Enable sidebar to see workspace buttons
And I click on a "Enable sidebar toggle switch" element with selector "[data-testid='sidebar-on-tidgi-mini-window-switch']"
And I wait for 0.2 seconds
# Select agent workspace (which is a page type workspace)
And I select "agent" from MUI Select with test id "tidgi-mini-window-fixed-workspace-select"
# Open tidgi mini window - should show agent workspace and no browser view
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
And I confirm the "tidgiMiniWindow" window browser view is not positioned within visible window bounds
Then I switch to "tidgiMiniWindow" window
# Verify sidebar is visible
And I should see a "main sidebar" element with selector "[data-testid='main-sidebar']"
# Verify agent workspace is active
And I should see a "agent workspace active button" element with selector "[data-testid='workspace-agent'][data-active='true']"
# Close tidgi mini window and switch to wiki workspace
And I wait for 0.2 seconds
Then I switch to "preferences" window
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window not visible
# Get the first wiki workspace ID and select it
And I select "wiki" from MUI Select with test id "tidgi-mini-window-fixed-workspace-select"
And I wait for 0.2 seconds
# Open tidgi mini window again - should show wiki workspace with browser view
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
And I confirm the "tidgiMiniWindow" window browser view is positioned within visible window bounds
Then I switch to "tidgiMiniWindow" window
# Verify sidebar is visible
And I should see a "main sidebar" element with selector "[data-testid='main-sidebar']"
# Verify browser view content is visible and wiki workspace is active
And I should see " TiddlyWiki" in the browser view content
And I should see a "wiki workspace active button" element with selector "[data-active='true']"
Scenario: Enabling sync workspace automatically hides sidebar
# Configure tidgi mini window with fixed workspace first
When I click on "agent workspace button and open preferences button" elements with selectors:
| [data-testid='workspace-agent'] |
| #open-preferences-button |
And I switch to "preferences" window
When I click on "tidgi mini window section and disable sync workspace switch" elements with selectors:
| [data-testid='preference-section-tidgiMiniWindow'] |
| [data-testid='tidgi-mini-window-sync-workspace-switch'] |
And I should see a "sidebar toggle switch" element with selector "[data-testid='sidebar-on-tidgi-mini-window-switch']"
# Enable sidebar to see it in mini window
And I click on a "Enable sidebar toggle switch" element with selector "[data-testid='sidebar-on-tidgi-mini-window-switch']"
# Open tidgi mini window and verify sidebar is visible
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
And I switch to "tidgiMiniWindow" window
And I should see a "main sidebar" element with selector "[data-testid='main-sidebar']"
# Close mini window and go back to preferences
Then I switch to "preferences" window
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window not visible
# Now enable sync workspace - should automatically hide sidebar
When I click on a "Enable tidgi mini window sync workspace switch" element with selector "[data-testid='tidgi-mini-window-sync-workspace-switch']"
# Verify sidebar option is now hidden
And I should not see "sidebar toggle switch and fixed workspace select" elements with selectors:
| [data-testid='sidebar-on-tidgi-mini-window-switch'] |
| [data-testid='tidgi-mini-window-fixed-workspace-select'] |
# Open tidgi mini window in sync mode - should sync to agent workspace
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
And I switch to "tidgiMiniWindow" window
# In sync mode, sidebar should not be visible (automatically hidden)
And I should not see a "main sidebar" element with selector "[data-testid='main-sidebar']"
Scenario: Clicking workspace button in mini window updates fixed workspace ID
# First click on guide workspace in main window to set a different active workspace
Then I switch to "main" window
When I click on a "guide workspace button" element with selector "[data-testid='workspace-guide']"
And I wait for 0.2 seconds
# Configure tidgi mini window with fixed workspace
And I click on an "open preferences button" element with selector "#open-preferences-button"
And I switch to "preferences" window
When I click on "tidgi mini window section and disable sync workspace switch" elements with selectors:
| [data-testid='preference-section-tidgiMiniWindow'] |
| [data-testid='tidgi-mini-window-sync-workspace-switch'] |
# Enable sidebar to see workspace buttons
And I click on a "Enable sidebar toggle switch" element with selector "[data-testid='sidebar-on-tidgi-mini-window-switch']"
And I wait for 0.2 seconds
# Select agent workspace as fixed workspace
And I select "agent" from MUI Select with test id "tidgi-mini-window-fixed-workspace-select"
And I wait for 0.2 seconds
# Open tidgi mini window
When I press the key combination "CommandOrControl+Shift+M"
And I confirm the "tidgiMiniWindow" window visible
Then I switch to "tidgiMiniWindow" window
# Verify agent workspace is active initially
And I should see a "agent workspace button with active state" element with selector "[data-testid='workspace-agent'][data-active='true']"
# Click on guide workspace button to update fixed workspace ID
When I click on a "guide workspace button" element with selector "[data-testid='workspace-guide']"
And I wait for 0.2 seconds
# Verify guide workspace is now active and agent workspace is no longer active
And I should see "guide workspace button with active state and agent workspace button without active state" elements with selectors:
| [data-testid='workspace-guide'][data-active='true'] |
| [data-testid='workspace-agent'][data-active='false'] |

View file

@ -32,7 +32,7 @@ const config: ForgeConfig = {
// Unpack worker files, native modules path, and ALL .node binaries (including better-sqlite3)
unpack: '{**/.webpack/main/*.worker.*,**/.webpack/main/native_modules/path.txt,**/{.**,**}/**/*.node}',
},
extraResource: ['localization', 'template/wiki', 'build-resources/menubar@2x.png', 'build-resources/menubarTemplate@2x.png'],
extraResource: ['localization', 'template/wiki', 'build-resources/tidgiMiniWindow@2x.png', 'build-resources/tidgiMiniWindowTemplate@2x.png'],
// @ts-expect-error - mac config is valid
mac: {
category: 'productivity',

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "Open CommandPalette",
"OpenLinkInBrowser": "Open Link in Browser",
"OpenTidGi": "Open TidGi",
"OpenTidGiMenuBar": "Open TidGi MenuBar",
"OpenTidGiMiniWindow": "Open TidGi Mini Window",
"OpenWorkspaceInNewWindow": "Open Workspace in New Window",
"Paste": "Paste",
"Preferences": "Preferences...",
@ -309,7 +309,7 @@
"SelectNextWorkspace": "Select Next Workspace",
"SelectPreviousWorkspace": "Select Previous Workspace",
"TidGi": "TidGi",
"TidGiMenuBar": "TidGi MenuBar",
"TidGiMiniWindow": "TidGi Mini Window",
"View": "View",
"Wiki": "Wiki",
"Window": "Window",
@ -324,10 +324,10 @@
"AlwaysOnTopDetail": "Keep TidGis main window always on top of other windows, and will not be covered by other windows",
"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",
"AttachToMenuBar": "Attach to menu bar",
"AttachToMenuBarShowSidebar": "Attach To Menu Bar Show Sidebar",
"AttachToMenuBarShowSidebarTip": "Generally, TidGi small window is only used to quickly view the current workspace, so the default synchronization with the main window workspace, do not need a sidebar, the default hidden sidebar.",
"AttachToMenuBarTip": "Make a small TidGi popup window that pop when you click appbar mini icon. Tip: Right-click on mini app icon to access context menu.",
"TidgiMiniWindow": "Attach to TidGi mini window",
"TidgiMiniWindowShowSidebar": "Attach To TidGi Mini Window Show Sidebar",
"TidgiMiniWindowShowSidebarTip": "Generally, TidGi mini window is only used to quickly view the current workspace, so the default synchronization with the main window workspace, do not need a sidebar, the default hidden sidebar.",
"TidgiMiniWindowTip": "Make a small TidGi popup window that pop when you click system tray mini icon. Tip: Right-click on mini app icon to access context menu.",
"AttachToTaskbar": "Attach to taskbar",
"AttachToTaskbarShowSidebar": "Attach To Taskbar Show Sidebar",
"ChooseLanguage": "Choose Language 选择语言",
@ -362,8 +362,16 @@
"ItIsWorking": "It is working!",
"Languages": "Lang/语言",
"LightTheme": "Light Theme",
"MenubarAlwaysOnTop": "Menubar Always on top",
"MenubarAlwaysOnTopDetail": "Keep TidGis Menubar always on top of other windows, and will not be covered by other windows",
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window Always on top",
"TidgiMiniWindowAlwaysOnTopDetail": "Keep TidGi's Mini Window always on top of other windows, and will not be covered by other windows",
"TidgiMiniWindowFixedWorkspace": "Select workspace for fixed TidGi Mini Window",
"TidgiMiniWindowShortcutKey": "Set shortcut key to toggle TidGi Mini Window",
"TidgiMiniWindowShortcutKeyHelperText": "Set a shortcut key to quickly open or close TidGi Mini Window",
"TidgiMiniWindowShortcutKeyPlaceholder": "e.g.: Ctrl+Shift+D",
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "TidGi Mini Window syncs with main window workspace",
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "When checked, TidGi Mini Window will display the same workspace content as main window",
"TidgiMiniWindowShowTitleBar": "Show title bar on TidGi Mini Window",
"TidgiMiniWindowShowTitleBarDetail": "Show draggable title bar on TidGi Mini Window",
"Miscellaneous": "Miscellaneous",
"MoreWorkspaceSyncSettings": "More Workspace Sync Settings",
"MoreWorkspaceSyncSettingsDescription": "Please right-click the workspace icon, open its workspace setting by click on \"Edit Workspace\" context menu item, and configure its independent synchronization settings in it.",
@ -430,7 +438,7 @@
"TestNotificationDescription": "<0>If notifications dont show up, make sure you enable notifications in<1>macOS Preferences → Notifications → TidGi</1>.</0>",
"Theme": "Theme",
"TiddlyWiki": "TiddlyWiki",
"ToggleMenuBar": "Toggle Menu Bar",
"ToggleTidgiMiniWindow": "Toggle TidGi Mini Window",
"Token": "Git credentials",
"TokenDescription": "The credentials used to authenticate to the Git server so you can securely synchronize content. Can be obtained by logging in to storage services (e.g., Github), or manually obtain \"personal access token\" and filled in here.",
"Translatium": "Translatium",

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "Ouvrir la palette de commandes",
"OpenLinkInBrowser": "Ouvrir le lien dans le navigateur",
"OpenTidGi": "Ouvrir TidGi",
"OpenTidGiMenuBar": "Ouvrir la barre de menu TidGi",
"OpenTidGiMiniWindow": "Ouvrir la mini-fenêtre TidGi",
"OpenWorkspaceInNewWindow": "Ouvrir l'espace de travail dans une nouvelle fenêtre",
"Paste": "Coller",
"Preferences": "Préférences...",
@ -309,7 +309,7 @@
"SelectNextWorkspace": "Sélectionner l'espace de travail suivant",
"SelectPreviousWorkspace": "Sélectionner l'espace de travail précédent",
"TidGi": "TidGi",
"TidGiMenuBar": "Barre de menu TidGi",
"TidGiMiniWindow": "Mini-fenêtre TidGi",
"View": "Vue",
"Wiki": "Wiki",
"Window": "Fenêtre",
@ -324,10 +324,10 @@
"AlwaysOnTopDetail": "Garder la fenêtre principale de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
"AntiAntiLeech": "Certains sites web ont une protection anti-leech, empêchant certaines images d'être affichées sur votre wiki, nous simulons un en-tête de requête qui ressemble à la visite de ce site web pour contourner cette protection.",
"AskDownloadLocation": "Demander où enregistrer chaque fichier avant de télécharger",
"AttachToMenuBar": "Attacher à la barre de menu",
"AttachToMenuBarShowSidebar": "Attacher à la barre de menu Afficher la barre latérale",
"AttachToMenuBarShowSidebarTip": "En général, la petite fenêtre TidGi est uniquement utilisée pour visualiser rapidement l'espace de travail actuel, donc la synchronisation avec l'espace de travail de la fenêtre principale n'est pas nécessaire, la barre latérale est masquée par défaut.",
"AttachToMenuBarTip": "Créer une petite fenêtre contextuelle TidGi qui apparaît lorsque vous cliquez sur l'icône mini de la barre d'application. Astuce : Cliquez avec le bouton droit sur l'icône mini de l'application pour accéder au menu contextuel.",
"TidgiMiniWindow": "Attacher à la mini-fenêtre TidGi",
"TidgiMiniWindowShowSidebar": "Attacher à la mini-fenêtre TidGi Afficher la barre latérale",
"TidgiMiniWindowShowSidebarTip": "En général, la petite fenêtre TidGi est uniquement utilisée pour visualiser rapidement l'espace de travail actuel, donc la synchronisation avec l'espace de travail de la fenêtre principale n'est pas nécessaire, la barre latérale est masquée par défaut.",
"TidgiMiniWindowTip": "Créer une petite fenêtre contextuelle TidGi qui apparaît lorsque vous cliquez sur l'icône mini de la barre d'application. Astuce : Cliquez avec le bouton droit sur l'icône mini de l'application pour accéder au menu contextuel.",
"AttachToTaskbar": "Attacher à la barre des tâches",
"AttachToTaskbarShowSidebar": "Attacher à la barre des tâches Afficher la barre latérale",
"ChooseLanguage": "Choisir la langue 选择语言",
@ -349,8 +349,8 @@
"General": "UI & Interact",
"HibernateAllUnusedWorkspaces": "Mettre en veille les espaces de travail inutilisés au lancement de l'application",
"HibernateAllUnusedWorkspacesDescription": "Mettre en veille tous les espaces de travail au lancement, sauf le dernier espace de travail actif.",
"HideMenuBar": "Masquer la barre de menu",
"HideMenuBarDetail": "Masquer la barre de menu sauf si Alt+M est pressé.",
"HideTidgiMiniWindow": "Masquer la mini-fenêtre TidGi",
"HideTidgiMiniWindowDetail": "Masquer la mini-fenêtre TidGi sauf si Alt+M est pressé.",
"HideSideBar": "Masquer la barre latérale",
"HideSideBarIconDetail": "Masquer l'icône et n'afficher que le nom de l'espace de travail pour rendre la liste des espaces de travail plus compacte",
"HideTitleBar": "Masquer la barre de titre",
@ -360,8 +360,8 @@
"ItIsWorking": "Ça fonctionne !",
"Languages": "Lang/语言",
"LightTheme": "Thème clair",
"MenubarAlwaysOnTop": "Barre de menu toujours au-dessus",
"MenubarAlwaysOnTopDetail": "Garder la barre de menu de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
"TidgiMiniWindowAlwaysOnTop": "TidGi Mini Window toujours au-dessus",
"TidgiMiniWindowAlwaysOnTopDetail": "Garder la Mini Window de TidGi toujours au-dessus des autres fenêtres, et ne sera pas couverte par d'autres fenêtres",
"Miscellaneous": "Divers",
"MoreWorkspaceSyncSettings": "Plus de paramètres de synchronisation de l'espace de travail",
"MoreWorkspaceSyncSettingsDescription": "Veuillez cliquer avec le bouton droit sur l'icône de l'espace de travail, ouvrir ses paramètres d'espace de travail en cliquant sur l'élément de menu contextuel \"Modifier l'espace de travail\", et configurer ses paramètres de synchronisation indépendants.",
@ -414,7 +414,7 @@
"TestNotificationDescription": "<0>Si les notifications ne s'affichent pas, assurez-vous d'activer les notifications dans<1>Préférences macOS → Notifications → TidGi</1>.</0>",
"Theme": "Thème",
"TiddlyWiki": "TiddlyWiki",
"ToggleMenuBar": "Basculer la barre de menu",
"ToggleTidgiMiniWindow": "Basculer la mini-fenêtre TidGi",
"Token": "Informations d'identification Git",
"TokenDescription": "Les informations d'identification utilisées pour s'authentifier auprès du serveur Git afin de pouvoir synchroniser le contenu en toute sécurité. Peut être obtenu en se connectant à des services de stockage (par exemple, Github), ou en obtenant manuellement un \"jeton d'accès personnel\" et en le remplissant ici.",
"Translatium": "Translatium",

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "コマンドパレットを開く",
"OpenLinkInBrowser": "ブラウザでリンクを開く",
"OpenTidGi": "TidGiを開く",
"OpenTidGiMenuBar": "TidGiメニューバーを開く",
"OpenTidGiMiniWindow": "TidGiミニウィンドウを開く",
"OpenWorkspaceInNewWindow": "ワークスペースを新しいウィンドウで開く",
"Paste": "貼り付け",
"Preferences": "設定...",
@ -359,8 +359,8 @@
"ItIsWorking": "使いやすい!",
"Languages": "言語/ランゲージ",
"LightTheme": "明るい色のテーマ",
"MenubarAlwaysOnTop": "メニューバーの小ウィンドウを他のウィンドウの上に保持する",
"MenubarAlwaysOnTopDetail": "太記のメニューバーウィンドウを常に他のウィンドウの上に表示させ、他のウィンドウで覆われないようにします。",
"TidgiMiniWindowAlwaysOnTop": "太記小ウィンドウを他のウィンドウの上に保持する",
"TidgiMiniWindowAlwaysOnTopDetail": "太記の小ウィンドウを常に他のウィンドウの上に表示させ、他のウィンドウで覆われないようにします。",
"Miscellaneous": "その他の設定",
"MoreWorkspaceSyncSettings": "さらに多くのワークスペース同期設定",
"MoreWorkspaceSyncSettingsDescription": "ワークスペースアイコンを右クリックし、右クリックメニューから「ワークスペースの編集」を選択して、ワークスペース設定を開いてください。そこで各ワークスペースの同期設定を行います。",

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "Открыть палитру команд",
"OpenLinkInBrowser": "Открыть ссылку в браузере",
"OpenTidGi": "Открыть TidGi",
"OpenTidGiMenuBar": "Открыть меню TidGi",
"OpenTidGiMiniWindow": "Открыть мини-окно TidGi",
"OpenWorkspaceInNewWindow": "Открыть рабочее пространство в новом окне",
"Paste": "Вставить",
"Preferences": "Настройки...",
@ -309,7 +309,7 @@
"SelectNextWorkspace": "Выбрать следующее рабочее пространство",
"SelectPreviousWorkspace": "Выбрать предыдущее рабочее пространство",
"TidGi": "TidGi",
"TidGiMenuBar": "Слишком помню маленькое окно.",
"TidGiMiniWindow": "Мини-окно TidGi",
"View": "Просмотр",
"Wiki": "Wiki",
"Window": "Окно",
@ -359,8 +359,8 @@
"ItIsWorking": "Работает!",
"Languages": "Языки",
"LightTheme": "Светлая тема",
"MenubarAlwaysOnTop": "Меню всегда сверху",
"MenubarAlwaysOnTopDetail": "Детали меню всегда сверху",
"TidgiMiniWindowAlwaysOnTop": "TidGi мини-окно всегда сверху",
"TidgiMiniWindowAlwaysOnTopDetail": "Держать мини-окно TidGi всегда поверх других окон",
"Miscellaneous": "Разное",
"MoreWorkspaceSyncSettings": "Дополнительные настройки синхронизации рабочего пространства",
"MoreWorkspaceSyncSettingsDescription": "Описание дополнительных настроек синхронизации рабочего пространства",

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "打开搜索与命令面板",
"OpenLinkInBrowser": "在浏览器中打开链接",
"OpenTidGi": "打开太记",
"OpenTidGiMenuBar": "打开太记小窗口",
"OpenTidGiMiniWindow": "打开太记小窗口",
"OpenWorkspaceInNewWindow": "在新窗口中打开工作区",
"Paste": "粘贴",
"Preferences": "设置...",
@ -177,6 +177,7 @@
"SyncOnIntervalDescription": "开启后会根据全局设置里的时间间隔自动同步并且依然会在启动时自动同步点击按钮也可以手动同步。同步云端前会自动先把数据备份到本地Git。如果关闭则只有在应用程序打开时会有一次自动同步还有当用户通过点击知识库中的同步按钮手动触发同步。",
"SyncOnStartup": "启动时自动同步",
"SyncOnStartupDescription": "在应用冷启动时自动同步一次。",
"TiddlyWiki": "太微",
"TokenAuth": "凭证鉴权",
"TokenAuthAutoFillUserNameDescription": "此功能需要在全局设置或工作区设置里填写用户名,不然不会生效。若你未填,将自动在工作区设置里填一个默认值,你可自行修改。",
"TokenAuthCurrentHeader": "凭证鉴权当前请求头",
@ -188,7 +189,6 @@
"UploadOrSelectPathDescription": "点击上传按钮将文件提交给太记保管,也可以点击选择路径按钮从你保管的位置选取文件。",
"WikiRootTiddler": "知识库根条目",
"WikiRootTiddlerDescription": "知识库的根条目root-tiddler决定了系统的核心行为修改前请阅读官方文档来了解",
"TiddlyWiki": "太微",
"WikiRootTiddlerItems": {
}
},
@ -234,6 +234,14 @@
"Tags": {
}
},
"KeyboardShortcut": {
"Clear": "清除",
"HelpText": "按下任意键组合(如 Ctrl+Shift+A。单独的修饰键将被忽略。",
"None": "无",
"PressKeys": "请按键...",
"PressKeysPrompt": "请为{{feature}}按下快捷键组合",
"RegisterShortcut": "注册快捷键"
},
"LOG": {
"CommitBackupMessage": "使用太记桌面版备份",
"CommitMessage": "使用太记桌面版同步"
@ -309,7 +317,7 @@
"SelectNextWorkspace": "选择下一个工作区",
"SelectPreviousWorkspace": "选择前一个工作区",
"TidGi": "太记",
"TidGiMenuBar": "太记小窗",
"TidGiMiniWindow": "太记小窗",
"View": "查看",
"Wiki": "知识库",
"Window": "窗口",
@ -324,10 +332,10 @@
"AlwaysOnTopDetail": "让太记的主窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
"AntiAntiLeech": "有的网站做了防盗链,会阻止某些图片在你的知识库上显示,我们通过模拟访问该网站的请求头来绕过这种限制。",
"AskDownloadLocation": "下载前询问每个文件的保存位置",
"AttachToMenuBar": "附加到菜单栏",
"AttachToMenuBarShowSidebar": "附加到菜单栏的窗口包含侧边栏",
"AttachToMenuBarShowSidebarTip": "一般太记小窗仅用于快速查看当前工作区,所以默认与主窗口工作区同步,不需要侧边栏,默认隐藏侧边栏。",
"AttachToMenuBarTip": "创建一个点击菜单栏/任务栏图标会弹出的小太记窗口。提示:右键单击小图标以访问上下文菜单。",
"TidgiMiniWindow": "附加到太记小窗",
"TidgiMiniWindowShowSidebar": "太记小窗包含侧边栏",
"TidgiMiniWindowShowSidebarTip": "一般太记小窗仅用于快速查看当前工作区,所以默认与主窗口工作区同步,不需要侧边栏,默认隐藏侧边栏。",
"TidgiMiniWindowTip": "创建一个点击系统托盘图标会弹出的小太记窗口。提示:右键单击小图标以访问上下文菜单。",
"AttachToTaskbar": "附加到任务栏",
"AttachToTaskbarShowSidebar": "附加到任务栏的窗口包含侧边栏",
"ChooseLanguage": "选择语言 Choose Language",
@ -363,8 +371,16 @@
"ItIsWorking": "好使的!",
"Languages": "语言/Lang",
"LightTheme": "亮色主题",
"MenubarAlwaysOnTop": "保持菜单栏小窗口在其他窗口上方",
"MenubarAlwaysOnTopDetail": "让太记的菜单栏小窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
"TidgiMiniWindowAlwaysOnTop": "保持太记小窗口在其他窗口上方",
"TidgiMiniWindowAlwaysOnTopDetail": "让太记的小窗口永远保持在其它窗口上方,不会被其他窗口覆盖",
"TidgiMiniWindowFixedWorkspace": "为固定的太记小窗口选择工作区",
"TidgiMiniWindowShortcutKey": "设置快捷键来切换太记小窗口",
"TidgiMiniWindowShortcutKeyHelperText": "设置一个快捷键来快速打开或关闭太记小窗口",
"TidgiMiniWindowShortcutKeyPlaceholder": "例如Ctrl+Shift+D",
"TidgiMiniWindowSyncWorkspaceWithMainWindow": "小窗和主窗口展示同样的工作区",
"TidgiMiniWindowSyncWorkspaceWithMainWindowDetail": "勾选后,小窗将与主窗口同步显示相同的工作区内容",
"TidgiMiniWindowShowTitleBar": "小窗显示标题栏",
"TidgiMiniWindowShowTitleBarDetail": "在太记小窗口上显示可拖动的标题栏",
"Miscellaneous": "其他设置",
"MoreWorkspaceSyncSettings": "更多工作区同步设置",
"MoreWorkspaceSyncSettingsDescription": "请右键工作区图标,点右键菜单里的「编辑工作区」来打开工作区设置,在里面配各个工作区的同步设置。",
@ -406,6 +422,7 @@
"SearchEmbeddingStatusIdle": "未生成嵌入",
"SearchEmbeddingUpdate": "更新嵌入",
"SearchNoWorkspaces": "未找到工作区",
"SelectWorkspace": "选择工作区",
"ShareBrowsingData": "在工作区之间共享浏览器数据cookies、缓存等关闭后可以每个工作区登不同的第三方服务账号。",
"ShowSideBar": "显示侧边栏",
"ShowSideBarDetail": "侧边栏让你可以在工作区之间快速切换",
@ -431,7 +448,7 @@
"TestNotificationDescription": "<0>如果通知未显示,请确保在<1>macOS首选项 → 通知 → TidGi中启用通知</1></0>",
"Theme": "主题色",
"TiddlyWiki": "太微",
"ToggleMenuBar": "切换显隐菜单栏",
"ToggleTidgiMiniWindow": "切换太记小窗",
"Token": "Git身份凭证",
"TokenDescription": "用于向Git服务器验证身份并同步内容的凭证可通过登录在线存储服务如Github来取得也可以手动获取「Personal Access Token」后填到这里。",
"Translatium": "翻译素APP",
@ -465,6 +482,7 @@
"Add": "添加",
"Agent": "智能体",
"AreYouSure": "你确定要移除这个工作区吗?移除工作区会删除本应用中的工作区,但不会删除硬盘上的文件夹。如果你选择一并删除知识库文件夹,则所有内容都会被删除。",
"DedicatedWorkspace": "专用工作区",
"DefaultTiddlers": "默认条目",
"EditCurrentWorkspace": "配置当前工作区",
"EditWorkspace": "配置工作区",

View file

@ -92,7 +92,7 @@
"OpenCommandPalette": "打開搜索與命令面板",
"OpenLinkInBrowser": "在瀏覽器中打開連結",
"OpenTidGi": "打開太記",
"OpenTidGiMenuBar": "打開太記小窗口",
"OpenTidGiMiniWindow": "打開太記小窗口",
"OpenWorkspaceInNewWindow": "在新窗口中打開工作區",
"Paste": "黏貼",
"Preferences": "設置...",
@ -309,7 +309,7 @@
"SelectNextWorkspace": "選擇下一個工作區",
"SelectPreviousWorkspace": "選擇前一個工作區",
"TidGi": "太記",
"TidGiMenuBar": "太記小窗",
"TidGiMiniWindow": "太記小窗",
"View": "查看",
"Wiki": "知識庫",
"Window": "窗口",
@ -324,10 +324,10 @@
"AlwaysOnTopDetail": "讓太記的主窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
"AntiAntiLeech": "有的網站做了防盜鏈,會阻止某些圖片在你的知識庫上顯示,我們透過模擬訪問該網站的請求頭來繞過這種限制。",
"AskDownloadLocation": "下載前詢問每個文件的保存位置",
"AttachToMenuBar": "附加到選單欄",
"AttachToMenuBarShowSidebar": "附加到選單欄的窗口包含側邊欄",
"AttachToMenuBarShowSidebarTip": "一般太記小窗僅用於快速查看當前工作區,所以默認與主窗口工作區同步,不需要側邊欄,默認隱藏側邊欄。",
"AttachToMenuBarTip": "創建一個點擊選單欄/任務欄圖示會彈出的小太記窗口。提示:右鍵單擊小圖示以訪問上下文菜單。",
"TidgiMiniWindow": "附加到太記小窗",
"TidgiMiniWindowShowSidebar": "太記小窗包含側邊欄",
"TidgiMiniWindowShowSidebarTip": "一般太記小窗僅用於快速查看當前工作區,所以默認與主窗口工作區同步,不需要側邊欄,默認隱藏側邊欄。",
"TidgiMiniWindowTip": "創建一個點擊系統托盤圖示會彈出的小太記窗口。提示:右鍵單擊小圖示以訪問上下文菜單。",
"AttachToTaskbar": "附加到任務欄",
"AttachToTaskbarShowSidebar": "附加到任務欄的窗口包含側邊欄",
"ChooseLanguage": "選擇語言 Choose Language",
@ -363,8 +363,8 @@
"ItIsWorking": "好使的!",
"Languages": "語言/Lang",
"LightTheme": "亮色主題",
"MenubarAlwaysOnTop": "保持選單欄小窗口在其他窗口上方",
"MenubarAlwaysOnTopDetail": "讓太記的選單欄小窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
"TidgiMiniWindowAlwaysOnTop": "保持太記小窗口在其他窗口上方",
"TidgiMiniWindowAlwaysOnTopDetail": "讓太記的小窗口永遠保持在其它窗口上方,不會被其他窗口覆蓋",
"Miscellaneous": "其他設置",
"MoreWorkspaceSyncSettings": "更多工作區同步設定",
"MoreWorkspaceSyncSettingsDescription": "請右鍵工作區圖示,點右鍵菜單裡的「編輯工作區」來打開工作區設置,在裡面配各個工作區的同步設定。",
@ -431,7 +431,7 @@
"TestNotificationDescription": "<0>如果通知未顯示,請確保在<1>macOS首選項 → 通知 → TidGi中啟用通知</1></0>",
"Theme": "主題色",
"TiddlyWiki": "太微",
"ToggleMenuBar": "切換顯隱選單欄",
"ToggleTidgiMiniWindow": "切換太記小窗",
"Token": "Git身份憑證",
"TokenDescription": "用於向Git伺服器驗證身份並同步內容的憑證可透過登錄在線儲存服務如Github來取得也可以手動獲取「Personal Access Token」後填到這裡。",
"Translatium": "翻譯素APP",

View file

@ -1,7 +1,7 @@
{
"en": "English",
"zh-Hans": "汉",
"zh-Hant": "漢",
"zh-Hans": "汉",
"zh-Hant": "漢",
"ja": "日本語",
"fr": "Français",
"ru": "Русский"

View file

@ -4,7 +4,7 @@
"description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.",
"version": "0.12.4",
"license": "MPL 2.0",
"packageManager": "pnpm@10.13.1",
"packageManager": "pnpm@10.18.2",
"scripts": {
"start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development pnpm dlx tsx scripts/developmentMkdir.ts && pnpm run start:dev",
"start:dev": "cross-env NODE_ENV=development electron-forge start",
@ -18,7 +18,7 @@
"test": "pnpm run test:unit && pnpm run test:prepare-e2e && pnpm run test:e2e",
"test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron ./node_modules/vitest/vitest.mjs run",
"test:unit:coverage": "pnpm run test:unit --coverage",
"test:prepare-e2e": "pnpm run clean && pnpm run build:plugin && cross-env NODE_ENV=test DEBUG=electron-forge:* electron-forge package",
"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 -- ./userData-test ./wiki-test && cross-env NODE_ENV=test pnpm dlx tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js",
"make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make",
"make:analyze": "cross-env ANALYZE=true pnpm run make",
@ -65,7 +65,7 @@
"default-gateway": "6.0.3",
"dugite": "2.7.1",
"electron-dl": "^4.0.0",
"electron-ipc-cat": "2.0.1",
"electron-ipc-cat": "2.1.1",
"electron-settings": "5.0.0",
"electron-unhandled": "4.0.1",
"electron-window-state": "5.0.3",
@ -102,6 +102,7 @@
"rotating-file-stream": "^3.2.5",
"rxjs": "7.8.2",
"semver": "7.7.2",
"serialize-error": "^12.0.0",
"simplebar": "6.3.1",
"simplebar-react": "3.3.0",
"source-map-support": "0.5.21",
@ -122,19 +123,19 @@
"zx": "8.5.5"
},
"optionalDependencies": {
"@electron-forge/maker-deb": "7.8.1",
"@electron-forge/maker-flatpak": "7.8.1",
"@electron-forge/maker-rpm": "7.8.1",
"@electron-forge/maker-snap": "7.8.1",
"@electron-forge/maker-squirrel": "7.8.1",
"@electron-forge/maker-zip": "7.8.1",
"@reforged/maker-appimage": "^5.0.0"
"@electron-forge/maker-deb": "7.10.2",
"@electron-forge/maker-flatpak": "7.10.2",
"@electron-forge/maker-rpm": "7.10.2",
"@electron-forge/maker-snap": "7.10.2",
"@electron-forge/maker-squirrel": "7.10.2",
"@electron-forge/maker-zip": "7.10.2",
"@reforged/maker-appimage": "5.1.0"
},
"devDependencies": {
"@cucumber/cucumber": "^11.2.0",
"@electron-forge/cli": "7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "7.8.1",
"@electron-forge/plugin-vite": "^7.9.0",
"@electron-forge/cli": "7.10.2",
"@electron-forge/plugin-auto-unpack-natives": "7.10.2",
"@electron-forge/plugin-vite": "7.10.2",
"@electron/rebuild": "^4.0.1",
"@fetsorn/vite-node-worker": "^1.0.1",
"@testing-library/jest-dom": "^6.6.3",

738
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,212 @@
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from '@mui/material';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface KeyboardShortcutRegisterProps {
/** Current shortcut key value */
value: string;
/** Callback function when shortcut key changes */
onChange: (value: string) => void;
/** Feature name, displayed on the button */
label: string;
/** Button variant */
variant?: 'text' | 'outlined' | 'contained';
/** Button color */
color?: 'inherit' | 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning';
/** Disabled state */
disabled?: boolean;
}
/**
* Keyboard shortcut register component
* Provides a button that opens a modal dialog when clicked, allowing users to set shortcuts via keystrokes
*/
export const KeyboardShortcutRegister: React.FC<KeyboardShortcutRegisterProps> = ({
value,
onChange,
label,
variant = 'outlined',
color = 'primary',
disabled = false,
}) => {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false);
const [currentKeyCombo, setCurrentKeyCombo] = useState(value);
/**
* Format shortcut text for display
*/
const formatShortcutText = useCallback((shortcut: string): string => {
if (!shortcut) return t('KeyboardShortcut.None');
return shortcut;
}, [t]);
/**
* Handle keyboard events to update current shortcut combination
*/
const handleKeyDown = useCallback(async (event: KeyboardEvent) => {
// Handle special keys for dialog control
if (event.key === 'Escape') {
event.preventDefault();
setDialogOpen(false);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
onChange(currentKeyCombo);
setDialogOpen(false);
return;
}
// Prevent default behavior to avoid page scrolling
event.preventDefault();
// Get the currently pressed key
const key = event.key;
// Ignore modifier keys pressed alone
if (['Shift', 'Control', 'Alt', 'Meta', 'OS'].includes(key)) {
return;
}
// Build the shortcut combination
const combo: string[] = [];
try {
if (event.ctrlKey || event.metaKey) {
const platform = await window.service.context.get('platform');
const modifier = platform === 'darwin' ? 'Cmd' : 'Ctrl';
combo.push(modifier);
}
if (event.altKey) {
combo.push('Alt');
}
if (event.shiftKey) {
combo.push('Shift');
}
// Add the main key
combo.push(key);
// Update current shortcut combination
const newCombo = combo.join('+');
setCurrentKeyCombo(newCombo);
} catch {
// Handle error silently
}
}, [onChange, currentKeyCombo]);
/**
* Open the dialog
*/
const handleOpenDialog = useCallback(() => {
setCurrentKeyCombo(value);
setDialogOpen(true);
}, [value]);
/**
* Close the dialog
*/
const handleCloseDialog = useCallback(() => {
setDialogOpen(false);
}, []);
/**
* Confirm shortcut key setting
*/
const handleConfirm = useCallback(() => {
onChange(currentKeyCombo);
setDialogOpen(false);
}, [onChange, currentKeyCombo]);
/**
* Clear shortcut key
*/
const handleClear = useCallback(() => {
setCurrentKeyCombo('');
}, []);
/**
* Add or remove keyboard event listeners when dialog opens or closes
*/
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
void handleKeyDown(event);
};
if (dialogOpen) {
document.addEventListener('keydown', keyDownHandler);
} else {
document.removeEventListener('keydown', keyDownHandler);
}
return () => {
document.removeEventListener('keydown', keyDownHandler);
};
}, [dialogOpen, handleKeyDown]);
return (
<>
<Button
variant={variant}
color={color}
onClick={handleOpenDialog}
disabled={disabled}
fullWidth
data-testid='shortcut-register-button'
>
{label}: {formatShortcutText(value)}
</Button>
<Dialog
open={dialogOpen}
onClose={handleCloseDialog}
maxWidth='sm'
fullWidth
data-testid='shortcut-dialog'
>
<DialogTitle>{t('KeyboardShortcut.RegisterShortcut')}</DialogTitle>
<DialogContent data-testid='shortcut-dialog-content'>
<Typography variant='body1' gutterBottom>
{t('KeyboardShortcut.PressKeysPrompt', { feature: label })}
</Typography>
<Box
data-testid='shortcut-display'
sx={{
padding: 2,
border: 1,
borderColor: 'divider',
borderRadius: 1,
marginBottom: 2,
textAlign: 'center',
backgroundColor: 'background.paper',
}}
>
<Typography variant='h5' component='div'>
{currentKeyCombo || t('KeyboardShortcut.PressKeys')}
</Typography>
</Box>
<Typography variant='caption' color='textSecondary'>
{t('KeyboardShortcut.HelpText')}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleClear} color='inherit' data-testid='shortcut-clear-button'>
{t('KeyboardShortcut.Clear')}
</Button>
<Button onClick={handleCloseDialog} data-testid='shortcut-cancel-button'>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm} variant='contained' color={color} data-testid='shortcut-confirm-button'>
{t('Confirm')}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View file

@ -11,7 +11,7 @@ export function useAuth(storageService: SupportedStorageServices): [() => Promis
await window.service.auth.set(`${storageService}-token`, '');
// await window.service.window.clearStorageData();
} catch (error) {
void window.service.native.log('error', 'TokenForm: auth operation failed', { function: 'useAuth', error: String(error) });
void window.service.native.log('error', 'TokenForm: auth operation failed', { function: 'useAuth', error });
}
}, [storageService]);
@ -66,7 +66,7 @@ export function useGetGithubUserInfoOnLoad(): void {
await window.service.auth.setUserInfos(userInfo);
}
} catch (error) {
void window.service.native.log('error', 'TokenForm: get github user info failed', { function: 'useGetGithubUserInfoOnLoad', error: String(error) });
void window.service.native.log('error', 'TokenForm: get github user info failed', { function: 'useGetGithubUserInfoOnLoad', error });
}
});
}, []);

View file

@ -0,0 +1,507 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { ThemeProvider } from '@mui/material/styles';
import { lightTheme } from '@services/theme/defaultTheme';
import { KeyboardShortcutRegister } from '../KeyboardShortcutRegister';
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ThemeProvider theme={lightTheme}>
{children}
</ThemeProvider>
);
describe('KeyboardShortcutRegister Component', () => {
let mockOnChange: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockOnChange = vi.fn();
// Mock window.service.context.get
Object.defineProperty(window.service.context, 'get', {
value: vi.fn().mockImplementation((key: string) => {
if (key === 'platform') {
// Return platform based on process.platform for testing
return Promise.resolve(process.platform === 'darwin' ? 'darwin' : 'win32');
}
return Promise.resolve('win32');
}),
writable: true,
});
});
const renderComponent = (overrides: {
value?: string;
onChange?: (value: string) => void;
label?: string;
disabled?: boolean;
} = {}) => {
const defaultProps = {
value: '',
onChange: mockOnChange,
label: 'Test Shortcut',
disabled: false,
...overrides,
};
return render(
<TestWrapper>
<KeyboardShortcutRegister {...defaultProps} />
</TestWrapper>,
);
};
describe('Initial state and rendering', () => {
it('should display button with label and current shortcut value', () => {
renderComponent({
value: 'Ctrl+Shift+T',
label: 'Toggle Window',
});
const button = screen.getByTestId('shortcut-register-button');
expect(button).toBeInTheDocument();
expect(button).toHaveTextContent('Toggle Window');
expect(button).toHaveTextContent('Ctrl+Shift+T');
});
it('should display "None" when no shortcut is set', () => {
renderComponent({
value: '',
label: 'Toggle Window',
});
const button = screen.getByTestId('shortcut-register-button');
expect(button).toHaveTextContent('KeyboardShortcut.None');
});
it('should respect disabled state', () => {
renderComponent({
disabled: true,
});
const button = screen.getByTestId('shortcut-register-button');
expect(button).toBeDisabled();
});
it('should not show dialog initially', () => {
renderComponent();
expect(screen.queryByTestId('shortcut-dialog')).not.toBeInTheDocument();
});
});
describe('Dialog interaction', () => {
it('should open dialog when button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
expect(screen.getByText('KeyboardShortcut.RegisterShortcut')).toBeInTheDocument();
});
it('should display current shortcut in dialog initially', async () => {
const user = userEvent.setup();
renderComponent({
value: 'Ctrl+A',
});
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+A');
});
});
it('should close dialog when cancel button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
// Press ESC to close dialog (MUI Dialog default behavior)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
await waitFor(() => {
expect(screen.queryByTestId('shortcut-dialog')).not.toBeInTheDocument();
}, { timeout: 500 });
});
});
describe('Keyboard shortcut capture', () => {
it('should capture Ctrl key combination', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
// Simulate keyboard event with Ctrl+Shift+T
fireEvent.keyDown(dialogContent, {
key: 'T',
ctrlKey: true,
shiftKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+Shift+T');
});
});
it('should capture Cmd key combination on macOS', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true,
configurable: true,
});
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
// Simulate Cmd+K key press on document
fireEvent.keyDown(document, {
key: 'K',
metaKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Cmd+K');
});
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
configurable: true,
});
});
it('should capture Alt key combination', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
fireEvent.keyDown(dialogContent, {
key: 'F4',
altKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Alt+F4');
});
});
it('should ignore modifier keys pressed alone', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
const display = screen.getByTestId('shortcut-display');
// Should show initial "Press keys" message
expect(display).toHaveTextContent('KeyboardShortcut.PressKeys');
const modifierKeys = ['Shift', 'Control', 'Alt', 'Meta'];
for (const key of modifierKeys) {
fireEvent.keyDown(dialogContent, {
key,
bubbles: true,
});
}
// Should still show "Press keys" message
expect(display).toHaveTextContent('KeyboardShortcut.PressKeys');
});
it('should update display when keys are pressed', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
// Press first combination
fireEvent.keyDown(dialogContent, {
key: 'A',
ctrlKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+A');
});
// Press second combination - should replace
fireEvent.keyDown(dialogContent, {
key: 'B',
ctrlKey: true,
shiftKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+Shift+B');
});
});
});
describe('Clear functionality', () => {
it('should clear current shortcut when clear button is clicked', async () => {
const user = userEvent.setup();
renderComponent({
value: 'Ctrl+T',
});
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
// Initially should show the current value
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+T');
// Verify clear button exists and is enabled
const clearButton = screen.getByTestId('shortcut-clear-button');
expect(clearButton).toBeInTheDocument();
expect(clearButton).toBeEnabled();
});
it('should call onChange with empty string when cleared and confirmed', async () => {
const user = userEvent.setup();
renderComponent({
value: '', // Start with empty to test setting empty and confirming
});
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
// Don't set any keys, just press Enter to confirm empty
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('');
});
});
});
describe('Confirm functionality', () => {
it('should call onChange with new shortcut on confirm', async () => {
const user = userEvent.setup();
renderComponent({
value: '',
});
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
fireEvent.keyDown(dialogContent, {
key: 'N',
ctrlKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+N');
});
// Press Enter to confirm
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(mockOnChange).toHaveBeenCalledWith('Ctrl+N');
});
});
it('should not call onChange when cancel is clicked', async () => {
const user = userEvent.setup();
renderComponent({
value: 'Ctrl+A',
});
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
fireEvent.keyDown(dialogContent, {
key: 'B',
ctrlKey: true,
bubbles: true,
});
await waitFor(() => {
const display = screen.getByTestId('shortcut-display');
expect(display).toHaveTextContent('Ctrl+B');
});
// Press ESC to cancel without saving
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
await waitFor(() => {
expect(mockOnChange).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.queryByTestId('shortcut-dialog')).not.toBeInTheDocument();
}, { timeout: 500 });
});
it('should close dialog after confirm', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
const dialogContent = screen.getByTestId('shortcut-dialog-content');
fireEvent.keyDown(dialogContent, {
key: 'T',
ctrlKey: true,
bubbles: true,
});
// Press Enter to confirm and close dialog
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(screen.queryByTestId('shortcut-dialog')).not.toBeInTheDocument();
}, { timeout: 500 });
});
});
describe('Help text', () => {
it('should display help text in dialog', async () => {
const user = userEvent.setup();
renderComponent();
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByText('KeyboardShortcut.HelpText')).toBeInTheDocument();
});
});
});
describe('Props validation', () => {
it('should use custom label', () => {
renderComponent({ label: 'Custom Label' });
const button = screen.getByTestId('shortcut-register-button');
expect(button).toHaveTextContent('Custom Label');
});
it('should handle onChange callback', async () => {
const customOnChange = vi.fn();
const user = userEvent.setup();
renderComponent({ onChange: customOnChange });
const button = screen.getByTestId('shortcut-register-button');
await user.click(button);
await waitFor(() => {
expect(screen.getByTestId('shortcut-dialog')).toBeInTheDocument();
});
// Simulate Ctrl+X key press on document
fireEvent.keyDown(document, {
key: 'X',
ctrlKey: true,
bubbles: true,
});
// Wait for the key combination to be processed
await waitFor(() => {
expect(screen.getByText('Ctrl+X')).toBeInTheDocument();
});
// Press Enter to confirm
fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' });
await waitFor(() => {
expect(customOnChange).toHaveBeenCalledWith('Ctrl+X');
});
});
});
});

View file

@ -24,14 +24,15 @@ export const sourcePath = isPackaged
? path.resolve(process.resourcesPath, '..') // Packaged: go up from resources/ to app root
: path.resolve(__dirname, '..', '..'); // Dev/Unit test: from src/constants to project root
// Build resources (only used in dev/test)
export const buildResourcePath = path.resolve(sourcePath, '..', 'build-resources');
// In dev the `sourcePath` already points to project root, so join directly to `build-resources`.
export const buildResourcePath = path.resolve(sourcePath, 'build-resources');
export const developmentImageFolderPath = path.resolve(sourcePath, 'images');
// Menubar icon
const menuBarIconFileName = isMac ? 'menubarTemplate@2x.png' : 'menubar@2x.png';
export const MENUBAR_ICON_PATH = isPackaged
? path.resolve(process.resourcesPath, menuBarIconFileName) // Packaged: resources/icon
: path.resolve(buildResourcePath, menuBarIconFileName); // Dev/Unit test: build-resources/icon
// TidGi Mini Window icon
const tidgiMiniWindowIconFileName = isMac ? 'tidgiMiniWindowTemplate@2x.png' : 'tidgiMiniWindow@2x.png';
export const TIDGI_MINI_WINDOW_ICON_PATH = isPackaged
? path.resolve(process.resourcesPath, tidgiMiniWindowIconFileName) // Packaged: resources/<icon>
: path.resolve(buildResourcePath, tidgiMiniWindowIconFileName); // Dev/Unit test: <project-root>/build-resources/<icon>
// System paths
export const CHROME_ERROR_PATH = 'chrome-error://chromewebdata/';

View file

@ -0,0 +1,121 @@
/**
* Test-only keyboard shortcut fallback for E2E testing environments.
*
* TECHNICAL LIMITATION EXPLANATION:
* E2E testing frameworks (like Playwright, Puppeteer, or Selenium) cannot simulate
* system-level global keyboard shortcuts because:
* 1. Operating systems restrict access to global hotkeys for security reasons
* 2. Browser sandboxing prevents web content from intercepting OS-level key events
* 3. Test automation tools operate within browser context, not at OS level
* 4. Global shortcuts are typically handled by native applications outside browser scope
*
* WORKAROUND SOLUTION:
* This fallback listens to document-level keydown events in the renderer process
* and manually routes matching key combinations to their corresponding service methods.
* This approach only works during testing when the application window has focus,
* but allows E2E tests to verify keyboard shortcut functionality without requiring
* actual OS-level global hotkey simulation.
*
* GENERIC DESIGN:
* Unlike hardcoding specific shortcuts, this system dynamically handles ALL registered
* keyboard shortcuts by parsing the "ServiceName.methodName" format and calling the
* appropriate service method through the window.service API.
*
* In production, global shortcuts are properly handled by the main process via
* NativeService.registerKeyboardShortcut using Electron's globalShortcut API.
*/
export function initTestKeyboardShortcutFallback(): () => void {
const isTestEnvironment = process.env.NODE_ENV === 'test';
void window.service.native.log('debug', 'Renderer(Test): initTestKeyboardShortcutFallback called', {
isTestEnvironment,
nodeEnv: process.env.NODE_ENV,
});
if (!isTestEnvironment) return () => {};
let allShortcuts: Record<string, string> = {};
let platform = 'win32';
// Load platform and all current shortcut mappings
void (async () => {
platform = await window.service.context.get('platform').catch(() => platform);
void window.service.native.log('debug', 'Renderer(Test): Platform detected', { platform });
allShortcuts = await window.service.native.getKeyboardShortcuts().catch(() => ({}));
void window.service.native.log('debug', 'Renderer(Test): Loaded all keyboard shortcuts', {
shortcuts: allShortcuts,
shortcutCount: Object.keys(allShortcuts).length,
});
})();
// Subscribe to preference changes to keep all shortcuts up to date
const subscription = window.observables.preference?.preference$?.subscribe?.((pref: unknown) => {
const p = pref as { keyboardShortcuts?: Record<string, string> } | undefined;
if (p?.keyboardShortcuts) {
allShortcuts = { ...p.keyboardShortcuts };
void window.service.native.log('debug', 'Renderer(Test): Updated shortcuts from preferences', {
shortcuts: allShortcuts,
});
}
});
const formatComboFromEvent = (event: KeyboardEvent): string => {
const combo: string[] = [];
const isMac = platform === 'darwin';
// On macOS, Cmd (metaKey) and Ctrl are distinct and both may be used.
// On other platforms, only Ctrl is used as the primary modifier here.
if (isMac) {
if (event.ctrlKey) combo.push('Ctrl');
if (event.metaKey) combo.push('Cmd');
} else {
if (event.ctrlKey) combo.push('Ctrl');
}
if (event.altKey) combo.push('Alt');
if (event.shiftKey) combo.push('Shift');
combo.push(event.key);
return combo.join('+');
};
const handleKeyDown = (event: KeyboardEvent): void => {
const pressed = formatComboFromEvent(event);
void window.service.native.log('debug', 'Renderer(Test): Key pressed', {
pressed,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
key: event.key,
availableShortcuts: Object.keys(allShortcuts).length,
});
// Find matching shortcut key for the pressed combination
let matched = false;
for (const [key, shortcut] of Object.entries(allShortcuts)) {
if (shortcut && pressed === shortcut) {
event.preventDefault();
matched = true;
void window.service.native.log('debug', 'Renderer(Test): Shortcut matched', {
pressed,
key,
shortcut,
});
void window.service.native.executeShortcutCallback(key);
break; // Only execute the first match
}
}
if (!matched) {
void window.service.native.log('debug', 'Renderer(Test): No shortcut matched', {
pressed,
allShortcuts,
});
}
};
void window.service.native.log('debug', 'Renderer(Test): Adding keydown listener to document');
document.addEventListener('keydown', handleKeyDown);
return () => {
void window.service.native.log('debug', 'Renderer(Test): Cleanup - removing keydown listener');
document.removeEventListener('keydown', handleKeyDown);
subscription?.unsubscribe?.();
};
}

View file

@ -24,6 +24,7 @@ import type { IDeepLinkService } from '@services/deepLink/interface';
import type { IExternalAPIService } from '@services/externalAPI/interface';
import type { IGitService } from '@services/git/interface';
import { initializeObservables } from '@services/libs/initializeObservables';
import type { INativeService } from '@services/native/interface';
import { reportErrorToGithubWithTemplates } from '@services/native/reportError';
import type { IThemeService } from '@services/theme/interface';
import type { IUpdaterService } from '@services/updater/interface';
@ -77,6 +78,7 @@ const externalAPIService = container.get<IExternalAPIService>(serviceIdentifier.
const gitService = container.get<IGitService>(serviceIdentifier.Git);
const themeService = container.get<IThemeService>(serviceIdentifier.ThemeService);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);
app.on('second-instance', async () => {
// see also src/helpers/singleInstance.ts
@ -116,21 +118,18 @@ const commonInit = async (): Promise<void> => {
externalAPIService.initialize(),
]);
// if user want a menubar, we create a new window for that
// if user want a tidgi mini window, we create a new window for that
// handle workspace name + tiddler name in uri https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app
deepLinkService.initializeDeepLink('tidgi');
const attachToMenubar = await preferenceService.get('attachToMenubar');
await Promise.all([
windowService.open(WindowNames.main),
attachToMenubar ? windowService.open(WindowNames.menuBar) : Promise.resolve(),
]);
await windowService.open(WindowNames.main);
// Initialize services that depend on windows being created
await Promise.all([
gitService.initialize(),
themeService.initialize(),
viewService.initialize(),
nativeService.initialize(),
]);
initializeObservables();
@ -140,6 +139,10 @@ const commonInit = async (): Promise<void> => {
await workspaceService.initializeDefaultPageWorkspaces();
// perform wiki startup and git sync for each workspace
await workspaceViewService.initializeAllWorkspaceView();
const tidgiMiniWindow = await preferenceService.get('tidgiMiniWindow');
if (tidgiMiniWindow) {
await windowService.openTidgiMiniWindow(true, false);
}
ipcMain.emit('request-update-pause-notifications-info');
// Fix webview is not resized automatically
@ -195,8 +198,7 @@ app.on('ready', async () => {
}
await updaterService.checkForUpdates();
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('Error during app ready handler', { function: "app.on('ready')", error: error_.message, stack: error_.stack ?? '' });
logger.error('Error during app ready handler', { function: "app.on('ready')", error });
}
});
app.on(MainChannel.windowAllClosed, async () => {
@ -222,7 +224,7 @@ app.on(
unhandled({
showDialog: !isDevelopmentOrTest,
logger: (error: Error) => {
logger.error(error.message + (error.stack ?? ''));
logger.error('unhandled', { error });
},
reportButton: (error) => {
reportErrorToGithubWithTemplates(error);

View file

@ -12,7 +12,7 @@ export function TabStoreInitializer() {
useEffect(() => {
// Initialize the tab store when the component mounts
initialize().catch((error: unknown) => {
void window.service.native.log('error', 'Failed to initialize tab store', { function: 'TabStoreInitializer.initialize', error: String(error) });
void window.service.native.log('error', 'Failed to initialize tab store', { function: 'TabStoreInitializer.initialize', error });
});
}, [initialize]);

View file

@ -49,7 +49,7 @@ export const agentActions = (
`Failed to fetch agent definition for ${agentWithoutMessages.agentDefId}`,
{
function: 'agentActions.processAgentData',
error: String(error),
error,
},
);
}
@ -88,9 +88,9 @@ export const agentActions = (
error: null,
loading: false,
});
} catch (error_) {
set({ error: error_ instanceof Error ? error_ : new Error(String(error_)) });
void window.service.native.log('error', 'Failed to load agent', { function: 'agentActions.loadAgent', error: String(error_) });
} catch (error) {
set({ error: error as Error });
void window.service.native.log('error', 'Failed to load agent', { function: 'agentActions.loadAgent', error });
} finally {
set({ loading: false });
}
@ -114,9 +114,9 @@ export const agentActions = (
});
return processedData.agent;
} catch (error_) {
set({ error: error_ instanceof Error ? error_ : new Error(String(error_)) });
void window.service.native.log('error', 'Failed to create agent', { function: 'agentActions.createAgent', error: String(error_) });
} catch (error) {
set({ error: error as Error });
void window.service.native.log('error', 'Failed to create agent', { function: 'agentActions.createAgent', error });
return null;
} finally {
set({ loading: false });
@ -147,9 +147,9 @@ export const agentActions = (
});
return processedData.agent;
} catch (error_) {
set({ error: error_ instanceof Error ? error_ : new Error(String(error_)) });
void window.service.native.log('error', 'Failed to update agent', { function: 'agentActions.updateAgent', error: String(error_) });
} catch (error) {
set({ error: error as Error });
void window.service.native.log('error', 'Failed to update agent', { function: 'agentActions.updateAgent', error });
return null;
} finally {
set({ loading: false });
@ -179,7 +179,7 @@ export const agentActions = (
} catch (error) {
const isInitialCall = !get().agent;
set({
error: error instanceof Error ? error : new Error(String(error)),
error: error as Error,
...(isInitialCall ? { loading: false } : {}),
});
}
@ -246,7 +246,7 @@ export const agentActions = (
`Error in message subscription for ${message.id}`,
{
function: 'agentActions.subscribeToUpdates.messageSubscription',
error: String(error_),
error: error_,
},
);
},
@ -281,10 +281,10 @@ export const agentActions = (
'Error in agent subscription',
{
function: 'agentActions.subscribeToUpdates.agentSubscription',
error: String(error_),
error: error_,
},
);
set({ error: error_ instanceof Error ? error_ : new Error(String(error_)) });
set({ error: error_ as Error });
},
});
@ -295,9 +295,9 @@ export const agentActions = (
subscription.unsubscribe();
});
};
} catch (error_) {
void window.service.native.log('error', 'Failed to subscribe to agent updates', { function: 'agentActions.subscribeToUpdates', error: String(error_) });
set({ error: error_ instanceof Error ? error_ : new Error(String(error_)) });
} catch (error) {
void window.service.native.log('error', 'Failed to subscribe to agent updates', { function: 'agentActions.subscribeToUpdates', error });
set({ error: error as Error });
return undefined;
}
},
@ -347,8 +347,8 @@ export const agentActions = (
try {
await window.service.agentInstance.cancelAgent(storeAgent.id);
} catch (error_) {
void window.service.native.log('error', 'Store: cancelAgent backend call failed', { function: 'agentActions.cancelAgent', agentId: storeAgent.id, error: String(error_) });
} catch (error) {
void window.service.native.log('error', 'Store: cancelAgent backend call failed', { function: 'agentActions.cancelAgent', agentId: storeAgent.id, error });
}
},
});

View file

@ -44,11 +44,11 @@ export const messageActions = (
set({ loading: true });
await window.service.agentInstance.sendMsgToAgent(storeAgent.id, { text: content });
} catch (error) {
set({ error: error instanceof Error ? error : new Error(String(error)) });
set({ error: error as Error });
void window.service.native.log(
'error',
'Failed to send message',
{ function: 'messageActions.sendMessage', error: String(error) },
{ function: 'messageActions.sendMessage', error },
);
} finally {
set({ loading: false });

View file

@ -148,7 +148,7 @@ export const previewActionsMiddleware: StateCreator<AgentChatStoreType, [], [],
previewCurrentStep: 'Error occurred',
previewCurrentPlugin: null,
});
reject(error instanceof Error ? error : new Error(String(error)));
reject(error as Error);
},
complete: () => {
completed = true;

View file

@ -265,7 +265,7 @@ export const basicActionsMiddleware: StateCreator<
activeTabId,
});
} catch (error) {
void window.service.native.log('error', 'Failed to add tab:', { error: String(error) });
void window.service.native.log('error', 'Failed to add tab:', { error });
console.error('Failed to add tab:', error);
}

View file

@ -65,7 +65,7 @@ export const ChatTitle: React.FC<ChatTitleProps> = ({ title, agent, updateAgent
await updateAgent({ name: newTitle });
}
} catch (error) {
void window.service?.native?.log?.('error', 'Failed to save agent title', { function: 'ChatTitle.handleSaveEdit', error: String(error) });
void window.service?.native?.log?.('error', 'Failed to save agent title', { function: 'ChatTitle.handleSaveEdit', error });
}
};

View file

@ -20,7 +20,7 @@ export function useLoadHelpPagesList(language = 'en-GB') {
const data = await fetch(source).then(async response => await (response.json() as Promise<typeof helpPages.default>));
return data.map(makeFallbackUrlsArray);
} catch (error) {
await window.service.native.log('error', `Help page Failed to load online source: ${source} ${(error as Error).message}`);
await window.service.native.log('error', `Help page Failed to load online source: ${source}`, { error });
return [];
}
}),
@ -28,7 +28,7 @@ export function useLoadHelpPagesList(language = 'en-GB') {
const newItems = responses.flat();
setItems(currentItems => uniqBy([...currentItems, ...newItems], 'url'));
} catch (error) {
void window.service.native.log('error', 'Failed to load online sources', { function: 'useLoadHelpPagesList.loadMoreItems', error: String(error) });
void window.service.native.log('error', 'Failed to load online sources', { function: 'useLoadHelpPagesList.loadMoreItems', error });
}
};

View file

@ -71,13 +71,13 @@ const IconButton = styled(IconButtonRaw)`
color: ${({ theme }) => theme.palette.action.active};
`;
const SidebarContainer = ({ children }: { children: React.ReactNode }): React.JSX.Element => {
const SidebarContainer = ({ children, ...props }: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => {
const platform = usePromiseValue(async () => await window.service.context.get('platform'));
// use native scroll bar on macOS
if (platform === 'darwin') {
return <SidebarRoot>{children}</SidebarRoot>;
return <SidebarRoot {...props}>{children}</SidebarRoot>;
}
return <SidebarWithStyle>{children}</SidebarWithStyle>;
return <SidebarWithStyle {...props}>{children}</SidebarWithStyle>;
};
export function SideBar(): React.JSX.Element {
@ -92,7 +92,7 @@ export function SideBar(): React.JSX.Element {
const { showSideBarText, showSideBarIcon } = preferences;
return (
<SidebarContainer>
<SidebarContainer data-testid='main-sidebar'>
<SidebarTop $titleBar={titleBar}>
{workspacesList === undefined
? <div>{t('Loading')}</div>

View file

@ -1,16 +1,17 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import { isWikiWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { MouseEvent, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { WorkspaceSelectorBase } from './WorkspaceSelectorBase';
import { useLocation } from 'wouter';
import { PageType } from '@/constants/pageTypes';
import { getBuildInPageIcon } from '@/pages/Main/WorkspaceIconAndSelector/getBuildInPageIcon';
import { getBuildInPageName } from '@/pages/Main/WorkspaceIconAndSelector/getBuildInPageName';
import { usePreferenceObservable } from '@services/preferences/hooks';
import { WindowNames } from '@services/windows/WindowProperties';
import { useLocation } from 'wouter';
import { getWorkspaceMenuTemplate } from '@services/workspaces/getWorkspaceMenuTemplate';
import { isWikiWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { WorkspaceSelectorBase } from './WorkspaceSelectorBase';
export interface ISortableItemProps {
index: number;
@ -22,6 +23,7 @@ export interface ISortableItemProps {
export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarTexts, showSideBarIcon }: ISortableItemProps): React.JSX.Element {
const { t } = useTranslation();
const { active, id, name, picturePath, pageType } = workspace;
const preference = usePreferenceObservable();
const isWiki = isWikiWorkspace(workspace);
const hibernated = isWiki ? workspace.hibernated : false;
@ -49,20 +51,41 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
}
return undefined;
}, [pageType]);
const isMiniWindow = window.meta().windowName === WindowNames.tidgiMiniWindow;
// Determine active state based on window type
const isActive = useMemo(() => {
if (isMiniWindow) {
// In mini window, compare with tidgiMiniWindowFixedWorkspaceId
return preference?.tidgiMiniWindowFixedWorkspaceId === id;
}
// In main window, use workspace's active state
return active;
}, [isMiniWindow, preference?.tidgiMiniWindowFixedWorkspaceId, id, active]);
const onWorkspaceClick = useCallback(async () => {
workspaceClickedLoadingSetter(true);
try {
if (workspace.pageType) {
// Handle special "add" workspace
// Special "add" workspace always opens add workspace window
if (workspace.pageType === PageType.add) {
await window.service.window.open(WindowNames.addWorkspace);
} else {
// Handle other page workspaces - navigate to the page and set as active workspace
return;
}
// In mini window, only update the fixed workspace ID
if (isMiniWindow) {
await window.service.preference.set('tidgiMiniWindowFixedWorkspaceId', id);
return;
}
// In main window, handle different workspace types
if (workspace.pageType) {
// Page workspaces (dashboard, etc.)
setLocation(`/${workspace.pageType}`);
await window.service.workspaceView.setActiveWorkspaceView(id);
}
} else {
// Handle regular wiki workspace
// Regular wiki workspace
setLocation(`/${PageType.wiki}/${id}/`);
await window.service.workspace.openWorkspaceTiddler(workspace);
}
@ -73,7 +96,7 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
} finally {
workspaceClickedLoadingSetter(false);
}
}, [id, setLocation, workspace]);
}, [id, setLocation, workspace, isMiniWindow]);
const onWorkspaceContextMenu = useCallback(
async (event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
@ -90,7 +113,7 @@ export function SortableWorkspaceSelectorButton({ index, workspace, showSidebarT
restarting={workspace.metadata.isRestarting}
showSideBarIcon={showSideBarIcon}
onClick={onWorkspaceClick}
active={active}
active={isActive}
id={id}
key={id}
pageType={pageType || undefined}

View file

@ -1,6 +1,10 @@
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useMemo } from 'react';
import { PageType } from '@/constants/pageTypes';
import { WindowNames } from '@services/windows/WindowProperties';
import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton';
@ -19,7 +23,17 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText,
}),
);
const workspaceIDs = workspacesList.map((workspace) => workspace.id);
const isMiniWindow = window.meta().windowName === WindowNames.tidgiMiniWindow;
// Filter out 'add' workspace in mini window
const filteredWorkspacesList = useMemo(() => {
if (isMiniWindow) {
return workspacesList.filter((workspace) => workspace.pageType !== PageType.add);
}
return workspacesList;
}, [isMiniWindow, workspacesList]);
const workspaceIDs = filteredWorkspacesList.map((workspace) => workspace.id);
return (
<DndContext
@ -47,7 +61,7 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText,
}}
>
<SortableContext items={workspaceIDs} strategy={verticalListSortingStrategy}>
{workspacesList
{filteredWorkspacesList
.sort((a, b) => a.order - b.order)
.map((workspace, index) => (
<SortableWorkspaceSelectorButton

View file

@ -190,6 +190,7 @@ export function WorkspaceSelectorBase({
$workspaceClickedLoading={workspaceClickedLoading}
onClick={workspaceClickedLoading ? () => {} : onClick}
data-testid={pageType ? `workspace-${pageType}` : `workspace-${id}`}
data-active={active ? 'true' : 'false'}
>
<Badge color='secondary' badgeContent={badgeCount} max={99}>
{icon}

View file

@ -14,7 +14,7 @@ import Main from '../index';
// Mock window.observables to provide realistic API behavior
const preferencesSubject = new BehaviorSubject({
sidebar: true,
sidebarOnMenubar: true,
tidgiMiniWindowShowSidebar: true,
showSideBarText: true,
showSideBarIcon: true,
});

View file

@ -57,19 +57,19 @@ const ContentRoot = styled('div')<{ $sidebar: boolean }>`
height: 100%;
`;
const windowName = window.meta().windowName;
export default function Main(): React.JSX.Element {
const { t } = useTranslation();
useInitialPage();
const windowName = window.meta().windowName;
const preferences = usePreferenceObservable();
const showSidebar = (windowName === WindowNames.menuBar ? preferences?.sidebarOnMenubar : preferences?.sidebar) ?? true;
const isTidgiMiniWindow = windowName === WindowNames.tidgiMiniWindow;
const showSidebar = (isTidgiMiniWindow ? preferences?.tidgiMiniWindowShowSidebar : preferences?.sidebar) ?? true;
return (
<OuterRoot>
<Helmet>
<title>{t('Menu.TidGi')}</title>
<title>{t('Menu.TidGi')}{isTidgiMiniWindow ? ` - ${t('Menu.TidGiMiniWindow')}` : ''}</title>
</Helmet>
<Root>
<Root data-windowName={windowName} data-showSidebar={showSidebar}>
{showSidebar && <SideBar />}
<ContentRoot $sidebar={showSidebar}>
<FindInPage />

View file

@ -1,31 +1,92 @@
import { PageType } from '@/constants/pageTypes';
import { usePreferenceObservable } from '@services/preferences/hooks';
import type { IPreferences } from '@services/preferences/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import { useWorkspacesListObservable } from '@services/workspaces/hooks';
import type { IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { useEffect, useRef } from 'react';
import { useLocation } from 'wouter';
/**
* Helper function to determine the target workspace for tidgi mini window based on preferences
*/
function getTidgiMiniWindowTargetWorkspace(
workspacesList: IWorkspaceWithMetadata[],
preferences: IPreferences,
): IWorkspaceWithMetadata | undefined {
const { tidgiMiniWindowSyncWorkspaceWithMainWindow, tidgiMiniWindowFixedWorkspaceId } = preferences;
// Default to sync (undefined means default to true, or explicitly true)
const shouldSync = tidgiMiniWindowSyncWorkspaceWithMainWindow === undefined || tidgiMiniWindowSyncWorkspaceWithMainWindow;
if (shouldSync) {
// Sync with main window - use active workspace
return workspacesList.find(workspace => workspace.active);
} else if (tidgiMiniWindowFixedWorkspaceId) {
// Use fixed workspace
return workspacesList.find(ws => ws.id === tidgiMiniWindowFixedWorkspaceId);
}
// No fixed workspace set - return undefined
return undefined;
}
export function useInitialPage() {
const [location, setLocation] = useLocation();
const workspacesList = useWorkspacesListObservable();
const preferences = usePreferenceObservable();
const hasInitialized = useRef(false);
const windowName = window.meta().windowName;
useEffect(() => {
// Only initialize once and only when at root
if (workspacesList && !hasInitialized.current && (location === '/' || location === '')) {
hasInitialized.current = true;
const activeWorkspace = workspacesList.find(workspace => workspace.active);
if (!activeWorkspace) {
let targetWorkspace = workspacesList.find(workspace => workspace.active);
// For tidgi mini window, determine which workspace to show based on preferences
if (windowName === WindowNames.tidgiMiniWindow && preferences) {
targetWorkspace = getTidgiMiniWindowTargetWorkspace(workspacesList, preferences) || targetWorkspace;
}
if (!targetWorkspace) {
// If there's no active workspace, navigate to root instead of guide.
// Root lets the UI stay neutral and prevents forcing the guide view.
setLocation(`/`);
} else if (activeWorkspace.pageType) {
} else if (targetWorkspace.pageType) {
// Don't navigate to add page, fallback to guide instead
if (activeWorkspace.pageType === PageType.add) {
if (targetWorkspace.pageType === PageType.add) {
setLocation(`/`);
} else {
setLocation(`/${activeWorkspace.pageType}`);
setLocation(`/${targetWorkspace.pageType}`);
}
} else {
setLocation(`/${PageType.wiki}/${activeWorkspace.id}/`);
setLocation(`/${PageType.wiki}/${targetWorkspace.id}/`);
}
}
}, [location, workspacesList, setLocation]);
}, [location, workspacesList, preferences, windowName, setLocation]);
// For tidgi mini window, also listen to active workspace changes
useEffect(() => {
if (windowName !== WindowNames.tidgiMiniWindow || !workspacesList || !preferences) {
return;
}
// Determine target workspace using helper function
const targetWorkspace = getTidgiMiniWindowTargetWorkspace(workspacesList, preferences);
if (!targetWorkspace) return;
// Navigate to the target workspace's page
let targetPath = '/';
if (targetWorkspace.pageType && targetWorkspace.pageType !== PageType.add) {
targetPath = `/${targetWorkspace.pageType}`;
} else if (!targetWorkspace.pageType) {
targetPath = `/${PageType.wiki}/${targetWorkspace.id}/`;
}
// Only navigate if we're not already on the target path
if (location !== targetPath) {
setLocation(targetPath);
}
}, [windowName, workspacesList, preferences, location, setLocation]);
}

View file

@ -34,7 +34,7 @@ export const remoteMethods = {
* @returns the index of the clicked button. -1 means unknown or errored. 0 if canceled (this can be configured by `cancelId` in the options).
*/
showElectronMessageBoxSync: (options: Electron.MessageBoxSyncOptions): number => {
// only main window can show message box, view window (browserView) can't. Currently didn't handle menubar window, hope it won't show message box...
// only main window can show message box, view window (browserView) can't. Currently didn't handle tidgi mini window, hope it won't show message box...
const clickedButtonIndex = ipcRenderer.sendSync(NativeChannel.showElectronMessageBoxSync, options, WindowNames.main) as unknown;
if (typeof clickedButtonIndex === 'number') {
return clickedButtonIndex;

View file

@ -1,6 +1,6 @@
import { ThemeProvider } from '@mui/material/styles';
import i18next from 'i18next';
import React, { JSX, StrictMode, Suspense } from 'react';
import React, { JSX, StrictMode, Suspense, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import { Router } from 'wouter';
@ -24,10 +24,12 @@ import { initRendererI18N } from './services/libs/i18n/renderer';
import 'electron-ipc-cat/fixContextIsolation';
import { useHashLocation } from 'wouter/use-hash-location';
import { RootStyle } from './components/RootStyle';
import { initTestKeyboardShortcutFallback } from './helpers/testKeyboardShortcuts';
import { Pages } from './windows';
function App(): JSX.Element {
const theme = useThemeObservable();
useEffect(() => initTestKeyboardShortcutFallback(), []);
return (
<StrictMode>

View file

@ -49,8 +49,7 @@ export class AgentBrowserService implements IAgentBrowserService {
this.tabRepository = this.dataSource.getRepository(AgentBrowserTabEntity);
logger.debug('Agent browser repository initialized');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize agent browser service: ${errorMessage}`);
logger.error('Failed to initialize agent browser service', { error });
throw error;
}
}
@ -220,7 +219,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to get tabs', {
function: 'getAllTabs',
error: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
@ -242,7 +241,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to get active tab', {
function: 'getActiveTabId',
error: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
@ -277,7 +276,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to set active tab', {
function: 'setActiveTab',
error: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
@ -321,7 +320,7 @@ export class AgentBrowserService implements IAgentBrowserService {
return savedTab;
} catch (error) {
logger.error(`Failed to add tab: ${error as Error}`);
logger.error('Failed to add tab', { error });
throw error;
}
}
@ -392,7 +391,7 @@ export class AgentBrowserService implements IAgentBrowserService {
await this.tabRepository!.save(existingTab);
await this.updateTabsObservable();
} catch (error) {
logger.error(`Failed to update tab: ${error as Error}`);
logger.error('Failed to update tab', { error });
throw error;
}
}
@ -458,7 +457,7 @@ export class AgentBrowserService implements IAgentBrowserService {
await this.reindexTabPositions();
await this.updateTabsObservable();
} catch (error) {
logger.error(`Failed to close tab: ${error instanceof Error ? `${error.message} ${error.stack}` : String(error)}`);
logger.error('Failed to close tab', { error });
throw error;
}
}
@ -560,7 +559,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to get closed tabs', {
function: 'getClosedTabs',
error: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
@ -606,7 +605,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to restore closed tab', {
function: 'restoreClosedTab',
error: error instanceof Error ? error.message : String(error),
error,
});
throw error;
}
@ -648,7 +647,7 @@ export class AgentBrowserService implements IAgentBrowserService {
} catch (error) {
logger.error('Failed to reindex tab positions', {
function: 'reindexTabPositions',
error: error instanceof Error ? error.message : String(error),
error,
});
}
}

View file

@ -241,13 +241,13 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
(tm).metadata = { ...(tm).metadata, isPersisted: true };
} catch (error1) {
logger.warn('Failed to persist pending tool result before error', {
error: error1 instanceof Error ? error1.message : String(error1),
error: error1,
messageId: tm.id,
});
}
}
} catch (error2) {
logger.warn('Failed to flush pending tool messages before persisting error', { error: error2 instanceof Error ? error2.message : String(error2) });
logger.warn('Failed to flush pending tool messages before persisting error', { error: error2 });
}
// Push an explicit error message into history for UI rendering
@ -269,7 +269,7 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
await agentInstanceService.saveUserMessage(errorMessageForHistory);
} catch (persistError) {
logger.warn('Failed to persist error message to database', {
error: persistError instanceof Error ? persistError.message : String(persistError),
error: persistError,
messageId: errorMessageForHistory.id,
agentId: context.agent.id,
});
@ -286,11 +286,8 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
});
currentRequestId = undefined;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Unexpected error during AI generation', {
error: errorMessage,
});
yield completed(`Unexpected error: ${errorMessage}`, context);
logger.error('Unexpected error during AI generation', { error });
yield completed(`Unexpected error: ${(error as Error).message}`, context);
} finally {
if (context.isCancelled() && currentRequestId) {
logger.debug('Cancelling AI request in finally block', {
@ -304,12 +301,11 @@ export async function* basicPromptConcatHandler(context: AgentHandlerContext) {
// Start processing with the initial user message
yield* processLLMCall();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Error processing prompt', {
method: 'basicPromptConcatHandler',
agentId: context.agent.id,
error: errorMessage,
error,
});
yield completed(`Error processing prompt: ${errorMessage}`, context);
yield completed(`Error processing prompt: ${(error as Error).message}`, context);
}
}

View file

@ -44,8 +44,7 @@ export class AgentInstanceService implements IAgentInstanceService {
await this.initializeDatabase();
await this.initializeHandlers();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize agent instance service: ${errorMessage}`);
logger.error('Failed to initialize agent instance service', { error });
throw error;
}
}
@ -58,8 +57,7 @@ export class AgentInstanceService implements IAgentInstanceService {
this.agentMessageRepository = this.dataSource.getRepository(AgentInstanceMessageEntity);
logger.debug('AgentInstance repositories initialized');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize agent instance database: ${errorMessage}`);
logger.error('Failed to initialize agent instance database', { error });
throw error;
}
}
@ -74,8 +72,7 @@ export class AgentInstanceService implements IAgentInstanceService {
this.registerBuiltinHandlers();
logger.debug('AgentInstance handlers registered');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to initialize agent instance handlers: ${errorMessage}`);
logger.error('Failed to initialize agent instance handlers', { error });
throw error;
}
}
@ -163,8 +160,7 @@ export class AgentInstanceService implements IAgentInstanceService {
modified: now,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to create agent instance: ${errorMessage}`);
logger.error('Failed to create agent instance', { error });
throw error;
}
}
@ -195,8 +191,7 @@ export class AgentInstanceService implements IAgentInstanceService {
messages,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to get agent instance: ${errorMessage}`);
logger.error('Failed to get agent instance', { error });
throw error;
}
}
@ -271,8 +266,7 @@ export class AgentInstanceService implements IAgentInstanceService {
return updatedAgent;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to update agent instance: ${errorMessage}`);
logger.error('Failed to update agent instance', { error });
throw error;
}
}
@ -292,8 +286,7 @@ export class AgentInstanceService implements IAgentInstanceService {
logger.info(`Deleted agent instance: ${agentId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to delete agent instance: ${errorMessage}`);
logger.error('Failed to delete agent instance', { error });
throw error;
}
}
@ -337,8 +330,7 @@ export class AgentInstanceService implements IAgentInstanceService {
return instances.map(entity => pick(entity, AGENT_INSTANCE_FIELDS));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to get agent instances: ${errorMessage}`);
logger.error('Failed to get agent instances', { error });
throw error;
}
}
@ -540,7 +532,7 @@ export class AgentInstanceService implements IAgentInstanceService {
}
}
} catch (error) {
logger.warn('Failed to propagate cancel status to message subscriptions', { function: 'cancelAgent', error: String(error) });
logger.warn('Failed to propagate cancel status to message subscriptions', { function: 'cancelAgent', error });
}
// Remove cancel token from map
@ -634,7 +626,7 @@ export class AgentInstanceService implements IAgentInstanceService {
}
}
}).catch((error: unknown) => {
logger.error('Failed to get initial status for message', { function: 'subscribeToAgentUpdates', error: String(error) });
logger.error('Failed to get initial status for message', { function: 'subscribeToAgentUpdates', error });
});
}
@ -649,7 +641,7 @@ export class AgentInstanceService implements IAgentInstanceService {
this.getAgent(agentId).then(agent => {
this.agentInstanceSubjects.get(agentId)?.next(agent);
}).catch((error: unknown) => {
logger.error('Failed to get initial agent data', { function: 'subscribeToAgentUpdates', error: String(error) });
logger.error('Failed to get initial agent data', { function: 'subscribeToAgentUpdates', error });
});
}
@ -700,8 +692,8 @@ export class AgentInstanceService implements IAgentInstanceService {
source: 'saveUserMessage',
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to save user message: ${errorMessage}`, {
logger.error('Failed to save user message', {
error,
messageId: userMessage.id,
agentId: userMessage.agentId,
});
@ -840,8 +832,7 @@ export class AgentInstanceService implements IAgentInstanceService {
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to update/create message content: ${errorMessage}`);
logger.error('Failed to update/create message content', { error });
}
},
debounceMs,
@ -891,7 +882,7 @@ export class AgentInstanceService implements IAgentInstanceService {
}
} catch (error) {
logger.error('Error in AgentInstanceService.concatPrompt', {
error: error instanceof Error ? error.message : String(error),
error,
promptDescriptionId: (promptDescription as AgentPromptDescription).id,
messagesCount: messages.length,
});
@ -915,7 +906,7 @@ export class AgentInstanceService implements IAgentInstanceService {
return { type: 'object', properties: {} };
} catch (error) {
logger.error('Error in AgentInstanceService.getHandlerConfigSchema', {
error: error instanceof Error ? error.message : String(error),
error,
handlerId,
});
throw error;

View file

@ -47,7 +47,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Message management plugin error in userMessageReceived', {
error: error instanceof Error ? error.message : String(error),
error,
messageId: context.messageId,
agentId: context.handlerContext.agent.id,
});
@ -79,7 +79,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Message management plugin error in agentStatusChanged', {
error: error instanceof Error ? error.message : String(error),
error,
agentId: context.handlerContext.agent.id,
status: context.status,
});
@ -119,7 +119,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true };
} catch (persistError) {
logger.warn('Failed to persist initial streaming AI message', {
error: persistError instanceof Error ? persistError.message : String(persistError),
error: persistError,
messageId: aiMessage.id,
});
}
@ -135,14 +135,14 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
agentInstanceService.debounceUpdateMessage(aiMessage, handlerContext.agent.id);
} catch (serviceError) {
logger.warn('Failed to update UI for streaming message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
error: serviceError,
messageId: aiMessage.id,
});
}
}
} catch (error) {
logger.error('Message management plugin error in responseUpdate', {
error: error instanceof Error ? error.message : String(error),
error,
});
} finally {
callback();
@ -194,7 +194,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
agentInstanceService.debounceUpdateMessage(aiMessage, handlerContext.agent.id);
} catch (serviceError) {
logger.warn('Failed to update UI for completed message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
error: serviceError,
messageId: aiMessage.id,
});
}
@ -208,7 +208,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Message management plugin error in responseComplete', {
error: error instanceof Error ? error.message : String(error),
error,
});
callback();
}
@ -245,7 +245,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
});
} catch (serviceError) {
logger.error('Failed to persist tool result message', {
error: serviceError instanceof Error ? serviceError.message : String(serviceError),
error: serviceError,
messageId: message.id,
});
}
@ -261,7 +261,7 @@ export const messageManagementPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Message management plugin error in toolExecuted', {
error: error instanceof Error ? error.message : String(error),
error,
});
callback();
}

View file

@ -168,7 +168,7 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Error in wiki operation tool list injection', {
error: error instanceof Error ? error.message : String(error),
error,
pluginId: pluginConfig.id,
});
callback();
@ -340,7 +340,7 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
toolResultMessage.metadata = { ...toolResultMessage.metadata, isPersisted: true };
} catch (persistError) {
logger.warn('Failed to persist tool result immediately in wikiOperationPlugin', {
error: persistError instanceof Error ? persistError.message : String(persistError),
error: persistError,
messageId: toolResultMessage.id,
});
}
@ -369,7 +369,7 @@ export const wikiOperationPlugin: PromptConcatPlugin = (hooks) => {
});
} catch (error) {
logger.error('Wiki operation tool execution failed', {
error: error instanceof Error ? error.message : String(error),
error,
agentId: handlerContext.agent.id,
toolParameters: toolMatch.parameters,
});
@ -425,9 +425,7 @@ Error: ${error instanceof Error ? error.message : String(error)}
callback();
} catch (error) {
logger.error('Error in wiki operation plugin response handler', {
error: error instanceof Error ? error.message : String(error),
});
logger.error('Error in wiki operation plugin response handler', { error });
callback();
}
});

View file

@ -262,7 +262,7 @@ async function executeWikiSearchTool(
}
} catch (error) {
logger.warn(`Error retrieving full tiddler content for ${result.title}`, {
error: error instanceof Error ? error.message : String(error),
error,
});
fullContentResults.push(result);
}
@ -278,7 +278,7 @@ async function executeWikiSearchTool(
};
} catch (error) {
logger.error('Vector search failed', {
error: error instanceof Error ? error.message : String(error),
error,
workspaceID,
query,
});
@ -327,7 +327,7 @@ async function executeWikiSearchTool(
}
} catch (error) {
logger.warn(`Error retrieving tiddler content for ${title}`, {
error: error instanceof Error ? error.message : String(error),
error,
});
results.push({ title });
}
@ -377,7 +377,7 @@ async function executeWikiSearchTool(
};
} catch (error) {
logger.error('Wiki search tool execution error', {
error: error instanceof Error ? error.message : String(error),
error,
parameters,
});
@ -471,7 +471,7 @@ async function executeWikiUpdateEmbeddingsTool(
};
} catch (error) {
logger.error('Wiki update embeddings tool execution error', {
error: error instanceof Error ? error.message : String(error),
error,
parameters,
});
@ -551,7 +551,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Error in wiki search tool list injection', {
error: error instanceof Error ? error.message : String(error),
error,
pluginId: pluginConfig.id,
});
callback();
@ -608,7 +608,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
latestAiMessage.metadata = { ...latestAiMessage.metadata, isPersisted: true };
} catch (error) {
logger.warn('Failed to persist AI message containing tool call immediately', {
error: error instanceof Error ? error.message : String(error),
error,
messageId: latestAiMessage.id,
});
}
@ -745,7 +745,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => {
});
} catch (error) {
logger.error('Wiki search tool execution failed', {
error: error instanceof Error ? error.message : String(error),
error,
toolCall: toolMatch,
});
@ -802,9 +802,7 @@ Error: ${error instanceof Error ? error.message : String(error)}
callback();
} catch (error) {
logger.error('Error in wiki search handler plugin', {
error: error instanceof Error ? error.message : String(error),
});
logger.error('Error in wiki search handler plugin', { error });
callback();
}
});

View file

@ -130,7 +130,7 @@ export const workspacesListPlugin: PromptConcatPlugin = (hooks) => {
callback();
} catch (error) {
logger.error('Error in workspaces list injection', {
error: error instanceof Error ? error.message : String(error),
error,
pluginId: pluginConfig.id,
});
callback();

View file

@ -11,7 +11,7 @@ export interface IPaths {
LOCALIZATION_FOLDER: string;
LOG_FOLDER: string;
MAIN_WINDOW_WEBPACK_ENTRY: string;
MENUBAR_ICON_PATH: string;
TIDGI_MINI_WINDOW_ICON_PATH: string;
SETTINGS_FOLDER: string;
TIDDLERS_PATH: string;
TIDDLYWIKI_TEMPLATE_FOLDER_PATH: string;

View file

@ -22,8 +22,7 @@ function fixEmptyAndErrorSettingFileOnStartUp() {
fs.writeJSONSync(settings.file(), {});
}
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('Error when checking Setting file format', { function: 'fixEmptyAndErrorSettingFileOnStartUp', error: error_.message, stack: error_.stack ?? '' });
logger.error('Error when checking Setting file format', { function: 'fixEmptyAndErrorSettingFileOnStartUp', error });
}
}
@ -44,8 +43,8 @@ export function fixSettingFileWhenError(jsonError: Error, providedJSONContent?:
fs.writeJSONSync(settings.file(), repaired);
logger.info('Fix JSON content done, saved', { repaired });
} catch (fixJSONError) {
const fixError = fixJSONError instanceof Error ? fixJSONError : new Error(String(fixJSONError));
logger.error('Setting file format bad, and cannot be fixed', { function: 'fixSettingFileWhenError', error: fixError.message, stack: fixError.stack ?? '', jsonContent });
const fixError = fixJSONError as Error;
logger.error('Setting file format bad, and cannot be fixed', { function: 'fixSettingFileWhenError', error: fixError, jsonContent });
}
}
@ -56,7 +55,6 @@ try {
atomicSave: !isWin,
});
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('Error when configuring settings', { function: 'settings.configure', error: error_.message, stack: error_.stack ?? '' });
logger.error('Error when configuring settings', { function: 'settings.configure', error });
}
fixEmptyAndErrorSettingFileOnStartUp();

View file

@ -52,6 +52,9 @@ export class DatabaseService implements IDatabaseService {
logger.info('loaded settings', {
hasContent: !!this.settingFileContent,
keys: this.settingFileContent ? Object.keys(this.settingFileContent).length : 0,
hasPreferences: !!this.settingFileContent?.preferences,
tidgiMiniWindow: this.settingFileContent?.preferences?.tidgiMiniWindow,
settingsFilePath: settings.file(),
function: 'DatabaseService.initializeForApp',
});
@ -64,8 +67,7 @@ export class DatabaseService implements IDatabaseService {
path: settings.file().replace(/settings\.json$/, ''),
});
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('Error initializing setting backup file', { function: 'DatabaseService.initializeForApp', error: error_.message, stack: error_.stack ?? '' });
logger.error('Error initializing setting backup file', { function: 'DatabaseService.initializeForApp', error });
}
// Ensure database folder exists
@ -171,7 +173,7 @@ export class DatabaseService implements IDatabaseService {
await this.loadSqliteVecExtension(dataSource);
} catch (error) {
logger.warn(`sqlite-vec extension failed to load during initialization for key: ${key}, continuing without vector search functionality`, {
error: (error as Error).message,
error,
});
// Don't throw - allow the database to work without vector functionality
}
@ -184,7 +186,7 @@ export class DatabaseService implements IDatabaseService {
await dataSource.destroy();
logger.info(`Database initialized for key: ${key}`);
} catch (error) {
logger.error(`Error initializing database for key: ${key}`, { error: (error as Error).message });
logger.error(`Error initializing database for key: ${key}`, { error });
throw error;
}
}
@ -216,7 +218,7 @@ export class DatabaseService implements IDatabaseService {
await this.loadSqliteVecExtension(dataSource);
} catch (error) {
logger.warn(`sqlite-vec extension failed to load for key: ${key}, continuing without vector search functionality`, {
error: (error as Error).message,
error,
});
}
}
@ -226,7 +228,7 @@ export class DatabaseService implements IDatabaseService {
return dataSource;
} catch (error) {
logger.error(`Failed to get database for key: ${key}`, { error: (error as Error).message });
logger.error(`Failed to get database for key: ${key}`, { error });
if (!isRetry) {
try {
@ -234,7 +236,7 @@ export class DatabaseService implements IDatabaseService {
await this.fixDatabaseLock(key);
return await this.getDatabase(key, {}, true);
} catch (retryError) {
logger.error(`Failed to retry getting database for key: ${key}`, { error: (retryError as Error).message });
logger.error(`Failed to retry getting database for key: ${key}`, { error: retryError });
}
}
@ -242,7 +244,7 @@ export class DatabaseService implements IDatabaseService {
await this.dataSources.get(key)?.destroy();
this.dataSources.delete(key);
} catch (closeError) {
logger.error(`Failed to close database in error handler for key: ${key}`, { error: (closeError as Error).message });
logger.error(`Failed to close database in error handler for key: ${key}`, { error: closeError });
}
throw error;
@ -267,7 +269,7 @@ export class DatabaseService implements IDatabaseService {
const stat = await fs.stat(databasePath);
return { exists: true, size: stat.size };
} catch (error) {
logger.error(`getDatabaseInfo failed for key: ${key}`, { error: (error as Error).message });
logger.error(`getDatabaseInfo failed for key: ${key}`, { error });
return { exists: false };
}
}
@ -289,7 +291,7 @@ export class DatabaseService implements IDatabaseService {
try {
await this.dataSources.get(key)?.destroy();
} catch (error) {
logger.warn(`Failed to destroy datasource for key: ${key} before deletion`, { error: (error as Error).message });
logger.warn(`Failed to destroy datasource for key: ${key} before deletion`, { error });
}
this.dataSources.delete(key);
}
@ -300,7 +302,7 @@ export class DatabaseService implements IDatabaseService {
logger.info(`Database file deleted for key: ${key}`);
}
} catch (error) {
logger.error(`deleteDatabase failed for key: ${key}`, { error: (error as Error).message });
logger.error(`deleteDatabase failed for key: ${key}`, { error });
throw error;
}
}
@ -323,7 +325,7 @@ export class DatabaseService implements IDatabaseService {
logger.info(`Database connection closed for key: ${key}`);
}
} catch (error) {
logger.error(`Failed to close database for key: ${key}`, { error: (error as Error).message });
logger.error(`Failed to close database for key: ${key}`, { error });
throw error;
}
}
@ -367,7 +369,7 @@ export class DatabaseService implements IDatabaseService {
await fs.unlink(temporaryPath);
logger.info(`Fixed database lock for key: ${key}`);
} catch (error) {
logger.error(`Failed to fix database lock for key: ${key}`, { error: (error as Error).message });
logger.error(`Failed to fix database lock for key: ${key}`, { error });
throw error;
}
}
@ -413,8 +415,7 @@ export class DatabaseService implements IDatabaseService {
// based on the dimensions needed
} catch (error) {
logger.error('Failed to load sqlite-vec extension:', {
error: (error as Error).message,
stack: (error as Error).stack,
error,
sqliteVecAvailable: typeof sqliteVec !== 'undefined',
});
throw new Error(`sqlite-vec extension failed to load: ${(error as Error).message}`);

View file

@ -57,11 +57,9 @@ export class Git implements IGitService {
this.gitWorker = createWorkerProxy<GitWorker>(worker);
logger.debug('gitWorker initialized successfully', { function: 'Git.initWorker' });
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('Failed to initialize gitWorker', {
function: 'Git.initWorker',
error: error_.message,
errorObj: error_,
error,
});
throw error;
}
@ -178,8 +176,8 @@ export class Git implements IGitService {
await this.nativeService.openInGitGuiApp(wikiFolderPath);
}
})
.catch((_error: unknown) => {
logger.error('createFailedDialog failed', _error instanceof Error ? _error : new Error(String(_error)));
.catch((error: unknown) => {
logger.error('createFailedDialog failed', { error });
});
}
}
@ -205,14 +203,14 @@ export class Git implements IGitService {
try {
try {
await this.updateGitInfoTiddler(workspace, configs.remoteUrl, configs.userInfo?.branch);
} catch (_error: unknown) {
logger.error('updateGitInfoTiddler failed when commitAndSync', _error instanceof Error ? _error : new Error(String(_error)));
} catch (error: unknown) {
logger.error('updateGitInfoTiddler failed when commitAndSync', { error });
}
const observable = this.gitWorker?.commitAndSyncWiki(workspace, configs, getErrorMessageI18NDict());
return await this.getHasChangeHandler(observable, workspace.wikiFolderLocation, workspaceIDToShowNotification);
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
this.createFailedNotification(error.message, workspaceIDToShowNotification);
} catch (error: unknown) {
const error_ = error as Error;
this.createFailedNotification(error_.message, workspaceIDToShowNotification);
return true;
}
}

View file

@ -11,8 +11,8 @@ export default async function getViewBounds(
): Promise<{ height: number; width: number; x: number; y: number }> {
const { findInPage = false, windowName } = config;
const preferencesService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const [sidebar, sidebarOnMenubar] = await Promise.all([preferencesService.get('sidebar'), preferencesService.get('sidebarOnMenubar')]);
const showSidebar = windowName === WindowNames.menuBar ? sidebarOnMenubar : sidebar;
const [sidebar, tidgiMiniWindowShowSidebar] = await Promise.all([preferencesService.get('sidebar'), preferencesService.get('tidgiMiniWindowShowSidebar')]);
const showSidebar = windowName === WindowNames.tidgiMiniWindow ? tidgiMiniWindowShowSidebar : sidebar;
// Now showing sidebar on secondary window
const secondary = windowName === WindowNames.secondary;
const x = (showSidebar && !secondary) ? 68 : 0;

View file

@ -160,7 +160,7 @@ export class Backend implements BackendModule {
try {
result = JSON.parse(payload.data ?? 'null');
} catch (parseError) {
const parseError_ = parseError instanceof Error ? parseError : new Error(String(parseError));
const parseError_ = parseError as Error;
parseError_.message = `Error parsing '${String(payload.filename)}'. Message: '${String(parseError)}'.`;
const entry = this.readCallbacks[payload.key];
const callback__ = entry?.callback;

View file

@ -1,9 +1,30 @@
import { LOG_FOLDER } from '@/constants/appPaths';
import winston, { format } from 'winston';
import 'winston-daily-rotate-file';
import type { TransformableInfo } from 'logform';
import { serializeError } from 'serialize-error';
import RendererTransport from './rendererTransport';
export * from './wikiOutput';
/**
* Custom formatter to serialize Error objects using serialize-error package.
* Falls back to template string if serialization fails.
*/
const errorSerializer = format((info: TransformableInfo) => {
const infoRecord = info as Record<string, unknown>;
// Serialize error objects
if (infoRecord.error instanceof Error) {
try {
infoRecord.error = serializeError(infoRecord.error);
} catch {
// Fallback to template string with optional chaining
const error = infoRecord.error as Error;
infoRecord.error = `${error?.message ?? ''} stack: ${error?.stack ?? ''}`;
}
}
return info;
});
const logger = winston.createLogger({
transports: [
@ -19,7 +40,7 @@ const logger = winston.createLogger({
}),
new RendererTransport(),
],
format: format.combine(format.timestamp(), format.json()),
format: format.combine(errorSerializer(), format.timestamp(), format.json()),
});
export { logger };

View file

@ -27,8 +27,8 @@ export function getUrlWithCorrectProtocol(workspace: IWorkspace, originalUrl: st
return parsedUrl.toString();
} catch (error) {
logger.error(
`Failed to getUrlWithCorrectProtocol for originalUrl ${originalUrl}, fallback to originalUrl. Error: ${(error as Error).message}`,
{ isHttps },
'Failed to getUrlWithCorrectProtocol for originalUrl, fallback to originalUrl',
{ isHttps, error },
);
return originalUrl;
}
@ -42,8 +42,10 @@ export function replaceUrlPortWithSettingPort(originalUrl: string, newPort: numb
parsedUrl.port = String(newPort);
return parsedUrl.toString();
} catch (error) {
const error_ = error as Error;
logger.error(
`Failed to replaceUrlPortWithSettingPort for originalUrl ${originalUrl} to newPort ${newPort} , fallback to originalUrl. Error: ${(error as Error).message}`,
'Failed to replaceUrlPortWithSettingPort for originalUrl, fallback to originalUrl',
{ error: error_ },
);
return originalUrl;
}

View file

@ -86,8 +86,7 @@ export class MenuService implements IMenuService {
Menu.setApplicationMenu(menu);
} catch (error) {
logger.error('buildMenu failed', {
message: (error as Error).message,
stack: (error as Error).stack ?? '',
error,
function: 'buildMenu',
});
try {

View file

@ -139,9 +139,9 @@ async function findApplication(editor: IDarwinExternalEditor): Promise<string |
// app-path not finding the app isn't an error, it just means the
// bundle isn't registered on the machine.
// https://github.com/sindresorhus/app-path/blob/0e776d4e132676976b4a64e09b5e5a4c6e99fcba/index.js#L7-L13
const installPath = await appPath(identifier).catch(async (_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
logger.info('gets appPath Error', { error: error.message ?? String(error), function: 'darwin.findApplication' });
const installPath = await appPath(identifier).catch(async (error_: unknown) => {
const error = error_ as Error;
logger.info('gets appPath Error', { error, function: 'darwin.findApplication' });
if (error.message === "Couldn't find the app") {
return await Promise.resolve(null);
}
@ -166,9 +166,9 @@ async function findApplication(editor: IDarwinExternalEditor): Promise<string |
installPath,
function: 'darwin.findApplication',
});
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
logger.info('unable to locate installation', { editorName: editor.name, error: error.message, function: 'darwin.findApplication' });
} catch (error_: unknown) {
const error = error_ as Error;
logger.info('unable to locate installation', { editorName: editor.name, error, function: 'darwin.findApplication' });
}
}

View file

@ -1,4 +1,4 @@
import { app, dialog, ipcMain, MessageBoxOptions, shell } from 'electron';
import { app, dialog, globalShortcut, ipcMain, MessageBoxOptions, shell } from 'electron';
import fs from 'fs-extra';
import { inject, injectable } from 'inversify';
import path from 'path';
@ -10,6 +10,7 @@ import { githubDesktopUrl } from '@/constants/urls';
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import { getLocalHostUrlWithActualIP, getUrlWithCorrectProtocol, replaceUrlPortWithSettingPort } from '@services/libs/url';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IWikiService } from '@services/wiki/interface';
import { ZxWorkerControlActions } from '@services/wiki/interface';
@ -22,20 +23,112 @@ import i18next from 'i18next';
import { ZxNotInitializedError } from './error';
import { findEditorOrDefault, findGitGUIAppOrDefault, launchExternalEditor } from './externalApp';
import type { INativeService, IPickDirectoryOptions } from './interface';
import { getShortcutCallback, registerShortcutByKey } from './keyboardShortcutHelpers';
import { reportErrorToGithubWithTemplates } from './reportError';
@injectable()
export class NativeService implements INativeService {
constructor(@inject(serviceIdentifier.Window) private readonly windowService: IWindowService) {
constructor(
@inject(serviceIdentifier.Window) private readonly windowService: IWindowService,
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
) {
this.setupIpcHandlers();
}
setupIpcHandlers(): void {
public setupIpcHandlers(): void {
ipcMain.on(NativeChannel.showElectronMessageBoxSync, (event, options: MessageBoxOptions, windowName: WindowNames = WindowNames.main) => {
event.returnValue = this.showElectronMessageBoxSync(options, windowName);
});
}
public async initialize(): Promise<void> {
await this.initializeKeyboardShortcuts();
}
private async initializeKeyboardShortcuts(): Promise<void> {
const shortcuts = await this.getKeyboardShortcuts();
logger.debug('shortcuts from preferences', { shortcuts, function: 'initializeKeyboardShortcuts' });
// Register all saved shortcuts
for (const [key, shortcut] of Object.entries(shortcuts)) {
if (shortcut && shortcut.trim() !== '') {
try {
await registerShortcutByKey(key, shortcut);
} catch (error) {
logger.error(`Failed to register shortcut ${key}: ${shortcut}`, { error });
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
public async registerKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T, shortcut: string): Promise<void> {
try {
const key = `${String(serviceName)}.${String(methodName)}`;
logger.info('Starting keyboard shortcut registration', { key, shortcut, serviceName, methodName, function: 'NativeService.registerKeyboardShortcut' });
// Save to preferences
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const shortcuts = await this.getKeyboardShortcuts();
logger.debug('Current shortcuts before registration', { shortcuts, function: 'NativeService.registerKeyboardShortcut' });
shortcuts[key] = shortcut;
await preferenceService.set('keyboardShortcuts', shortcuts);
logger.info('Saved shortcut to preferences', { key, shortcut, function: 'NativeService.registerKeyboardShortcut' });
// Register the shortcut
await registerShortcutByKey(key, shortcut);
logger.info('Successfully registered new keyboard shortcut', { key, shortcut, function: 'NativeService.registerKeyboardShortcut' });
} catch (error) {
logger.error('Failed to register keyboard shortcut', { error, serviceIdentifier: serviceName, methodName, shortcut, function: 'NativeService.registerKeyboardShortcut' });
throw error;
}
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
public async unregisterKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T): Promise<void> {
try {
const key = `${String(serviceName)}.${String(methodName)}`;
// Get the current shortcut string before removing from preferences
const shortcuts = await this.getKeyboardShortcuts();
const shortcutString = shortcuts[key];
// Remove from preferences
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete shortcuts[key];
await preferenceService.set('keyboardShortcuts', shortcuts);
// Unregister the shortcut using the actual shortcut string, not the key
if (shortcutString && globalShortcut.isRegistered(shortcutString)) {
globalShortcut.unregister(shortcutString);
logger.info('Successfully unregistered keyboard shortcut', { key, shortcutString });
} else {
logger.warn('Shortcut was not registered or shortcut string not found', { key, shortcutString });
}
} catch (error) {
logger.error('Failed to unregister keyboard shortcut', { error, serviceIdentifier: serviceName, methodName });
throw error;
}
}
public async getKeyboardShortcuts(): Promise<Record<string, string>> {
const preferences = this.preferenceService.getPreferences();
return preferences.keyboardShortcuts || {};
}
public async executeShortcutCallback(key: string): Promise<void> {
logger.debug('Frontend requested shortcut execution', { key, function: 'NativeService.executeShortcutCallback' });
const callback = getShortcutCallback(key);
if (callback) {
await callback();
logger.info('Successfully executed shortcut callback from frontend', { key, function: 'NativeService.executeShortcutCallback' });
} else {
logger.warn('No callback found for shortcut key from frontend', { key, function: 'NativeService.executeShortcutCallback' });
}
}
public async openInEditor(filePath: string, editorName?: string): Promise<boolean> {
// TODO: open vscode by default to speed up, support choose favorite editor later
let defaultEditor = await findEditorOrDefault('Visual Studio Code').catch(() => {});

View file

@ -2,6 +2,7 @@ import { MessageBoxOptions } from 'electron';
import { Observable } from 'rxjs';
import { NativeChannel } from '@/constants/channels';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IZxFileInput } from '@services/wiki/wikiWorker';
import { WindowNames } from '@services/windows/WindowProperties';
import { ProxyPropertyType } from 'electron-ipc-cat/common';
@ -18,6 +19,39 @@ export interface IPickDirectoryOptions {
* Wrap call to electron api, so we won't need remote module in renderer process
*/
export interface INativeService {
/**
* Initialize the native service
* This should be called during app startup
*/
initialize(): Promise<void>;
/**
* Register a keyboard shortcut and save it to preferences
* @param serviceName The service identifier name from serviceIdentifier
* @param methodName The method name to call when shortcut is triggered
* @param shortcut The keyboard shortcut string, e.g. "Ctrl+Shift+T"
* @template T The service interface type that contains the method, e.g. IWindowService
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
registerKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T, shortcut: string): Promise<void>;
/**
* Unregister a specific keyboard shortcut
* @param serviceName The service identifier name from serviceIdentifier
* @param methodName The method name
* @template T The service interface type that contains the method, e.g. IWindowService
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
unregisterKeyboardShortcut<T>(serviceName: keyof typeof serviceIdentifier, methodName: keyof T): Promise<void>;
/**
* Get all registered keyboard shortcuts from preferences, key is combination of service name and method name joined by '.'
* @returns A record where keys are formatted as 'serviceName.methodName' and values are the shortcut strings
*/
getKeyboardShortcuts(): Promise<Record<string, string>>;
/**
* Execute a keyboard shortcut callback by key
* This method wraps the backend getShortcutCallback logic for frontend use
* @param key The key in format "ServiceIdentifier.methodName"
*/
executeShortcutCallback(key: string): Promise<void>;
/**
* Copy a file or directory. The directory can have contents.
* @param fromFilePath Note that if src is a directory it will copy everything inside of this directory, not the entire directory itself (see fs.extra issue #537).
@ -91,6 +125,12 @@ export interface INativeService {
export const NativeServiceIPCDescriptor = {
channel: NativeChannel.name,
properties: {
initialize: ProxyPropertyType.Function,
initializeKeyboardShortcuts: ProxyPropertyType.Function,
registerKeyboardShortcut: ProxyPropertyType.Function,
unregisterKeyboardShortcut: ProxyPropertyType.Function,
getKeyboardShortcuts: ProxyPropertyType.Function,
executeShortcutCallback: ProxyPropertyType.Function,
copyPath: ProxyPropertyType.Function,
executeZxScript$: ProxyPropertyType.Function$,
formatFileUrlToAbsolutePath: ProxyPropertyType.Function,

View file

@ -0,0 +1,106 @@
import { container } from '@services/container';
import { logger } from '@services/libs/log';
import serviceIdentifier from '@services/serviceIdentifier';
import { globalShortcut } from 'electron';
/**
* Get the callback function for a shortcut key
* @param key The key in format "ServiceIdentifier.methodName"
* @returns The callback function or undefined
*/
export function getShortcutCallback(key: string): (() => Promise<void>) | undefined {
logger.debug('Getting shortcut callback', { key, function: 'getShortcutCallback' });
// Split the key into service and method parts
const [serviceIdentifierName, methodName] = key.split('.');
logger.debug('Parsed key components', { serviceIdentifierName, methodName, function: 'getShortcutCallback' });
// If we don't have any part, return
if (!serviceIdentifierName || !methodName) {
logger.warn('Invalid key format', { key, serviceIdentifierName, methodName, function: 'getShortcutCallback' });
return;
}
// Get the service identifier symbol
const serviceSymbol = (serviceIdentifier as Record<string, symbol>)[serviceIdentifierName];
logger.debug('Service symbol lookup', {
serviceIdentifierName,
serviceSymbol: serviceSymbol?.toString(),
availableServices: Object.keys(serviceIdentifier),
function: 'getShortcutCallback',
});
if (serviceSymbol === undefined) {
logger.warn('Service identifier not found', { serviceIdentifierName, availableServices: Object.keys(serviceIdentifier), function: 'getShortcutCallback' });
return;
}
// Return a callback that gets the service from container and calls the method
logger.debug('Creating callback function', { key, serviceIdentifierName, methodName, function: 'getShortcutCallback' });
return async () => {
try {
logger.info('🔥 SHORTCUT TRIGGERED! 🔥', { key, service: serviceIdentifierName, method: methodName, function: 'getShortcutCallback' });
logger.info('Shortcut triggered - starting execution', { key, service: serviceIdentifierName, method: methodName, function: 'getShortcutCallback' });
// Get the service instance from container lazily
const service = container.get<Record<string, (...arguments_: unknown[]) => unknown>>(serviceSymbol);
logger.debug('Service instance retrieved', {
key,
service: serviceIdentifierName,
method: methodName,
serviceExists: !!service,
serviceType: typeof service,
availableMethods: service ? Object.keys(service).filter(k => typeof service[k] === 'function') : [],
function: 'getShortcutCallback',
});
if (service && typeof service[methodName] === 'function') {
logger.info('Calling service method', { key, service: serviceIdentifierName, method: methodName, function: 'getShortcutCallback' });
// Call the method with await if it's an async method
await service[methodName]();
logger.info('Service method completed', { key, service: serviceIdentifierName, method: methodName, function: 'getShortcutCallback' });
} else {
logger.warn('Shortcut target method not found', {
key,
service: serviceIdentifierName,
method: methodName,
serviceExists: !!service,
methodExists: service ? typeof service[methodName] : 'service is null',
availableMethods: service ? Object.keys(service).filter(k => typeof service[k] === 'function') : [],
function: 'getShortcutCallback',
});
}
} catch (error) {
logger.error(`Failed to execute shortcut callback for ${key}`, { error, function: 'getShortcutCallback' });
}
};
}
/**
* Register a shortcut by key
* @param key The key in format "ServiceIdentifier.methodName"
* @param shortcut The shortcut string (e.g. "CmdOrCtrl+Shift+T")
*/
export async function registerShortcutByKey(key: string, shortcut: string): Promise<void> {
// Unregister any existing shortcut first
if (globalShortcut.isRegistered(shortcut)) {
globalShortcut.unregister(shortcut);
}
// Get the callback for this key
const callback = getShortcutCallback(key);
if (!callback) {
logger.warn('No callback found for shortcut key', { key });
return;
}
// Register the new shortcut
const success = globalShortcut.register(shortcut, callback);
if (success) {
logger.info('Successfully registered shortcut', { key, shortcut, function: 'registerShortcutByKey' });
} else {
logger.error('Failed to register shortcut', { key, shortcut, function: 'registerShortcutByKey' });
throw new Error(`Failed to register shortcut: ${shortcut}`);
}
}

View file

@ -63,10 +63,10 @@ export function reportErrorToGithubWithTemplates(error: Error): void {
const nativeService = container.get<INativeService>(serviceIdentifier.NativeService);
return nativeService.openPath(LOG_FOLDER, true);
})
.catch(async (_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
.catch(async (error_: unknown) => {
const error = error_ as Error;
await import('@services/libs/log').then(({ logger }) => {
logger.error(`Failed to open LOG_FOLDER in reportErrorToGithubWithTemplates`, error);
logger.error(`Failed to open LOG_FOLDER in reportErrorToGithubWithTemplates`, { error });
});
});
openNewGitHubIssue({

View file

@ -7,7 +7,6 @@ export const defaultPreferences: IPreferences = {
allowPrerelease: Boolean(semver.prerelease(app.getVersion())),
alwaysOnTop: false,
askForDownloadPath: true,
attachToMenubar: false,
disableAntiAntiLeech: false,
disableAntiAntiLeechForUrls: [],
downloadPath: DEFAULT_DOWNLOADS_PATH,
@ -15,8 +14,8 @@ export const defaultPreferences: IPreferences = {
hibernateUnusedWorkspacesAtLaunch: false,
hideMenuBar: false,
ignoreCertificateErrors: false,
keyboardShortcuts: {},
language: 'zh-Hans',
menuBarAlwaysOnTop: false,
pauseNotifications: '',
pauseNotificationsBySchedule: false,
pauseNotificationsByScheduleFrom: getDefaultPauseNotificationsByScheduleFrom(),
@ -28,7 +27,6 @@ export const defaultPreferences: IPreferences = {
showSideBarIcon: true,
showSideBarText: true,
sidebar: true,
sidebarOnMenubar: false,
spellcheck: true,
spellcheckLanguages: ['en-US'],
swipeToNavigate: true,
@ -36,6 +34,12 @@ export const defaultPreferences: IPreferences = {
syncDebounceInterval: 1000 * 60 * 30,
syncOnlyWhenNoDraft: true,
themeSource: 'system' as 'system' | 'light' | 'dark',
tidgiMiniWindow: false,
tidgiMiniWindowAlwaysOnTop: false,
tidgiMiniWindowFixedWorkspaceId: '',
tidgiMiniWindowShowSidebar: false,
tidgiMiniWindowShowTitleBar: true,
tidgiMiniWindowSyncWorkspaceWithMainWindow: true,
titleBar: true,
unreadCountBadge: true,
useHardwareAcceleration: true,

View file

@ -87,15 +87,22 @@ export class Preference implements IPreferenceService {
const notificationService = container.get<INotificationService>(serviceIdentifier.NotificationService);
await notificationService.updatePauseNotificationsInfo();
}
// Delegate window-related preference changes to WindowService
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
await windowService.reactWhenPreferencesChanged(key, value);
switch (key) {
case 'themeSource': {
nativeTheme.themeSource = value as IPreferences['themeSource'];
break;
return;
}
case 'language': {
await requestChangeLanguage(value as string);
break;
return;
}
default:
break;
}
}

View file

@ -8,7 +8,7 @@ export interface IPreferences {
allowPrerelease: boolean;
alwaysOnTop: boolean;
askForDownloadPath: boolean;
attachToMenubar: boolean;
tidgiMiniWindow: boolean;
/**
*
*/
@ -26,7 +26,7 @@ export interface IPreferences {
hideMenuBar: boolean;
ignoreCertificateErrors: boolean;
language: string;
menuBarAlwaysOnTop: boolean;
tidgiMiniWindowAlwaysOnTop: boolean;
pauseNotifications: string | undefined;
pauseNotificationsBySchedule: boolean;
pauseNotificationsByScheduleFrom: string;
@ -42,12 +42,28 @@ export interface IPreferences {
*/
sidebar: boolean;
/**
* Should show sidebar on menubar window?
* Should show sidebar on tidgi mini window?
*/
sidebarOnMenubar: boolean;
tidgiMiniWindowShowSidebar: boolean;
spellcheck: boolean;
spellcheckLanguages: HunspellLanguages[];
swipeToNavigate: boolean;
/**
* Whether menubar window should show the same workspace as main window
*/
tidgiMiniWindowSyncWorkspaceWithMainWindow: boolean;
/**
* The workspace ID that tidgi mini window should always show when tidgiMiniWindowSyncWorkspaceWithMainWindow is false
*/
tidgiMiniWindowFixedWorkspaceId: string | undefined;
/**
* Whether to show title bar on tidgi mini window (independent of main window's titleBar setting)
*/
tidgiMiniWindowShowTitleBar: boolean;
/**
* Keyboard shortcuts configuration stored as serviceIdentifier.methodName -> shortcut
*/
keyboardShortcuts: Record<string, string>;
syncBeforeShutdown: boolean;
syncDebounceInterval: number;
/**
@ -66,6 +82,7 @@ export enum PreferenceSections {
friendLinks = 'friendLinks',
general = 'general',
languages = 'languages',
tidgiMiniWindow = 'tidgiMiniWindow',
misc = 'misc',
network = 'network',
notifications = 'notifications',

View file

@ -109,11 +109,8 @@ export class Sync implements ISyncService {
}
return true;
} catch (error) {
logger.error(
`${(error as Error).message} when checking draft titles. ${
(error as Error).stack ?? ''
}\n This might because it just will throw error when on Windows and App is at background (WebContentsView will disappear and not accessible.)`,
);
const error_ = error as Error;
logger.error('Error when checking draft titles', { error: error_, function: 'checkCanSyncDueToNoDraft' });
// when app is on background, might have no draft, because user won't edit it. So just return true
return true;
}

View file

@ -55,10 +55,11 @@ export function handleNewWindow(
// open external url in browser
if (nextDomain !== undefined && (disposition === 'foreground-tab' || disposition === 'background-tab')) {
logger.debug('openExternal', { nextUrl, nextDomain, disposition, function: 'handleNewWindow' });
void shell.openExternal(nextUrl).catch((_error: unknown) => {
void shell.openExternal(nextUrl).catch((error_: unknown) => {
const error = error_ as Error;
logger.error(
`handleNewWindow() openExternal error ${_error instanceof Error ? _error.message : String(_error)}`,
_error instanceof Error ? _error : new Error(String(_error)),
`handleNewWindow() openExternal error ${error.message}`,
{ error },
);
});
return {

View file

@ -22,6 +22,7 @@ import { logger } from '@services/libs/log';
import type { INativeService } from '@services/native/interface';
import { type IBrowserViewMetaData, WindowNames } from '@services/windows/WindowProperties';
import { isWikiWorkspace, type IWorkspace } from '@services/workspaces/interface';
import { getTidgiMiniWindowTargetWorkspace } from '@services/workspacesView/utilities';
import debounce from 'lodash/debounce';
import { setViewEventName } from './constants';
import { ViewLoadUrlError } from './error';
@ -247,7 +248,7 @@ export class View implements IViewService {
};
const checkNotExistResult = await Promise.all([
checkNotExist(workspace, WindowNames.main),
this.preferenceService.get('attachToMenubar').then((attachToMenubar) => attachToMenubar && checkNotExist(workspace, WindowNames.menuBar)),
this.preferenceService.get('tidgiMiniWindow').then((tidgiMiniWindow) => tidgiMiniWindow && checkNotExist(workspace, WindowNames.tidgiMiniWindow)),
]);
return checkNotExistResult.every((result) => !result);
}
@ -312,7 +313,11 @@ export class View implements IViewService {
if (this.shouldMuteAudio !== undefined) {
view.webContents.audioMuted = this.shouldMuteAudio;
}
if (workspace.active || windowName === WindowNames.secondary) {
// Add view to window if:
// 1. workspace is active (main window)
// 2. windowName is secondary (always add)
// 3. windowName is tidgiMiniWindow (tidgi mini window can have fixed workspace independent of main window's active workspace)
if (workspace.active || windowName === WindowNames.secondary || windowName === WindowNames.tidgiMiniWindow) {
browserWindow.contentView.addChildView(view);
const contentSize = browserWindow.getContentSize();
const newViewBounds = await getViewBounds(contentSize as [number, number], { windowName });
@ -326,7 +331,7 @@ export class View implements IViewService {
if (updatedWorkspace === undefined) return;
// Prevent update non-active (hiding) wiki workspace, so it won't pop up to cover other active agent workspace
if (windowName === WindowNames.main && !updatedWorkspace.active) return;
if ([WindowNames.secondary, WindowNames.main, WindowNames.menuBar].includes(windowName)) {
if ([WindowNames.secondary, WindowNames.main, WindowNames.tidgiMiniWindow].includes(windowName)) {
const contentSize = browserWindow.getContentSize();
const newViewBounds = await getViewBounds(contentSize as [number, number], { windowName });
view.setBounds(newViewBounds);
@ -406,12 +411,31 @@ export class View implements IViewService {
}
public async setActiveViewForAllBrowserViews(workspaceID: string): Promise<void> {
await Promise.all([
this.setActiveView(workspaceID, WindowNames.main),
this.preferenceService.get('attachToMenubar').then(async (attachToMenubar) => {
return await (attachToMenubar && this.setActiveView(workspaceID, WindowNames.menuBar));
}),
]);
// Set main window workspace
const mainWindowTask = this.setActiveView(workspaceID, WindowNames.main);
const tidgiMiniWindow = await this.preferenceService.get('tidgiMiniWindow');
// For tidgi mini window, decide which workspace to show based on preferences
let tidgiMiniWindowTask = Promise.resolve();
if (tidgiMiniWindow) {
// Default to sync (undefined or true), otherwise use fixed workspace ID (fallback to main if not set)
const { targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceID);
const tidgiMiniWindowWorkspaceId = targetWorkspaceId || workspaceID;
logger.debug('setActiveViewForAllBrowserViews tidgi mini window decision', {
function: 'setActiveViewForAllBrowserViews',
tidgiMiniWindowWorkspaceId,
willSetActiveView: true,
});
tidgiMiniWindowTask = this.setActiveView(tidgiMiniWindowWorkspaceId, WindowNames.tidgiMiniWindow);
} else {
logger.info('setActiveViewForAllBrowserViews tidgi mini window not enabled', {
function: 'setActiveViewForAllBrowserViews',
});
}
await Promise.all([mainWindowTask, tidgiMiniWindowTask]);
}
public async setActiveView(workspaceID: string, windowName: WindowNames): Promise<void> {
@ -581,9 +605,9 @@ export class View implements IViewService {
const workspace = await workspaceService.getActiveWorkspace();
if (workspace !== undefined) {
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const isMenubarOpen = await windowService.isMenubarOpen();
if (isMenubarOpen) {
return this.getView(workspace.id, WindowNames.menuBar);
const isTidgiMiniWindowOpen = await windowService.isTidgiMiniWindowOpen();
if (isTidgiMiniWindowOpen) {
return this.getView(workspace.id, WindowNames.tidgiMiniWindow);
} else {
return this.getView(workspace.id, WindowNames.main);
}
@ -594,7 +618,7 @@ export class View implements IViewService {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const workspace = await workspaceService.getActiveWorkspace();
if (workspace !== undefined) {
return [this.getView(workspace.id, WindowNames.main), this.getView(workspace.id, WindowNames.menuBar)];
return [this.getView(workspace.id, WindowNames.main), this.getView(workspace.id, WindowNames.tidgiMiniWindow)];
}
logger.error(`getActiveBrowserViews workspace !== undefined`, { stack: new Error('stack').stack?.replace('Error:', '') });
return [];

View file

@ -30,11 +30,11 @@ export interface IViewService {
createViewAddToWindow(workspace: IWorkspace, browserWindow: BrowserWindow, sharedWebPreferences: WebPreferences, windowName: WindowNames): Promise<WebContentsView>;
forEachView: (functionToRun: (view: WebContentsView, workspaceID: string, windowName: WindowNames) => void) => void;
/**
* If menubar is open, we get menubar browser view, else we get main window browser view
* If tidgi mini window is open, we get tidgi mini window browser view, else we get main window browser view
*/
getActiveBrowserView: () => Promise<WebContentsView | undefined>;
/**
* Get active workspace's main window and menubar browser view.
* Get active workspace's main window and tidgi mini window browser view.
*/
getActiveBrowserViews: () => Promise<Array<WebContentsView | undefined>>;
getLoadedViewEnsure(workspaceID: string, windowName: WindowNames): Promise<WebContentsView>;
@ -74,7 +74,7 @@ export interface IViewService {
/**
* Bring an already created view to the front. If it happened to not created, will call `addView()` to create one.
* @param workspaceID id, can only be main workspace id, because only main workspace will have view created.
* @param windowName you can control main window or menubar window to have this view.
* @param windowName you can control main window or tidgi mini window to have this view.
* @returns
*/
setActiveView: (workspaceID: string, windowName: WindowNames) => Promise<void>;

View file

@ -153,13 +153,11 @@ export function setupIpcServerRoutesHandlers(view: WebContentsView, workspaceID:
}
}
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('setupIpcServerRoutesHandlers.handlerCallback error', {
function: 'setupIpcServerRoutesHandlers.handlerCallback',
error: error_.message,
stack: error_.stack ?? '',
error,
});
return new Response(undefined, { status: 500, statusText: `${error_.message} ${error_.stack ?? ''}` });
return new Response(undefined, { status: 500, statusText: `${(error as Error).message} ${(error as Error).stack ?? ''}` });
}
const statusText = `setupIpcServerRoutesHandlers.handlerCallback: tidgi protocol 404 ${request.url}`;
logger.warn(statusText);
@ -174,11 +172,9 @@ export function setupIpcServerRoutesHandlers(view: WebContentsView, workspaceID:
logger.warn('tidgi protocol is not handled', { function: 'setupIpcServerRoutesHandlers.handlerCallback' });
}
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('setupIpcServerRoutesHandlers.handlerCallback error', {
function: 'setupIpcServerRoutesHandlers.handlerCallback',
error: error_.message,
stack: error_.stack ?? '',
error,
});
}
}

View file

@ -95,17 +95,17 @@ export default function setupViewEventHandlers(
}
// if is external website
logger.debug('will-navigate openExternal', { newUrl, currentUrl, homeUrl, lastUrl });
await shell.openExternal(newUrl).catch((_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
logger.error(`will-navigate openExternal error ${error.message}`, error);
await shell.openExternal(newUrl).catch((error_: unknown) => {
const error = error_ as Error;
logger.error(`will-navigate openExternal error ${error.message}`, { error });
});
// if is an external website
event.preventDefault();
try {
// TODO: do this until https://github.com/electron/electron/issues/31783 fixed
await view.webContents.loadURL(currentUrl);
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
logger.warn(new ViewLoadUrlError(lastUrl ?? '', `${error.message} ${error.stack ?? ''}`));
}
// event.stopPropagation();
@ -299,8 +299,8 @@ export default function setupViewEventHandlers(
view.webContents.on('update-target-url', (_event, url) => {
try {
view.webContents.send('update-target-url', url);
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
logger.warn(error);
}
});

View file

@ -27,25 +27,25 @@ export function handleOpenFileExternalLink(nextUrl: string, newWindowContext: IN
const fileStat = fs.statSync(absoluteFilePath);
if (fileStat.isDirectory()) {
logger.info(`Opening directory ${absoluteFilePath}`, { function: 'handleOpenFileExternalLink' });
void shell.openPath(absoluteFilePath).catch((_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
void shell.openPath(absoluteFilePath).catch((error_: unknown) => {
const error = error_ as Error;
const message = i18n.t('Log.FailedToOpenDirectory', { path: absoluteFilePath, message: error.message });
logger.warn(message, { function: 'handleOpenFileExternalLink' });
logger.warn(message, { function: 'handleOpenFileExternalLink', error });
void wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, newWindowContext.workspace.id, [message]);
});
} else if (fileStat.isFile()) {
logger.info(`Opening file ${absoluteFilePath}`, { function: 'handleOpenFileExternalLink' });
void shell.openPath(absoluteFilePath).catch((_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
void shell.openPath(absoluteFilePath).catch((error_: unknown) => {
const error = error_ as Error;
const message = i18n.t('Log.FailedToOpenFile', { path: absoluteFilePath, message: error.message });
logger.warn(message, { function: 'handleOpenFileExternalLink' });
logger.warn(message, { function: 'handleOpenFileExternalLink', error });
void wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, newWindowContext.workspace.id, [message]);
});
}
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
const message = `${i18n.t('AddWorkspace.PathNotExist', { path: absoluteFilePath })} ${error.message}`;
logger.warn(message, { function: 'handleOpenFileExternalLink' });
logger.warn(message, { function: 'handleOpenFileExternalLink', error });
void wikiService.wikiOperationInBrowser(WikiChannel.generalNotification, newWindowContext.workspace.id, [message]);
}
return {

View file

@ -77,10 +77,9 @@ export class Wiki implements IWikiService {
});
} catch (error) {
logger.error('failed', {
error: (error as Error).message,
error,
newFolderPath,
folderName,
stack: (error as Error).stack,
function: 'copyWikiTemplate',
});
throw new CopyWikiTemplateError(`${(error as Error).message}, (${newFolderPath}, ${folderName})`);
@ -393,8 +392,7 @@ export class Wiki implements IWikiService {
logger.debug(`terminateWorker for ${id}`);
await terminateWorker(nativeWorker);
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('wiki worker stop failed', { function: 'stopWiki', error: error_.message, errorObj: error_ });
logger.error('wiki worker stop failed', { function: 'stopWiki', error });
}
delete this.wikiWorkers[id];
@ -709,10 +707,9 @@ export class Wiki implements IWikiService {
function: 'startWiki',
});
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.warn('startWiki failed', { function: 'startWiki', error: error_.message });
logger.warn('startWiki failed', { function: 'startWiki', error });
if (error instanceof WikiRuntimeError && error.retry) {
logger.warn('startWiki retry', { function: 'startWiki', error: error_.message });
logger.warn('startWiki retry', { function: 'startWiki', error });
// don't want it to throw here again, so no await here.
const workspaceViewService = container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView);
@ -720,7 +717,7 @@ export class Wiki implements IWikiService {
} else if ((error as Error).message.includes('Did not receive an init message from worker after')) {
// https://github.com/andywer/threads.js/issues/426
// wait some time and restart the wiki will solve this
logger.warn('startWiki handle error, restarting', { function: 'startWiki', error: error_.message });
logger.warn('startWiki handle error, restarting', { function: 'startWiki', error });
await this.restartWiki(workspace);
} else {
logger.warn('unexpected error, throw it', { function: 'startWiki' });

View file

@ -13,8 +13,7 @@ export async function updateGhConfig(wikiPath: string, options: IGhOptions): Pro
const newContent = content.replace(/(branches:\n\s+- )(master)$/m, `$1${options.branch}`);
await fs.writeFile(ghPagesConfigPath, newContent, 'utf8');
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('updateGhConfig failed', { function: 'updateGhConfig', error: error_.message, errorObj: error_ });
logger.error('updateGhConfig failed', { function: 'updateGhConfig', error });
}
}
}

View file

@ -117,7 +117,7 @@ export async function getSubWikiPluginContent(mainWikiPath: string): Promise<ISu
folderName: getFolderNamePathPart(line),
})).filter((item) => item.folderName.length > 0 && item.tagName.length > 0);
} catch (error) {
logger.error((error as Error).message, { function: 'getSubWikiPluginContent' });
logger.error((error as Error).message, { error, function: 'getSubWikiPluginContent' });
return [];
}
}

View file

@ -40,8 +40,8 @@ export class WikiOperationsInWikiWorker {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const result = new Function('$tw', script)(this.wikiInstance) as unknown;
resolve(result);
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
reject(error);
}
}, 1);

View file

@ -19,15 +19,15 @@ export async function extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath:
resolve();
},
});
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
reject(error);
}
});
} catch (_error: unknown) {
} catch (error_: unknown) {
// removes the folder function that failed to convert.
await remove(saveWikiFolderPath);
const error = _error instanceof Error ? _error : new Error(String(_error));
const error = error_ as Error;
throw error;
}
}
@ -48,8 +48,8 @@ export async function packetHTMLFromWikiFolder(folderWikiPath: string, pathOfNew
resolve();
},
});
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
reject(error);
}
});

View file

@ -233,8 +233,7 @@ export class WikiEmbeddingService implements IWikiEmbeddingService {
return Array.isArray(countResult) && countResult.length > 0 ? Number(countResult[0]) : 0;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to get total notes count', { function: 'getTotalNotesCount', error: errorMessage });
logger.error('Failed to get total notes count', { function: 'getTotalNotesCount', error });
return 0;
}
}
@ -714,7 +713,7 @@ export class WikiEmbeddingService implements IWikiEmbeddingService {
await this.statusRepository!.save(entity);
} catch (error) {
// If saving fails, just return the default status
logger.debug('could not save default embedding status', { function: 'getEmbeddingStatus', error: error instanceof Error ? error.message : String(error) });
logger.debug('could not save default embedding status', { function: 'getEmbeddingStatus', error });
}
return defaultStatus;
@ -759,7 +758,7 @@ export class WikiEmbeddingService implements IWikiEmbeddingService {
}).catch((error: unknown) => {
logger.error('Failed to initialize embedding status subscription', {
function: 'subscribeToEmbeddingStatus',
error: String(error),
error: error as Error,
});
});
}

View file

@ -55,8 +55,8 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
}),
]);
}
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
logger.error(`SyncBeforeShutdown failed`, { error });
} finally {
app.quit();
@ -98,9 +98,9 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
}
}
return newWorkspace;
} catch (_error: unknown) {
} catch (error_: unknown) {
// prepare to rollback changes
const error = _error instanceof Error ? _error : new Error(String(_error));
const error = error_ as Error;
const errorMessage = `initWikiGitTransaction failed, ${error.message} ${error.stack ?? ''}`;
logger.error(errorMessage);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
@ -112,9 +112,8 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
} else if (typeof mainWikiToLink === 'string') {
await wikiService.removeWiki(wikiFolderLocation, mainWikiToLink);
}
} catch (_error_) {
const error_ = _error_ instanceof Error ? _error_ : new Error(String(_error_));
throw new InitWikiGitRevertError(error_.message);
} catch (error_: unknown) {
throw new InitWikiGitRevertError(String(error_));
}
throw new InitWikiGitError(errorMessage);
}
@ -187,11 +186,10 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
logger.info('Default wiki workspace created successfully', {
function: 'WikiGitWorkspace.initialize',
});
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
} catch (error_: unknown) {
const error = error_ as Error;
logger.error('Failed to create default wiki workspace', {
error: error.message,
stack: error.stack,
error,
function: 'WikiGitWorkspace.initialize',
});
}
@ -223,9 +221,9 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
}
const wikiService = container.get<IWikiService>(serviceIdentifier.Wiki);
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
await wikiService.stopWiki(id).catch((_error: unknown) => {
const error = _error instanceof Error ? _error : new Error(String(_error));
logger.error(error.message, error);
await wikiService.stopWiki(id).catch((error_: unknown) => {
const error = error_ as Error;
logger.error(error.message, { error });
});
if (isSubWiki) {
if (mainWikiToLink === null) {
@ -251,9 +249,9 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
if (firstWorkspace !== undefined) {
await container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView).setActiveWorkspaceView(firstWorkspace.id);
}
} catch (_error: unknown) {
const error = _error instanceof Error ? _error : new Error(String(_error));
logger.error(error.message, error);
} catch (error_: unknown) {
const error = error_ as Error;
logger.error(error.message, { error });
}
}
}

View file

@ -15,7 +15,7 @@ export enum WindowNames {
* We only have a single instance of main window, that is the app window.
*/
main = 'main',
menuBar = 'menuBar',
tidgiMiniWindow = 'tidgiMiniWindow',
notifications = 'notifications',
preferences = 'preferences',
/**
@ -46,7 +46,7 @@ export const windowDimension: Record<WindowNames, { height?: number; width?: num
width: 1200,
height: 768,
},
[WindowNames.menuBar]: {
[WindowNames.tidgiMiniWindow]: {
width: 500,
height: 600,
},
@ -100,7 +100,7 @@ export interface WindowMeta {
[WindowNames.auth]: undefined;
[WindowNames.editWorkspace]: { workspaceID?: string };
[WindowNames.main]: { forceClose?: boolean };
[WindowNames.menuBar]: undefined;
[WindowNames.tidgiMiniWindow]: undefined;
[WindowNames.notifications]: undefined;
[WindowNames.preferences]: IPreferenceWindowMeta;
[WindowNames.spellcheck]: undefined;

View file

@ -1,23 +1,31 @@
import { MENUBAR_ICON_PATH } from '@/constants/paths';
import { TIDGI_MINI_WINDOW_ICON_PATH } from '@/constants/paths';
import { isMac } from '@/helpers/system';
import { container } from '@services/container';
import { i18n } from '@services/libs/i18n';
import { logger } from '@services/libs/log';
import type { IMenuService } from '@services/menu/interface';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IViewService } from '@services/view/interface';
import { BrowserWindowConstructorOptions, Menu, nativeImage, Tray } from 'electron';
import windowStateKeeper from 'electron-window-state';
import { debounce, merge as mergeDeep } from 'lodash';
import { debounce } from 'lodash';
import { Menubar, menubar } from 'menubar';
import type { IWindowService } from './interface';
import { getMainWindowEntry } from './viteEntry';
import { WindowNames } from './WindowProperties';
export async function handleAttachToMenuBar(windowConfig: BrowserWindowConstructorOptions, windowWithBrowserViewState: windowStateKeeper.State | undefined): Promise<Menubar> {
export async function handleAttachToTidgiMiniWindow(
windowConfig: BrowserWindowConstructorOptions,
windowWithBrowserViewState: windowStateKeeper.State | undefined,
): Promise<Menubar> {
const menuService = container.get<IMenuService>(serviceIdentifier.MenuService);
const windowService = container.get<IWindowService>(serviceIdentifier.Window);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
// Get tidgi mini window-specific titleBar preference
const tidgiMiniWindowShowTitleBar = await preferenceService.get('tidgiMiniWindowShowTitleBar');
// setImage after Tray instance is created to avoid
// "Segmentation fault (core dumped)" bug on Linux
@ -25,54 +33,71 @@ export async function handleAttachToMenuBar(windowConfig: BrowserWindowConstruct
// https://github.com/atomery/translatium/issues/164
const tray = new Tray(nativeImage.createEmpty());
// icon template is not supported on Windows & Linux
tray.setImage(MENUBAR_ICON_PATH);
tray.setImage(nativeImage.createFromPath(TIDGI_MINI_WINDOW_ICON_PATH));
const menuBar = menubar({
// Create tidgi mini window-specific window configuration
// Override titleBar settings from windowConfig with tidgi mini window-specific preference
const tidgiMiniWindowConfig: BrowserWindowConstructorOptions = {
...windowConfig,
show: false,
minHeight: 100,
minWidth: 250,
// Use tidgi mini window-specific titleBar setting instead of inheriting from main window
titleBarStyle: tidgiMiniWindowShowTitleBar ? 'default' : 'hidden',
frame: tidgiMiniWindowShowTitleBar,
// Always hide the menu bar (File, Edit, View menu), even when showing title bar
autoHideMenuBar: true,
};
logger.info('Creating tidgi mini window with titleBar configuration', {
function: 'handleAttachToTidgiMiniWindow',
tidgiMiniWindowShowTitleBar,
titleBarStyle: tidgiMiniWindowConfig.titleBarStyle,
frame: tidgiMiniWindowConfig.frame,
});
const tidgiMiniWindow = menubar({
index: getMainWindowEntry(),
tray,
activateWithApp: false,
showDockIcon: true,
preloadWindow: true,
tooltip: i18n.t('Menu.TidGiMenuBar'),
browserWindow: mergeDeep(windowConfig, {
show: false,
minHeight: 100,
minWidth: 250,
}),
tooltip: i18n.t('Menu.TidGiMiniWindow'),
browserWindow: tidgiMiniWindowConfig,
});
menuBar.on('after-create-window', () => {
if (menuBar.window !== undefined) {
menuBar.window.on('focus', async () => {
logger.debug('restore window position');
tidgiMiniWindow.on('after-create-window', () => {
if (tidgiMiniWindow.window !== undefined) {
tidgiMiniWindow.window.on('focus', async () => {
logger.debug('restore window position', { function: 'handleAttachToTidgiMiniWindow' });
if (windowWithBrowserViewState === undefined) {
logger.debug('windowWithBrowserViewState is undefined for menuBar');
logger.debug('windowWithBrowserViewState is undefined for tidgiMiniWindow', { function: 'handleAttachToTidgiMiniWindow' });
} else {
if (menuBar.window === undefined) {
logger.debug('menuBar.window is undefined');
if (tidgiMiniWindow.window === undefined) {
logger.debug('tidgiMiniWindow.window is undefined', { function: 'handleAttachToTidgiMiniWindow' });
} else {
const haveXYValue = [windowWithBrowserViewState.x, windowWithBrowserViewState.y].every((value) => Number.isFinite(value));
const haveWHValue = [windowWithBrowserViewState.width, windowWithBrowserViewState.height].every((value) => Number.isFinite(value));
if (haveXYValue) {
menuBar.window.setPosition(windowWithBrowserViewState.x, windowWithBrowserViewState.y, false);
tidgiMiniWindow.window.setPosition(windowWithBrowserViewState.x, windowWithBrowserViewState.y, false);
}
if (haveWHValue) {
menuBar.window.setSize(windowWithBrowserViewState.width, windowWithBrowserViewState.height, false);
tidgiMiniWindow.window.setSize(windowWithBrowserViewState.width, windowWithBrowserViewState.height, false);
}
}
}
const view = await viewService.getActiveBrowserView();
view?.webContents.focus();
});
menuBar.window.removeAllListeners('close');
menuBar.window.on('close', (event) => {
tidgiMiniWindow.window.removeAllListeners('close');
tidgiMiniWindow.window.on('close', (event) => {
event.preventDefault();
menuBar.hideWindow();
tidgiMiniWindow.hideWindow();
});
}
});
menuBar.on('hide', async () => {
// on mac, calling `menuBar.app.hide()` with main window open will bring background main window up, which we don't want. We want to bring previous other app up. So close main window first.
tidgiMiniWindow.on('hide', async () => {
// on mac, calling `tidgiMiniWindow.app.hide()` with main window open will bring background main window up, which we don't want. We want to bring previous other app up. So close main window first.
if (isMac) {
const mainWindow = windowService.get(WindowNames.main);
if (mainWindow?.isVisible() === true) {
@ -81,29 +106,29 @@ export async function handleAttachToMenuBar(windowConfig: BrowserWindowConstruct
}
});
// https://github.com/maxogden/menubar/issues/120
menuBar.on('after-hide', () => {
tidgiMiniWindow.on('after-hide', () => {
if (isMac) {
menuBar.app.hide();
tidgiMiniWindow.app.hide();
}
});
// manually save window state https://github.com/mawie81/electron-window-state/issues/64
const debouncedSaveWindowState = debounce(
() => {
if (menuBar.window !== undefined) {
windowWithBrowserViewState?.saveState(menuBar.window);
if (tidgiMiniWindow.window !== undefined) {
windowWithBrowserViewState?.saveState(tidgiMiniWindow.window);
}
},
500,
);
// menubar is hide, not close, so not managed by windowStateKeeper, need to save manually
menuBar.window?.on('resize', debouncedSaveWindowState);
menuBar.window?.on('move', debouncedSaveWindowState);
// tidgi mini window is hide, not close, so not managed by windowStateKeeper, need to save manually
tidgiMiniWindow.window?.on('resize', debouncedSaveWindowState);
tidgiMiniWindow.window?.on('move', debouncedSaveWindowState);
return await new Promise<Menubar>((resolve) => {
menuBar.on('ready', async () => {
tidgiMiniWindow.on('ready', async () => {
// right on tray icon
menuBar.tray.on('right-click', () => {
tidgiMiniWindow.tray.on('right-click', () => {
const contextMenu = Menu.buildFromTemplate([
{
label: i18n.t('ContextMenu.OpenTidGi'),
@ -112,9 +137,9 @@ export async function handleAttachToMenuBar(windowConfig: BrowserWindowConstruct
},
},
{
label: i18n.t('ContextMenu.OpenTidGiMenuBar'),
label: i18n.t('ContextMenu.OpenTidGiMiniWindow'),
click: async () => {
await menuBar.showWindow();
await tidgiMiniWindow.showWindow();
},
},
{
@ -143,23 +168,23 @@ export async function handleAttachToMenuBar(windowConfig: BrowserWindowConstruct
{
label: i18n.t('ContextMenu.Quit'),
click: () => {
menuBar.app.quit();
tidgiMiniWindow.app.quit();
},
},
]);
menuBar.tray.popUpContextMenu(contextMenu);
tidgiMiniWindow.tray.popUpContextMenu(contextMenu);
});
// right click on window content
if (menuBar.window?.webContents !== undefined) {
const unregisterContextMenu = await menuService.initContextMenuForWindowWebContents(menuBar.window.webContents);
menuBar.on('after-close', () => {
if (tidgiMiniWindow.window?.webContents !== undefined) {
const unregisterContextMenu = await menuService.initContextMenuForWindowWebContents(tidgiMiniWindow.window.webContents);
tidgiMiniWindow.on('after-close', () => {
unregisterContextMenu();
});
}
resolve(menuBar);
resolve(tidgiMiniWindow);
});
});
}

View file

@ -8,7 +8,9 @@ import { windowDimension, WindowMeta, WindowNames } from '@services/windows/Wind
import { Channels, MetaDataChannel, ViewChannel, WindowChannel } from '@/constants/channels';
import type { IPreferenceService } from '@services/preferences/interface';
import type { IViewService } from '@services/view/interface';
import type { IWorkspaceService } from '@services/workspaces/interface';
import type { IWorkspaceViewService } from '@services/workspacesView/interface';
import { SETTINGS_FOLDER } from '@/constants/appPaths';
import { isTest } from '@/constants/environment';
@ -19,8 +21,8 @@ import { container } from '@services/container';
import getViewBounds from '@services/libs/getViewBounds';
import { logger } from '@services/libs/log';
import type { IThemeService } from '@services/theme/interface';
import type { IViewService } from '@services/view/interface';
import { handleAttachToMenuBar } from './handleAttachToMenuBar';
import { getTidgiMiniWindowTargetWorkspace } from '@services/workspacesView/utilities';
import { handleAttachToTidgiMiniWindow } from './handleAttachToTidgiMiniWindow';
import { handleCreateBasicWindow } from './handleCreateBasicWindow';
import type { IWindowOpenConfig, IWindowService } from './interface';
import { registerBrowserViewWindowListeners } from './registerBrowserViewWindowListeners';
@ -31,8 +33,8 @@ import { getPreloadPath } from './viteEntry';
export class Window implements IWindowService {
private readonly windows = new Map<WindowNames, BrowserWindow>();
private windowMeta = {} as Partial<WindowMeta>;
/** menubar version of main window, if user set openInMenubar to true in preferences */
private mainWindowMenuBar?: Menubar;
/** tidgi mini window version of main window, if user set attachToTidgiMiniWindow to true in preferences */
private tidgiMiniWindowMenubar?: Menubar;
constructor(
@inject(serviceIdentifier.Preference) private readonly preferenceService: IPreferenceService,
@ -76,8 +78,8 @@ export class Window implements IWindowService {
}
public get(windowName: WindowNames = WindowNames.main): BrowserWindow | undefined {
if (windowName === WindowNames.menuBar) {
return this.mainWindowMenuBar?.window;
if (windowName === WindowNames.tidgiMiniWindow) {
return this.tidgiMiniWindowMenubar?.window;
}
return this.windows.get(windowName);
}
@ -124,8 +126,8 @@ export class Window implements IWindowService {
this.windows.clear();
}
public async isMenubarOpen(): Promise<boolean> {
return this.mainWindowMenuBar?.window?.isFocused() ?? false;
public async isTidgiMiniWindowOpen(): Promise<boolean> {
return this.tidgiMiniWindowMenubar?.window?.isVisible() ?? false;
}
public async open<N extends WindowNames>(windowName: N, meta?: WindowMeta[N], config?: IWindowOpenConfig<N>): Promise<undefined>;
@ -164,11 +166,11 @@ export class Window implements IWindowService {
// create new window
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const { hideMenuBar: autoHideMenuBar, titleBar: showTitleBar, menuBarAlwaysOnTop, alwaysOnTop } = preferenceService.getPreferences();
const { hideMenuBar: autoHideMenuBar, titleBar: showTitleBar, tidgiMiniWindowAlwaysOnTop, alwaysOnTop } = preferenceService.getPreferences();
let windowWithBrowserViewConfig: Partial<BrowserWindowConstructorOptions> = {};
let windowWithBrowserViewState: windowStateKeeperState | undefined;
const WindowToKeepPositionState = [WindowNames.main, WindowNames.menuBar];
const WindowWithBrowserView = [WindowNames.main, WindowNames.menuBar, WindowNames.secondary];
const WindowToKeepPositionState = [WindowNames.main, WindowNames.tidgiMiniWindow];
const WindowWithBrowserView = [WindowNames.main, WindowNames.tidgiMiniWindow, WindowNames.secondary];
const isWindowWithBrowserView = WindowWithBrowserView.includes(windowName);
if (WindowToKeepPositionState.includes(windowName)) {
windowWithBrowserViewState = windowStateKeeper({
@ -185,7 +187,7 @@ export class Window implements IWindowService {
};
}
// hide titleBar should not take effect on setting window
const hideTitleBar = [WindowNames.main, WindowNames.menuBar].includes(windowName) && !showTitleBar;
const hideTitleBar = [WindowNames.main, WindowNames.tidgiMiniWindow].includes(windowName) && !showTitleBar;
const windowConfig: BrowserWindowConstructorOptions = {
...windowDimension[windowName],
...windowWithBrowserViewConfig,
@ -197,7 +199,7 @@ export class Window implements IWindowService {
titleBarStyle: hideTitleBar ? 'hidden' : 'default',
// https://www.electronjs.org/docs/latest/tutorial/custom-title-bar#add-native-window-controls-windows-linux
...(hideTitleBar && process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
alwaysOnTop: windowName === WindowNames.menuBar ? menuBarAlwaysOnTop : alwaysOnTop,
alwaysOnTop: windowName === WindowNames.tidgiMiniWindow ? tidgiMiniWindowAlwaysOnTop : alwaysOnTop,
webPreferences: {
devTools: !isTest,
nodeIntegration: false,
@ -214,12 +216,12 @@ export class Window implements IWindowService {
parent: isWindowWithBrowserView ? undefined : this.get(WindowNames.main),
};
let newWindow: BrowserWindow;
if (windowName === WindowNames.menuBar) {
this.mainWindowMenuBar = await handleAttachToMenuBar(windowConfig, windowWithBrowserViewState);
if (this.mainWindowMenuBar.window === undefined) {
throw new Error('MenuBar failed to create window.');
if (windowName === WindowNames.tidgiMiniWindow) {
this.tidgiMiniWindowMenubar = await handleAttachToTidgiMiniWindow(windowConfig, windowWithBrowserViewState);
if (this.tidgiMiniWindowMenubar.window === undefined) {
throw new Error('TidgiMiniWindow failed to create window.');
}
newWindow = this.mainWindowMenuBar.window;
newWindow = this.tidgiMiniWindowMenubar.window;
} else {
newWindow = await handleCreateBasicWindow(windowName, windowConfig, meta, config);
if (isWindowWithBrowserView) {
@ -337,4 +339,243 @@ export class Window implements IWindowService {
}
}
}
public async toggleTidgiMiniWindow(): Promise<void> {
logger.info('toggleTidgiMiniWindow called', { function: 'toggleTidgiMiniWindow' });
try {
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const isOpen = await this.isTidgiMiniWindowOpen();
logger.debug('TidgiMiniWindow open status checked', { function: 'toggleTidgiMiniWindow', isOpen });
if (isOpen) {
logger.info('Closing tidgi mini window', { function: 'toggleTidgiMiniWindow' });
await this.closeTidgiMiniWindow();
} else {
const tidgiMiniWindow = await preferenceService.get('tidgiMiniWindow');
logger.debug('tidgiMiniWindow preference checked', { function: 'toggleTidgiMiniWindow', tidgiMiniWindow });
if (tidgiMiniWindow) {
logger.info('Opening tidgi mini window', { function: 'toggleTidgiMiniWindow' });
await this.openTidgiMiniWindow(true, true); // Explicitly show window when toggling
} else {
logger.warn('Cannot open tidgi mini window: tidgiMiniWindow preference is disabled', { function: 'toggleTidgiMiniWindow' });
}
}
} catch (error) {
logger.error('Failed to open/hide tidgi mini window', { error });
}
}
public async openTidgiMiniWindow(enableIt = true, showWindow = true): Promise<void> {
try {
// Check if tidgi mini window is already enabled
if (this.tidgiMiniWindowMenubar?.window !== undefined) {
logger.debug('TidGi mini window is already enabled, bring it to front', { function: 'openTidgiMiniWindow' });
if (showWindow) {
// Before showing, get the target workspace
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace();
logger.info('openTidgiMiniWindow: preparing to show window', {
function: 'openTidgiMiniWindow',
shouldSync,
targetWorkspaceId,
});
// Ensure view exists for the target workspace before realigning
if (targetWorkspaceId) {
const targetWorkspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(targetWorkspaceId);
if (targetWorkspace && !targetWorkspace.pageType) {
// This is a wiki workspace - ensure it has a view for tidgi mini window
const viewService = container.get<IViewService>(serviceIdentifier.View);
const existingView = viewService.getView(targetWorkspace.id, WindowNames.tidgiMiniWindow);
if (!existingView) {
logger.info('openTidgiMiniWindow: creating missing tidgi mini window view', {
function: 'openTidgiMiniWindow',
workspaceId: targetWorkspace.id,
});
await viewService.addView(targetWorkspace, WindowNames.tidgiMiniWindow);
}
}
logger.info('openTidgiMiniWindow: calling realignActiveWorkspace', {
function: 'openTidgiMiniWindow',
targetWorkspaceId,
});
await container.get<IWorkspaceViewService>(serviceIdentifier.WorkspaceView).realignActiveWorkspace(targetWorkspaceId);
logger.info('openTidgiMiniWindow: realignActiveWorkspace completed', {
function: 'openTidgiMiniWindow',
targetWorkspaceId,
});
}
// Use menuBar.showWindow() instead of direct window.show() for proper tidgi mini window behavior
void this.tidgiMiniWindowMenubar.showWindow();
}
return;
}
// Create tidgi mini window (create and open when enableIt is true)
await this.open(WindowNames.tidgiMiniWindow);
if (enableIt) {
logger.debug('TidGi mini window enabled', { function: 'openTidgiMiniWindow' });
// After creating the tidgi mini window, show it if requested
if (showWindow && this.tidgiMiniWindowMenubar) {
logger.debug('Showing newly created tidgi mini window', { function: 'openTidgiMiniWindow' });
void this.tidgiMiniWindowMenubar.showWindow();
}
}
} catch (error) {
logger.error('Failed to open tidgi mini window', { error, function: 'openTidgiMiniWindow' });
throw error;
}
}
public async closeTidgiMiniWindow(disableIt = false): Promise<void> {
try {
// Check if tidgi mini window exists
if (this.tidgiMiniWindowMenubar === undefined) {
logger.debug('TidGi mini window is already disabled', { function: 'closeTidgiMiniWindow' });
return;
}
const menuBar = this.tidgiMiniWindowMenubar;
if (disableIt) {
// Fully destroy tidgi mini window: destroy window and tray, then clear reference
if (menuBar.window) {
// remove custom close listener so destroy will actually close
menuBar.window.removeAllListeners('close');
menuBar.window.destroy();
}
// hide app on mac if needed
menuBar.app?.hide?.();
if (menuBar.tray && !menuBar.tray.isDestroyed()) {
menuBar.tray.destroy();
}
this.tidgiMiniWindowMenubar = undefined;
logger.debug('TidGi mini window disabled successfully without restart', { function: 'closeTidgiMiniWindow' });
} else {
// Only hide the tidgi mini window (keep tray and instance for re-open)
// Use menuBar.hideWindow() for proper tidgi mini window behavior
menuBar.hideWindow();
logger.debug('TidGi mini window closed (kept enabled)', { function: 'closeTidgiMiniWindow' });
}
} catch (error) {
logger.error('Failed to close tidgi mini window', { error });
throw error;
}
}
public async updateWindowProperties(windowName: WindowNames, properties: { alwaysOnTop?: boolean }): Promise<void> {
try {
const window = this.get(windowName);
if (window === undefined) {
logger.warn(`Window ${windowName} not found for property update`);
return;
}
if (properties.alwaysOnTop !== undefined) {
window.setAlwaysOnTop(properties.alwaysOnTop);
logger.info(`Updated ${windowName} alwaysOnTop to ${properties.alwaysOnTop}`);
}
// Handle tidgi mini window specific properties
if (windowName === WindowNames.tidgiMiniWindow && this.tidgiMiniWindowMenubar?.window) {
if (properties.alwaysOnTop !== undefined) {
this.tidgiMiniWindowMenubar.window.setAlwaysOnTop(properties.alwaysOnTop);
}
}
} catch (error) {
logger.error(`Failed to update window properties for ${windowName}`, { error, properties });
throw error;
}
}
public async reactWhenPreferencesChanged(key: string, value: unknown): Promise<void> {
switch (key) {
case 'tidgiMiniWindow': {
if (value) {
// Enable tidgi mini window without showing the window; visibility controlled by toggle/shortcut
await this.openTidgiMiniWindow(true, false);
// After enabling tidgi mini window, create view for the current active workspace (if it's a wiki workspace)
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const activeWorkspace = await workspaceService.getActiveWorkspace();
if (activeWorkspace && !activeWorkspace.pageType) {
// This is a wiki workspace - ensure it has a view for tidgi mini window
const existingView = viewService.getView(activeWorkspace.id, WindowNames.tidgiMiniWindow);
if (!existingView) {
await viewService.addView(activeWorkspace, WindowNames.tidgiMiniWindow);
}
}
} else {
await this.closeTidgiMiniWindow(true);
}
return;
}
case 'tidgiMiniWindowSyncWorkspaceWithMainWindow':
case 'tidgiMiniWindowFixedWorkspaceId': {
logger.info('Preference changed', { function: 'reactWhenPreferencesChanged', key, value: JSON.stringify(value) });
// When switching to sync with main window, hide the sidebar
if (key === 'tidgiMiniWindowSyncWorkspaceWithMainWindow' && value === true) {
await this.preferenceService.set('tidgiMiniWindowShowSidebar', false);
}
// When tidgi mini window workspace settings change, hide all views and let the next window show trigger realignment
const tidgiMiniWindow = this.get(WindowNames.tidgiMiniWindow);
if (tidgiMiniWindow) {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const viewService = container.get<IViewService>(serviceIdentifier.View);
const allWorkspaces = await workspaceService.getWorkspacesAsList();
logger.debug(`Hiding all tidgi mini window views (${allWorkspaces.length} workspaces)`, { function: 'reactWhenPreferencesChanged', key });
// Hide all views - the correct view will be shown when window is next opened
await Promise.all(
allWorkspaces.map(async (workspace) => {
const view = viewService.getView(workspace.id, WindowNames.tidgiMiniWindow);
if (view) {
await viewService.hideView(tidgiMiniWindow, WindowNames.tidgiMiniWindow, workspace.id);
}
}),
);
// View creation is handled by openTidgiMiniWindow when the window is shown
} else {
logger.warn('tidgiMiniWindow not found, skipping view management', { function: 'reactWhenPreferencesChanged', key });
}
return;
}
case 'tidgiMiniWindowAlwaysOnTop': {
await this.updateWindowProperties(WindowNames.tidgiMiniWindow, { alwaysOnTop: value as boolean });
return;
}
case 'tidgiMiniWindowShowTitleBar': {
// Title bar style requires recreating the window
// We need to fully destroy and recreate the tidgi mini window with new titleBar settings
logger.info('tidgiMiniWindowShowTitleBar changed, recreating tidgi mini window', {
function: 'reactWhenPreferencesChanged',
newValue: value,
});
const wasVisible = await this.isTidgiMiniWindowOpen();
logger.debug('Current tidgi mini window visibility', {
function: 'reactWhenPreferencesChanged',
wasVisible,
});
// Fully destroy current tidgi mini window (disableIt = true)
await this.closeTidgiMiniWindow(true);
logger.debug('Tidgi mini window destroyed', { function: 'reactWhenPreferencesChanged' });
// Reopen tidgi mini window with new titleBar setting from updated preferences
// enableIt = true to recreate, showWindow = wasVisible to restore visibility
await this.openTidgiMiniWindow(true, wasVisible);
logger.info('Tidgi mini window recreated with new titleBar setting', {
function: 'reactWhenPreferencesChanged',
showWindow: wasVisible,
});
return;
}
default:
break;
}
}
}

View file

@ -37,7 +37,7 @@ export interface IWindowService {
*/
hide(windowName: WindowNames): Promise<void>;
isFullScreen(windowName?: WindowNames): Promise<boolean | undefined>;
isMenubarOpen(): Promise<boolean>;
isTidgiMiniWindowOpen(): Promise<boolean>;
loadURL(windowName: WindowNames, newUrl?: string): Promise<void>;
maximize(): Promise<void>;
/**
@ -55,7 +55,16 @@ export interface IWindowService {
set(windowName: WindowNames, win: BrowserWindow | undefined): void;
setWindowMeta<N extends WindowNames>(windowName: N, meta?: WindowMeta[N]): Promise<void>;
stopFindInPage(close?: boolean, windowName?: WindowNames): Promise<void>;
toggleTidgiMiniWindow(): Promise<void>;
updateWindowMeta<N extends WindowNames>(windowName: N, meta?: WindowMeta[N]): Promise<void>;
/** Open tidgi mini window without restart - hot reload. enableIt=true means fully enable and open. */
openTidgiMiniWindow(enableIt?: boolean, showWindow?: boolean): Promise<void>;
/** Close tidgi mini window. disableIt=true means fully disable and cleanup tray. */
closeTidgiMiniWindow(disableIt?: boolean): Promise<void>;
/** Update window properties without restart - hot reload */
updateWindowProperties(windowName: WindowNames, properties: { alwaysOnTop?: boolean }): Promise<void>;
/** React to preference changes related to windows (tidgi mini window etc.) */
reactWhenPreferencesChanged(key: string, value: unknown): Promise<void>;
}
export const WindowServiceIPCDescriptor = {
channel: WindowChannel.name,
@ -69,7 +78,7 @@ export const WindowServiceIPCDescriptor = {
goForward: ProxyPropertyType.Function,
goHome: ProxyPropertyType.Function,
isFullScreen: ProxyPropertyType.Function,
isMenubarOpen: ProxyPropertyType.Function,
isTidgiMiniWindowOpen: ProxyPropertyType.Function,
loadURL: ProxyPropertyType.Function,
maximize: ProxyPropertyType.Function,
open: ProxyPropertyType.Function,
@ -78,6 +87,10 @@ export const WindowServiceIPCDescriptor = {
sendToAllWindows: ProxyPropertyType.Function,
setWindowMeta: ProxyPropertyType.Function,
stopFindInPage: ProxyPropertyType.Function,
toggleTidgiMiniWindow: ProxyPropertyType.Function,
updateWindowMeta: ProxyPropertyType.Function,
openTidgiMiniWindow: ProxyPropertyType.Function,
closeTidgiMiniWindow: ProxyPropertyType.Function,
updateWindowProperties: ProxyPropertyType.Function,
},
};

View file

@ -123,10 +123,6 @@ export async function registerMenu(): Promise<void> {
// if back is called in popup window
// navigate in the popup window instead
if (browserWindow !== undefined) {
// TODO: test if we really can get this isPopup value, and it works for help page popup and menubar window
// const { isPopup = false } = await getFromRenderer<IBrowserViewMetaData>(MetaDataChannel.getViewMetaData, browserWindow);
// const windowName = isPopup ? WindowNames.menuBar : WindowNames.main
await windowService.goForward();
}
ipcMain.emit('request-go-forward');

View file

@ -24,6 +24,7 @@ import { DELAY_MENU_REGISTER } from '@/constants/parameters';
import type { ISyncService } from '@services/sync/interface';
import type { IInitializeWorkspaceOptions, IWorkspaceViewService } from './interface';
import { registerMenu } from './registerMenu';
import { getTidgiMiniWindowTargetWorkspace } from './utilities';
@injectable()
export class WorkspaceView implements IWorkspaceViewService {
@ -153,11 +154,9 @@ export class WorkspaceView implements IWorkspaceViewService {
}
}
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('wikiStartup sync failed', {
function: 'initializeAllWorkspaceView',
error: error_.message,
stack: error_.stack ?? 'no stack',
error,
});
}
};
@ -173,7 +172,7 @@ export class WorkspaceView implements IWorkspaceViewService {
logger.debug('Skip because alreadyHaveView');
return;
}
// Create browserView, and if user want a menubar, we also create a new window for that
// Create browserView, and if user want a tidgi mini window, we also create a new window for that
await this.addViewForAllBrowserViews(workspace);
if (isNew && options.from === WikiCreationMethod.Create) {
const view = container.get<IViewService>(serviceIdentifier.View).getView(workspace.id, WindowNames.main);
@ -205,12 +204,28 @@ export class WorkspaceView implements IWorkspaceViewService {
}
public async addViewForAllBrowserViews(workspace: IWorkspace): Promise<void> {
await Promise.all([
container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.main),
this.preferenceService.get('attachToMenubar').then(async (attachToMenubar) => {
return await (attachToMenubar && container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.menuBar));
}),
]);
const mainTask = container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.main);
// For tidgi mini window, decide which workspace to show based on preferences
const tidgiMiniWindowTask = (async () => {
const tidgiMiniWindow = await this.preferenceService.get('tidgiMiniWindow');
if (!tidgiMiniWindow) {
return;
}
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspace.id);
// If syncing with main window, use the current workspace
if (shouldSync) {
await container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.tidgiMiniWindow);
return;
}
// If not syncing and a fixed workspace is set, only add view if this IS the fixed workspace
if (targetWorkspaceId && workspace.id === targetWorkspaceId) {
await container.get<IViewService>(serviceIdentifier.View).addView(workspace, WindowNames.tidgiMiniWindow);
}
// If not syncing and no fixed workspace is set, don't add any view (user needs to select one)
})();
await Promise.all([mainTask, tidgiMiniWindowTask]);
}
public async openWorkspaceWindowWithView(workspace: IWorkspace, configs?: { uri?: string }): Promise<void> {
@ -355,11 +370,9 @@ export class WorkspaceView implements IWorkspaceViewService {
await container.get<IViewService>(serviceIdentifier.View).setActiveViewForAllBrowserViews(nextWorkspaceID);
await this.realignActiveWorkspace(nextWorkspaceID);
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('setActiveWorkspaceView error', {
function: 'setActiveWorkspaceView',
error: error_.message,
errorObj: error_,
error,
});
throw error;
}
@ -388,11 +401,9 @@ export class WorkspaceView implements IWorkspaceViewService {
try {
await this.hideWorkspaceView(activeWorkspace.id);
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('setActiveWorkspaceView error', {
function: 'clearActiveWorkspaceView',
error: error_.message,
errorObj: error_,
error,
});
throw error;
}
@ -461,7 +472,7 @@ export class WorkspaceView implements IWorkspaceViewService {
await Promise.all(
workspaces.map(async (workspace) => {
await Promise.all(
[WindowNames.main, WindowNames.menuBar].map(async (windowName) => {
[WindowNames.main, WindowNames.tidgiMiniWindow].map(async (windowName) => {
const view = container.get<IViewService>(serviceIdentifier.View).getView(workspace.id, windowName);
if (view !== undefined) {
await container.get<IViewService>(serviceIdentifier.View).loadUrlForView(workspace, view);
@ -529,11 +540,9 @@ export class WorkspaceView implements IWorkspaceViewService {
try {
await container.get<IMenuService>(serviceIdentifier.MenuService).buildMenu();
} catch (error) {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('realignActiveWorkspace buildMenu error', {
function: 'realignActiveWorkspace',
error: error_.message,
errorObj: error_,
error,
});
throw error;
}
@ -556,7 +565,7 @@ export class WorkspaceView implements IWorkspaceViewService {
return;
}
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
const menuBarWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.menuBar);
const tidgiMiniWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.tidgiMiniWindow);
logger.info(
`realignActiveWorkspaceView: id ${workspaceToRealign?.id ?? 'undefined'}`,
@ -565,7 +574,7 @@ export class WorkspaceView implements IWorkspaceViewService {
logger.warn('realignActiveWorkspaceView: no active workspace');
return;
}
if (mainWindow === undefined && menuBarWindow === undefined) {
if (mainWindow === undefined && tidgiMiniWindow === undefined) {
logger.warn('realignActiveWorkspaceView: no active window');
return;
}
@ -576,18 +585,20 @@ export class WorkspaceView implements IWorkspaceViewService {
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(mainWindow, workspaceToRealign.id, WindowNames.main));
logger.debug(`realignActiveWorkspaceView: realign main window for ${workspaceToRealign.id}.`);
}
if (menuBarWindow === undefined) {
logger.info(`realignActiveWorkspaceView: no menuBarBrowserViewWebContent, skip menu bar window for ${workspaceToRealign.id}.`);
if (tidgiMiniWindow === undefined) {
logger.info(`realignActiveWorkspaceView: no tidgiMiniWindowBrowserViewWebContent, skip tidgi mini window for ${workspaceToRealign.id}.`);
} else {
logger.debug(`realignActiveWorkspaceView: realign menu bar window for ${workspaceToRealign.id}.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(menuBarWindow, workspaceToRealign.id, WindowNames.menuBar));
// For tidgi mini window, decide which workspace to show based on preferences
const { targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(workspaceToRealign.id);
const tidgiMiniWindowWorkspaceId = targetWorkspaceId || workspaceToRealign.id;
tasks.push(container.get<IViewService>(serviceIdentifier.View).realignActiveView(tidgiMiniWindow, tidgiMiniWindowWorkspaceId, WindowNames.tidgiMiniWindow));
}
await Promise.all(tasks);
}
private async hideWorkspaceView(idToDeactivate: string): Promise<void> {
const mainWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.main);
const menuBarWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.menuBar);
const tidgiMiniWindow = container.get<IWindowService>(serviceIdentifier.Window).get(WindowNames.tidgiMiniWindow);
const tasks = [];
if (mainWindow === undefined) {
logger.warn(`hideWorkspaceView: no mainBrowserWindow, skip main window browserView.`);
@ -595,11 +606,20 @@ export class WorkspaceView implements IWorkspaceViewService {
logger.info(`hideWorkspaceView: hide main window browserView.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).hideView(mainWindow, WindowNames.main, idToDeactivate));
}
if (menuBarWindow === undefined) {
logger.debug(`hideWorkspaceView: no menuBarBrowserWindow, skip menu bar window browserView.`);
if (tidgiMiniWindow === undefined) {
logger.debug(`hideWorkspaceView: no tidgiMiniWindowBrowserWindow, skip tidgi mini window browserView.`);
} else {
logger.info(`hideWorkspaceView: hide menu bar window browserView.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).hideView(menuBarWindow, WindowNames.menuBar, idToDeactivate));
// For tidgi mini window, only hide if syncing with main window OR if this is the fixed workspace being deactivated
const { shouldSync, targetWorkspaceId } = await getTidgiMiniWindowTargetWorkspace(idToDeactivate);
// Only hide tidgi mini window view if:
// 1. Syncing with main window (should hide when main window hides)
// 2. OR the workspace being hidden is the fixed workspace (rare case, but should be handled)
if (shouldSync || idToDeactivate === targetWorkspaceId) {
logger.info(`hideWorkspaceView: hide tidgi mini window browserView.`);
tasks.push(container.get<IViewService>(serviceIdentifier.View).hideView(tidgiMiniWindow, WindowNames.tidgiMiniWindow, idToDeactivate));
} else {
logger.debug(`hideWorkspaceView: skip hiding tidgi mini window browserView (fixed workspace: ${targetWorkspaceId || 'none'}).`);
}
}
await Promise.all(tasks);
logger.info(`hideWorkspaceView: done.`);

View file

@ -65,8 +65,7 @@ export async function registerMenu(): Promise<void> {
if (error instanceof DownloadCancelError) {
logger.debug('cancelled', { function: 'registerMenu.printPage' });
} else {
const error_ = error instanceof Error ? error : new Error(String(error));
logger.error('print page error', { function: 'registerMenu.printPage', error: error_.message, errorObj: error_ });
logger.error('print page error', { function: 'registerMenu.printPage', error });
}
}
},

View file

@ -0,0 +1,34 @@
import { container } from '@services/container';
import type { IPreferenceService } from '@services/preferences/interface';
import serviceIdentifier from '@services/serviceIdentifier';
import type { IWorkspaceService } from '@services/workspaces/interface';
/**
* Helper function to determine the target workspace for tidgi mini window based on preferences
* @param fallbackWorkspaceId - The workspace ID to use as fallback (usually the active/current workspace)
* @returns Object containing shouldSync flag and targetWorkspaceId
*/
export async function getTidgiMiniWindowTargetWorkspace(fallbackWorkspaceId?: string): Promise<{
shouldSync: boolean;
targetWorkspaceId: string | undefined;
}> {
const preferenceService = container.get<IPreferenceService>(serviceIdentifier.Preference);
const [tidgiMiniWindowSyncWorkspaceWithMainWindow, tidgiMiniWindowFixedWorkspaceId] = await Promise.all([
preferenceService.get('tidgiMiniWindowSyncWorkspaceWithMainWindow'),
preferenceService.get('tidgiMiniWindowFixedWorkspaceId'),
]);
// Default to sync (undefined means default to true, or explicitly true)
const shouldSync = tidgiMiniWindowSyncWorkspaceWithMainWindow === undefined || tidgiMiniWindowSyncWorkspaceWithMainWindow;
let targetWorkspaceId: string | undefined;
if (shouldSync) {
// Sync with main window - use fallback or active workspace
targetWorkspaceId = fallbackWorkspaceId ?? (await container.get<IWorkspaceService>(serviceIdentifier.Workspace).getActiveWorkspace())?.id;
} else {
// Use fixed workspace
targetWorkspaceId = tidgiMiniWindowFixedWorkspaceId;
}
return { shouldSync, targetWorkspaceId };
}

View file

@ -23,6 +23,7 @@ import { Search } from './sections/Search';
import { Sync } from './sections/Sync';
import { System } from './sections/System';
import { TiddlyWiki } from './sections/TiddlyWiki';
import { TidGiMiniWindow } from './sections/TidGiMiniWindow';
import { Updates } from './sections/Updates';
import { SectionSideBar } from './SectionsSideBar';
import { usePreferenceSections } from './useSections';
@ -66,6 +67,7 @@ export default function Preferences(): React.JSX.Element {
<Inner>
<TiddlyWiki sections={sections} requestRestartCountDown={requestRestartCountDown} />
<General sections={sections} requestRestartCountDown={requestRestartCountDown} />
<TidGiMiniWindow sections={sections} />
<Sync sections={sections} requestRestartCountDown={requestRestartCountDown} />
<ExternalAPI sections={sections} />
<AIAgent sections={sections} />

View file

@ -24,7 +24,7 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
'AIAgent: fetch agent database info failed',
{
function: 'AIAgent.fetchInfo',
error: String(error),
error,
},
);
}
@ -54,7 +54,7 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
'AIAgent: open database folder failed',
{
function: 'AIAgent.openDatabaseFolder',
error: String(error),
error,
path: agentInfo.path,
},
);
@ -118,7 +118,7 @@ export function AIAgent(props: ISectionProps): React.JSX.Element {
'AIAgent: delete agent database failed',
{
function: 'AIAgent.handleDelete',
error: String(error),
error,
},
);
}

View file

@ -33,7 +33,7 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
'DeveloperTools: fetch externalApi database info failed',
{
function: 'DeveloperTools.fetchInfo',
error: String(error),
error,
},
);
}
@ -79,7 +79,7 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
'DeveloperTools: open V8 cache folder failed',
{
function: 'DeveloperTools.openV8CacheFolder',
error: String(error),
error: error as Error,
},
);
}
@ -133,7 +133,7 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
'DeveloperTools: open externalApi database folder failed',
{
function: 'DeveloperTools.openExternalApiDatabaseFolder',
error: String(error),
error,
path: externalApiInfo.path,
},
);
@ -202,7 +202,7 @@ export function DeveloperTools(props: ISectionProps): React.JSX.Element {
'DeveloperTools: delete externalApi database failed',
{
function: 'DeveloperTools.handleDelete',
error: String(error),
error,
},
);
}

View file

@ -29,7 +29,7 @@ export function Downloads(props: Required<ISectionProps>): React.JSX.Element {
}
})
.catch((error: unknown) => {
void window.service.native.log('error', 'Preferences: pickDirectory failed', { function: 'Downloads.pickDirectory', error: String(error) });
void window.service.native.log('error', 'Preferences: pickDirectory failed', { function: 'Downloads.pickDirectory', error });
});
}}
>

Some files were not shown because too many files have changed in this diff Show more