mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-06 02:30:47 -08:00
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:
parent
fa9751e5ea
commit
19ef74a4a6
111 changed files with 4588 additions and 884 deletions
|
|
@ -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;
|
||||
|
||||
// Special case for 'current' window
|
||||
if (windowType === 'current') {
|
||||
return this.currentWindow;
|
||||
}
|
||||
|
||||
// Use the findWindowByType method with retry logic
|
||||
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;
|
||||
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 1 second and retry (except for the last attempt)
|
||||
// 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.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue