diff --git a/apps/remix-ide-e2e/src/commands/hideToolTips.ts b/apps/remix-ide-e2e/src/commands/hideToolTips.ts index dc8715f0a86..8fd2f07d51f 100644 --- a/apps/remix-ide-e2e/src/commands/hideToolTips.ts +++ b/apps/remix-ide-e2e/src/commands/hideToolTips.ts @@ -2,31 +2,49 @@ import { NightwatchBrowser } from 'nightwatch' import EventEmitter from 'events' class HideToolTips extends EventEmitter { - command(this: NightwatchBrowser) { - console.log('Hiding tooltips...') + command(this: NightwatchBrowser): NightwatchBrowser { + const browser = this.api browser - .perform((done) => { - browser.execute(function () { - // hide tooltips - function addStyle(styleString) { - const style = document.createElement('style') - style.textContent = styleString - document.head.append(style) + .execute(function () { + // Set global flag to disable all CustomTooltip components + (window as any).REMIX_DISABLE_TOOLTIPS = true + + // Dispatch custom event to notify all CustomTooltip components + const event = new CustomEvent('remix-tooltip-toggle', { + detail: { disabled: true } + }) + window.dispatchEvent(event) + + // Add CSS as backup for any non-CustomTooltip tooltips + const style = document.createElement('style') + style.id = 'nightwatch-disable-tooltips' + style.textContent = ` + .tooltip, + .popover, + [role="tooltip"], + [id*="Tooltip"], + [id*="tooltip"] { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; } - addStyle(` - .popover { - display:none !important; - } - #scamDetails { - display:none !important; - } - `) - }, [], done()) - }) - .perform((done) => { - done() + ` + const existing = document.getElementById('nightwatch-disable-tooltips') + if (existing) existing.remove() + document.head.appendChild(style) + + // Remove any existing tooltips from DOM + document.querySelectorAll('.tooltip, .popover, [role="tooltip"]').forEach(el => { + try { el.remove() } catch (e) {} + }) + }, []) + .pause(100) + .perform(() => { this.emit('complete') }) + + return browser } } diff --git a/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts b/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts index b68425bc7e4..6a2b1bc4eed 100644 --- a/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts +++ b/apps/remix-ide/src/app/matomo/MatomoAutoInit.ts @@ -44,6 +44,16 @@ export async function autoInitializeMatomo(options: MatomoAutoInitOptions): Prom } try { + // Check for Electron - always initialize in anonymous mode (no consent needed) + const isElectron = (window as any).electronAPI !== undefined; + if (isElectron) { + log('Electron detected, auto-initializing in anonymous mode (server-side tracking)'); + await matomoManager.initialize('anonymous'); + await matomoManager.processPreInitQueue(); + log('Electron Matomo initialized and pre-init queue processed'); + return true; + } + // Check if we should show the consent dialog const shouldShowDialog = matomoManager.shouldShowConsentDialog(config); diff --git a/apps/remix-ide/src/app/matomo/MatomoConfig.ts b/apps/remix-ide/src/app/matomo/MatomoConfig.ts index 7415f656c87..e5aacb3ad42 100644 --- a/apps/remix-ide/src/app/matomo/MatomoConfig.ts +++ b/apps/remix-ide/src/app/matomo/MatomoConfig.ts @@ -4,6 +4,7 @@ * Single source of truth for Matomo site IDs and configuration */ +import isElectron from 'is-electron'; import { MatomoConfig } from './MatomoManager'; // ================ DEVELOPER CONFIGURATION ================ @@ -34,7 +35,7 @@ export interface DomainCustomDimensions { } // Type for domain keys (single source of truth) -export type MatomotDomain = 'alpha.remix.live' | 'beta.remix.live' | 'remix.ethereum.org' | 'localhost' | '127.0.0.1'; +export type MatomotDomain = 'alpha.remix.live' | 'beta.remix.live' | 'remix.ethereum.org' | 'localhost' | '127.0.0.1' | 'electron'; // Type for site ID configuration export type SiteIdConfig = Record; @@ -54,7 +55,8 @@ export const MATOMO_DOMAINS: SiteIdConfig = { 'beta.remix.live': 2, 'remix.ethereum.org': 3, 'localhost': 5, - '127.0.0.1': 5 + '127.0.0.1': 5, + 'electron': 4 // Remix Desktop (Electron) app }; // Bot tracking site IDs (separate databases to avoid polluting human analytics) @@ -64,7 +66,8 @@ export const MATOMO_BOT_SITE_IDS: BotSiteIdConfig = { 'beta.remix.live': null, // TODO: Create bot tracking site in Matomo (e.g., site ID 11) 'remix.ethereum.org': 8, // TODO: Create bot tracking site in Matomo (e.g., site ID 12) 'localhost': 7, // Keep bots in same localhost site for testing (E2E tests need cookies) - '127.0.0.1': 7 // Keep bots in same localhost site for testing (E2E tests need cookies) + '127.0.0.1': 7, // Keep bots in same localhost site for testing (E2E tests need cookies) + 'electron': null // Electron app uses same site ID for bots (filtered via isBot dimension) }; // Domain-specific custom dimension IDs for HUMAN traffic @@ -96,6 +99,12 @@ export const MATOMO_CUSTOM_DIMENSIONS: CustomDimensionsConfig = { trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode clickAction: 3, // Dimension for 'true'/'false' click tracking isBot: 4 // Dimension for 'human'/'bot'/'automation' detection + }, + // Electron Desktop App + electron: { + trackingMode: 1, // Dimension for 'anon'/'cookie' tracking mode + clickAction: 2, // Dimension for 'true'/'false' click tracking + isBot: 3 // Dimension for 'human'/'bot'/'automation' detection } }; @@ -119,9 +128,24 @@ export const MATOMO_BOT_CUSTOM_DIMENSIONS: BotCustomDimensionsConfig = { trackingMode: 1, clickAction: 3, isBot: 2 - } + }, + 'electron': null // Electron app uses same custom dimensions as human traffic }; +/** + * Get the appropriate domain key for tracking + * Returns 'electron' for Electron app, otherwise returns the hostname + */ +export function getDomainKey(): MatomotDomain { + if (isElectron()) { + return 'electron'; + } + + const hostname = window.location.hostname as MatomotDomain; + // Return hostname if it's a known domain, otherwise default to localhost + return MATOMO_DOMAINS[hostname] !== undefined ? hostname : 'localhost'; +} + /** * Get the appropriate site ID for the current domain and bot status * @@ -129,15 +153,15 @@ export const MATOMO_BOT_CUSTOM_DIMENSIONS: BotCustomDimensionsConfig = { * @returns Site ID to use for tracking */ export function getSiteIdForTracking(isBot: boolean): number { - const hostname = window.location.hostname; + const domainKey = getDomainKey(); // If bot and bot site ID is configured, use it - if (isBot && MATOMO_BOT_SITE_IDS[hostname] !== null && MATOMO_BOT_SITE_IDS[hostname] !== undefined) { - return MATOMO_BOT_SITE_IDS[hostname]; + if (isBot && MATOMO_BOT_SITE_IDS[domainKey] !== null && MATOMO_BOT_SITE_IDS[domainKey] !== undefined) { + return MATOMO_BOT_SITE_IDS[domainKey]; } // Otherwise use normal site ID - return MATOMO_DOMAINS[hostname] || MATOMO_DOMAINS['localhost']; + return MATOMO_DOMAINS[domainKey]; } /** @@ -146,21 +170,15 @@ export function getSiteIdForTracking(isBot: boolean): number { * @param isBot - Whether the visitor is detected as a bot (to use bot-specific dimensions if configured) */ export function getDomainCustomDimensions(isBot: boolean = false): DomainCustomDimensions { - const hostname = window.location.hostname; + const domainKey = getDomainKey(); // If bot and bot-specific dimensions are configured, use them - if (isBot && MATOMO_BOT_CUSTOM_DIMENSIONS[hostname] !== null && MATOMO_BOT_CUSTOM_DIMENSIONS[hostname] !== undefined) { - return MATOMO_BOT_CUSTOM_DIMENSIONS[hostname]; + if (isBot && MATOMO_BOT_CUSTOM_DIMENSIONS[domainKey] !== null && MATOMO_BOT_CUSTOM_DIMENSIONS[domainKey] !== undefined) { + return MATOMO_BOT_CUSTOM_DIMENSIONS[domainKey]; } // Return dimensions for current domain - if (MATOMO_CUSTOM_DIMENSIONS[hostname]) { - return MATOMO_CUSTOM_DIMENSIONS[hostname]; - } - - // Fallback to localhost if domain not found - console.warn(`No custom dimensions found for domain: ${hostname}, using localhost fallback`); - return MATOMO_CUSTOM_DIMENSIONS['localhost']; + return MATOMO_CUSTOM_DIMENSIONS[domainKey]; } /** diff --git a/apps/remix-ide/src/app/matomo/MatomoManager.ts b/apps/remix-ide/src/app/matomo/MatomoManager.ts index d297a5e45ca..b320a799635 100644 --- a/apps/remix-ide/src/app/matomo/MatomoManager.ts +++ b/apps/remix-ide/src/app/matomo/MatomoManager.ts @@ -319,6 +319,14 @@ export class MatomoManager implements IMatomoManager { this.emit('log', { message, data, timestamp }); } + /** + * Check if running in Electron environment + */ + private isElectronApp(): boolean { + return typeof window !== 'undefined' && + (window as any).electronAPI !== undefined; + } + private setupPaqInterception(): void { this.log('Setting up _paq interception'); if (typeof window === 'undefined') return; @@ -825,40 +833,63 @@ export class MatomoManager implements IMatomoManager { trackEvent(eventObjOrCategory: MatomoEvent | string, action?: string, name?: string, value?: string | number): number { const eventId = ++this.state.lastEventId; + // Extract event parameters + let category: string; + let eventAction: string; + let eventName: string | undefined; + let eventValue: string | number | undefined; + let isClick: boolean | undefined; + // If first parameter is a MatomoEvent object, use type-safe approach if (typeof eventObjOrCategory === 'object' && eventObjOrCategory !== null && 'category' in eventObjOrCategory) { - const { category, action: eventAction, name: eventName, value: eventValue, isClick } = eventObjOrCategory; + category = eventObjOrCategory.category; + eventAction = eventObjOrCategory.action; + eventName = eventObjOrCategory.name; + eventValue = eventObjOrCategory.value; + isClick = eventObjOrCategory.isClick; + this.log(`Tracking type-safe event ${eventId}: ${category} / ${eventAction} / ${eventName} / ${eventValue} / isClick: ${isClick}`); + } else { + // Legacy string-based approach + category = eventObjOrCategory as string; + eventAction = action!; + eventName = name; + eventValue = value; - // Set custom action dimension for click tracking - if (isClick !== undefined) { - window._paq.push(['setCustomDimension', this.customDimensions.clickAction, isClick ? 'true' : 'false']); - } + this.log(`Tracking legacy event ${eventId}: ${category} / ${eventAction} / ${eventName} / ${eventValue} (⚠️ no click dimension)`); + } - const matomoEvent: MatomoCommand = ['trackEvent', category, eventAction]; - if (eventName !== undefined) matomoEvent.push(eventName); - if (eventValue !== undefined) matomoEvent.push(eventValue); + // Check if running in Electron - use IPC bridge instead of _paq + if (this.isElectronApp()) { + this.log(`Electron detected - routing event through IPC bridge`); - window._paq.push(matomoEvent); - this.emit('event-tracked', { eventId, category, action: eventAction, name: eventName, value: eventValue, isClick }); + const electronAPI = (window as any).electronAPI; + if (electronAPI && electronAPI.trackEvent) { + // Pass isClick as the 6th parameter + const eventData = ['trackEvent', category, eventAction, eventName || '', eventValue, isClick]; + electronAPI.trackEvent(eventData).catch((err: any) => { + console.error('[Matomo] Failed to send event to Electron:', err); + }); + } + this.emit('event-tracked', { eventId, category, action: eventAction, name: eventName, value: eventValue, isClick }); return eventId; } - // Legacy string-based approach - no isClick dimension set - const category = eventObjOrCategory as string; - this.log(`Tracking legacy event ${eventId}: ${category} / ${action} / ${name} / ${value} (⚠️ no click dimension)`); + // Standard web tracking using _paq + if (isClick !== undefined) { + window._paq.push(['setCustomDimension', this.customDimensions.clickAction, isClick ? 'true' : 'false']); + } - const matomoEvent: MatomoCommand = ['trackEvent', category, action!]; - if (name !== undefined) matomoEvent.push(name); - if (value !== undefined) matomoEvent.push(value); + const matomoEvent: MatomoCommand = ['trackEvent', category, eventAction]; + if (eventName !== undefined) matomoEvent.push(eventName); + if (eventValue !== undefined) matomoEvent.push(eventValue); window._paq.push(matomoEvent); - this.emit('event-tracked', { eventId, category, action, name, value }); + this.emit('event-tracked', { eventId, category, action: eventAction, name: eventName, value: eventValue, isClick }); return eventId; } - trackPageView(title?: string): void { this.log(`Tracking page view: ${title || 'default'}`); const pageView: MatomoCommand = ['trackPageView']; @@ -948,6 +979,14 @@ export class MatomoManager implements IMatomoManager { // ================== SCRIPT LOADING ================== async loadScript(): Promise { + // Skip script loading in Electron - we use IPC bridge instead + if (this.isElectronApp()) { + this.log('Electron detected - skipping Matomo script load (using IPC bridge)'); + this.state.scriptLoaded = true; + this.emit('script-loaded'); + return; + } + if (this.state.scriptLoaded) { this.log('Script already loaded'); return; @@ -1344,11 +1383,16 @@ export class MatomoManager implements IMatomoManager { */ shouldShowConsentDialog(configApi?: any): boolean { try { + // Electron doesn't need cookie consent (uses server-side HTTP tracking) + const isElectron = (window as any).electronAPI !== undefined; + if (isElectron) { + return false; + } + // Use domains from constructor config or fallback to empty object const matomoDomains = this.config.matomoDomains || {}; - const isElectron = (window as any).electronAPI !== undefined; - const isSupported = matomoDomains[window.location.hostname] || isElectron; + const isSupported = matomoDomains[window.location.hostname]; if (!isSupported) { return false; diff --git a/apps/remix-ide/src/app/plugins/electron/compilerLoaderPlugin.ts b/apps/remix-ide/src/app/plugins/electron/compilerLoaderPlugin.ts index 66e904b62e5..90a3bcc0e63 100644 --- a/apps/remix-ide/src/app/plugins/electron/compilerLoaderPlugin.ts +++ b/apps/remix-ide/src/app/plugins/electron/compilerLoaderPlugin.ts @@ -51,7 +51,7 @@ export class compilerLoaderPlugin extends Plugin { export class compilerLoaderPluginDesktop extends ElectronPlugin { constructor() { super(profile) - this.methods = [] + this.methods = methods } async onActivation(): Promise { diff --git a/apps/remix-ide/src/app/plugins/electron/electronConfigPlugin.ts b/apps/remix-ide/src/app/plugins/electron/electronConfigPlugin.ts index 324d2774bee..14298125142 100644 --- a/apps/remix-ide/src/app/plugins/electron/electronConfigPlugin.ts +++ b/apps/remix-ide/src/app/plugins/electron/electronConfigPlugin.ts @@ -7,6 +7,6 @@ export class electronConfig extends ElectronPlugin { name: 'electronconfig', description: 'electronconfig', }) - this.methods = [] + this.methods = ['readConfig'] } } \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/templates-selection/templates-selection-plugin.tsx b/apps/remix-ide/src/app/plugins/templates-selection/templates-selection-plugin.tsx index 420109daf9d..51d660fb18e 100644 --- a/apps/remix-ide/src/app/plugins/templates-selection/templates-selection-plugin.tsx +++ b/apps/remix-ide/src/app/plugins/templates-selection/templates-selection-plugin.tsx @@ -262,10 +262,10 @@ export class TemplatesSelectionPlugin extends ViewPlugin { item.templateType = TEMPLATE_METADATA[item.value] if (item.templateType && item.templateType.desktopCompatible === false && isElectron()) { - return (<>) + return } - if (item.templateType && item.templateType.disabled === true) return + if (item.templateType && item.templateType.disabled === true) return null if (!item.opts) { return ( Promise isE2E: () => Promise - canTrackMatomo: () => Promise - // Desktop tracking helpers - trackDesktopEvent: (category: string, action: string, name?: string, value?: string | number) => Promise - setTrackingMode: (mode: 'cookie' | 'anon') => Promise + trackEvent: (args: any[]) => Promise openFolder: (path: string) => Promise openFolderInSameWindow: (path: string) => Promise activatePlugin: (name: string) => Promise diff --git a/apps/remixdesktop/src/main.ts b/apps/remixdesktop/src/main.ts index a3b062175c1..42905a571ae 100644 --- a/apps/remixdesktop/src/main.ts +++ b/apps/remixdesktop/src/main.ts @@ -44,19 +44,37 @@ export const createWindow = async (dir?: string): Promise => { if (screen.getPrimaryDisplay().size.width < 2560 || screen.getPrimaryDisplay().size.height < 1440) { resizeFactor = 1 } - const width = screen.getPrimaryDisplay().size.width * resizeFactor - const height = screen.getPrimaryDisplay().size.height * resizeFactor + const windowWidth = Math.round(screen.getPrimaryDisplay().size.width * resizeFactor) + const windowHeight = Math.round(screen.getPrimaryDisplay().size.height * resizeFactor) // Create the browser window. const mainWindow = new BrowserWindow({ - width: (isE2E ? 2560 : width), - height: (isE2E ? 1140 : height), + // For normal use, start at ~80% of the primary display; E2E will maximize anyway + width: windowWidth, + height: windowHeight, + minWidth: 1024, + minHeight: 650, frame: true, webPreferences: { - preload: path.join(__dirname, 'preload.js') - + preload: path.join(__dirname, 'preload.js'), + // Hint an initial zoom; Electron applies this at creation time + zoomFactor: (isE2E ? 0.5 : 1.0), }, }); + + // Ensure zoom is applied after content loads (some pages reset it on load) + if (isE2E) { + const applyZoom = () => { + try { mainWindow.webContents.setZoomFactor(0.5); } catch (_) {} + }; + // Apply once the first load finishes + mainWindow.webContents.once('did-finish-load', applyZoom); + // Re-apply on any navigation within the window (SPA route changes, reloads) + mainWindow.webContents.on('did-navigate-in-page', applyZoom); + mainWindow.webContents.on('did-navigate', applyZoom); + } + + mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url); // Open URL in user's browser. return { action: "deny" }; // Prevent the app from opening the URL. @@ -286,14 +304,10 @@ ipcMain.handle('config:isE2E', async () => { return isE2E }) -ipcMain.handle('config:canTrackMatomo', async (event, name: string) => { - console.log('config:canTrackMatomo', ((process.env.NODE_ENV === 'production' || isPackaged) && !isE2E)) - return ((process.env.NODE_ENV === 'production' || isPackaged) && !isE2E) -}) - ipcMain.handle('matomo:trackEvent', async (event, data) => { if (data && data[0] && data[0] === 'trackEvent') { - trackEvent(data[1], data[2], data[3], data[4]) + // data[5] is isClick (optional) + trackEvent(data[1], data[2], data[3], data[4], 0, data[5]); } }) diff --git a/apps/remixdesktop/src/menus/view.ts b/apps/remixdesktop/src/menus/view.ts index 0d8886b341c..9a33ff053b6 100644 --- a/apps/remixdesktop/src/menus/view.ts +++ b/apps/remixdesktop/src/menus/view.ts @@ -64,13 +64,10 @@ export default ( if (focusedWindow){ let factor = (focusedWindow as BrowserWindow).webContents.getZoomFactor() console.log(factor) - if (factor > 1.25) { + if (factor > 0.1) { factor = factor - 0.25 - ;(focusedWindow as BrowserWindow).webContents.setZoomFactor(factor) - }else{ - (focusedWindow as BrowserWindow).webContents.setZoomFactor(1) + ;(focusedWindow as BrowserWindow).webContents.setZoomFactor(Math.max(0.1, factor)) } - } } }, diff --git a/apps/remixdesktop/src/plugins/appUpdater.ts b/apps/remixdesktop/src/plugins/appUpdater.ts index 78abaa3721f..839077ebf91 100644 --- a/apps/remixdesktop/src/plugins/appUpdater.ts +++ b/apps/remixdesktop/src/plugins/appUpdater.ts @@ -110,12 +110,35 @@ class AppUpdaterPluginClient extends ElectronBasePluginClient { async checkForUpdates(): Promise { console.log('checkForUpdates') + + // Get OS information + const platform = process.platform + let osName = 'Unknown OS' + if (platform === 'darwin') osName = 'macOS' + else if (platform === 'win32') osName = 'Windows' + else if (platform === 'linux') osName = 'Linux' + + // Send welcome message + const welcomeMessage = `Welcome to Remix Desktop ${autoUpdater.currentVersion} on ${osName} + +This desktop version includes: +• Native Git integration - Access your system's Git directly from Remix +• Native Terminals - Full-featured terminal emulator with native shell access + Click "New Terminal" in the Terminal menu to open native ${osName} terminals + +You can use this output terminal to: +• Execute JavaScript scripts + - Input a script directly in the command line interface + - Select a JavaScript file in the file explorer and run \`remix.execute()\` or \`remix.exeCurrent()\` in the command line interface + - Right-click on a JavaScript file in the file explorer and click \`Run\` +` + this.call('terminal', 'log', { type: 'log', - value: 'Remix Desktop version: ' + autoUpdater.currentVersion, + value: welcomeMessage, }) + trackEvent('App', 'CheckForUpdate', 'Remix Desktop version: ' + autoUpdater.currentVersion, 1); - autoUpdater.checkForUpdates() } } diff --git a/apps/remixdesktop/src/plugins/fsPlugin.ts b/apps/remixdesktop/src/plugins/fsPlugin.ts index dcfda9ebc24..b1deec003ec 100644 --- a/apps/remixdesktop/src/plugins/fsPlugin.ts +++ b/apps/remixdesktop/src/plugins/fsPlugin.ts @@ -1,12 +1,12 @@ -import {ElectronBasePlugin, ElectronBasePluginClient} from '@remixproject/plugin-electron' +import { ElectronBasePlugin, ElectronBasePluginClient } from '@remixproject/plugin-electron' import fs from 'fs/promises' -import {Profile} from '@remixproject/plugin-utils' +import { Profile } from '@remixproject/plugin-utils' import chokidar from 'chokidar' -import {dialog, shell} from 'electron' -import {createWindow, isE2E, isPackaged} from '../main' -import {writeConfig} from '../utils/config' +import { dialog, shell } from 'electron' +import { createWindow, isE2E, isPackaged } from '../main' +import { writeConfig } from '../utils/config' import path from 'path' -import {customAction} from '@remixproject/plugin-api' +import { customAction } from '@remixproject/plugin-api' import { PluginEventDataBatcher } from '../utils/pluginEventDataBatcher' type recentFolder = { @@ -43,7 +43,7 @@ const deplucateFolderList = (list: recentFolder[]): recentFolder[] => { export class FSPlugin extends ElectronBasePlugin { clients: FSPluginClient[] = [] constructor() { - super(profile, clientProfile, isE2E? FSPluginClientE2E: FSPluginClient) + super(profile, clientProfile, isE2E ? FSPluginClientE2E : FSPluginClient) this.methods = [...super.methods, 'closeWatch', 'removeCloseListener'] } @@ -51,9 +51,11 @@ export class FSPlugin extends ElectronBasePlugin { const config = await this.call('electronconfig', 'readConfig') const openedFolders = (config && config.openedFolders) || [] const recentFolders: recentFolder[] = (config && config.recentFolders) || [] - this.call('electronconfig', 'writeConfig', {...config, + this.call('electronconfig', 'writeConfig', { + ...config, recentFolders: deplucateFolderList(recentFolders), - openedFolders: openedFolders}) + openedFolders: openedFolders + }) const foldersToDelete: string[] = [] if (recentFolders && recentFolders.length) { for (const folder of recentFolders) { @@ -69,7 +71,7 @@ export class FSPlugin extends ElectronBasePlugin { } if (foldersToDelete.length) { const newFolders = recentFolders.filter((f: recentFolder) => !foldersToDelete.includes(f.path)) - this.call('electronconfig', 'writeConfig', {recentFolders: deplucateFolderList(newFolders)}) + this.call('electronconfig', 'writeConfig', { recentFolders: deplucateFolderList(newFolders) }) } } createWindow() @@ -106,7 +108,7 @@ const clientProfile: Profile = { name: 'fs', displayName: 'fs', description: 'fs', - methods: ['readdir', 'readFile', 'writeFile', 'mkdir', 'rmdir', 'unlink', 'rename', 'stat', 'lstat', 'exists', 'currentPath', 'getWorkingDir', 'watch', 'closeWatch', 'setWorkingDir', 'openFolder', 'openFolderInSameWindow', 'getRecentFolders', 'removeRecentFolder', 'openWindow', 'selectFolder', 'revealInExplorer', 'openInVSCode', 'openInVSCode', 'currentPath'], + methods: ['readdir', 'readFile', 'writeFile', 'mkdir', 'rmdir', 'unlink', 'rename', 'stat', 'lstat', 'exists', 'currentPath', 'getWorkingDir', 'watch', 'closeWatch', 'setWorkingDir', 'openFolder', 'openFolderInSameWindow', 'getRecentFolders', 'removeRecentFolder', 'openWindow', 'selectFolder', 'revealInExplorer', 'openInVSCode', 'getWatcherStats', 'refreshDirectory', 'resetWatchers', 'resetNotificationLimits', 'currentPath'], } class FSPluginClient extends ElectronBasePluginClient { @@ -115,15 +117,30 @@ class FSPluginClient extends ElectronBasePluginClient { trackDownStreamUpdate: Record = {} expandedPaths: string[] = ['.'] dataBatcher: PluginEventDataBatcher - private writeQueue: Map = new Map() + private writeQueue: Map = new Map() private writeTimeouts: Map = new Map() + private watcherLimitReached: boolean = false + private maxWatchers: number = this.getPlatformWatcherLimit() // Platform-specific limit + // Guard flags for safe auto-resets + private isResettingWatchers: boolean = false + private lastWatcherResetAt: number = 0 + private static WATCHER_RESET_COOLDOWN_MS = 5000 + // Rate-limit cooldown logs + private lastCooldownLogAt: number = 0 + private lastCooldownReason: string | null = null + // Rate-limit system suggestion alerts (show detailed suggestions only once per session) + private systemSuggestionsShown: Set = new Set() constructor(webContentsId: number, profile: Profile) { super(webContentsId, profile) + + // Set up global error handlers for watcher issues + this.setupErrorHandlers() + this.onload(() => { - if (!isPackaged) { - this.window.webContents.openDevTools() - } + // if (!isPackaged) { + // this.window.webContents.openDevTools() + // } this.window.on('close', async () => { await this.removeFromOpenedFolders(this.workingDir) await this.closeWatch() @@ -136,6 +153,142 @@ class FSPluginClient extends ElectronBasePluginClient { }) } + private setupErrorHandlers(): void { + // Handle unhandled promise rejections from watchers + const originalListeners = process.listeners('unhandledRejection') + process.removeAllListeners('unhandledRejection') + + process.on('unhandledRejection', (reason: any, promise: Promise) => { + if (reason && (reason.code === 'ENOSPC' || reason.message?.includes('ENOSPC'))) { + //console.error('File watcher error: System limit reached -', reason.message) + this.watcherLimitReached = true + // Automatically reduce watchers when system limit is reached + this.maybeResetWatchers('ENOSPC (unhandledRejection)') + + const suggestions = [ + 'Increase system watch limit: sudo sysctl fs.inotify.max_user_watches=524288', + 'Add to /etc/sysctl.conf for permanent fix: fs.inotify.max_user_watches=524288', + 'Consider restarting the application to reset watchers' + ] + + this.emit('watcherLimitReached', { + path: 'system', + isRootWatcher: false, + suggestions + }) + + // Show detailed system suggestions only once per session + if (!this.systemSuggestionsShown.has('ENOSPC_unhandledRejection')) { + this.systemSuggestionsShown.add('ENOSPC_unhandledRejection') + this.call('notification' as any, 'alert', { + title: 'File Watcher System Limit Reached', + id: 'watcherLimitEnospcUnhandled', + message: `The system has run out of file watchers. To fix this permanently on Linux: + +• Temporary fix: sudo sysctl fs.inotify.max_user_watches=524288 +• Permanent fix: Add "fs.inotify.max_user_watches=524288" to /etc/sysctl.conf + +Watchers have been automatically reduced for now.` + }) + } else { + // Just a simple toast for subsequent occurrences + this.call('notification' as any, 'toast', + 'File watcher limit reached. Watchers automatically reduced.' + ) + } + return // Don't let it crash the app + } + + // Re-emit to original handlers for other types of unhandled rejections + originalListeners.forEach(listener => { + if (typeof listener === 'function') { + listener(reason, promise) + } + }) + }) + } + + // Reset watchers with cooldown & reentrancy guard + private async maybeResetWatchers(reason: string): Promise { + const now = Date.now() + if (this.isResettingWatchers) { + //console.warn(`Watcher reset already in progress, skipping (${reason})`) + return + } + if (now - this.lastWatcherResetAt < FSPluginClient.WATCHER_RESET_COOLDOWN_MS) { + // Log this warning at most once per cooldown window, or if the reason changes + const withinCooldownLogWindow = (now - this.lastCooldownLogAt) < FSPluginClient.WATCHER_RESET_COOLDOWN_MS + if (!withinCooldownLogWindow || this.lastCooldownReason !== reason) { + const msUntilNext = Math.max(0, FSPluginClient.WATCHER_RESET_COOLDOWN_MS - (now - this.lastWatcherResetAt)) + console.warn(`Watcher reset throttled (cooldown). Next attempt in ~${msUntilNext}ms. Reason: ${reason}`) + this.lastCooldownLogAt = now + this.lastCooldownReason = reason + } + return + } + if (this.maxWatchers <= 5) { + console.warn(`Watcher reset skipped; already at minimum limit. Reason: ${reason}`) + return + } + try { + this.isResettingWatchers = true + this.lastWatcherResetAt = now + console.log(`Auto-reducing watchers (reason: ${reason}) from ${this.maxWatchers} to ${Math.max(5, Math.floor(this.maxWatchers / 2))}`) + await this.resetWatchers() + } catch (e) { + console.error('Error during watcher auto-reset:', e) + } finally { + this.isResettingWatchers = false + } + } + + + private getPlatformWatcherLimit(): number { + // 1) Explicit override via env + const env = process.env.REMIX_MAX_WATCHERS + if (env && !Number.isNaN(Number(env))) { + const v = Math.max(5, Math.floor(Number(env))) + console.info(`[fs] Using env REMIX_MAX_WATCHERS=${v}`) + return v + } + + const os = require('os') + const platform = os.platform() + + // 2) Platform defaults + Linux dynamic probe + if (platform === 'linux') { + try { + // Read inotify limits to derive a safe budget for our app + const fsSync = require('fs') + const maxWatchesStr = fsSync.readFileSync('/proc/sys/fs/inotify/max_user_watches', 'utf8').trim() + const maxWatches = Number(maxWatchesStr) || 8192 + + // Keep our budget small relative to system-wide limit (2% capped to 300) + const derived = Math.floor(Math.min(300, Math.max(50, maxWatches * 0.02))) + console.info(`[fs] Linux inotify max_user_watches=${maxWatches}, using budget=${derived}`) + return derived + } catch { + // Fallback when /proc is unavailable + console.info('[fs] Linux inotify limits not readable, using conservative default=75') + return 75 + } + } + + if (platform === 'darwin') { + // FSEvents is efficient; 1000 is safe for our shallow watchers + return 1000 + } + + if (platform === 'win32') { + // Windows API is also generous; 800 is a balanced default + return 800 + } + + // Unknown platform: use moderate default + return 200 + } + + // best for non recursive async readdir(path: string): Promise { if (this.workingDir === '') return new Promise((resolve, reject) => reject({ @@ -175,10 +328,10 @@ class FSPluginClient extends ElectronBasePluginClient { if (existingTimeout) { clearTimeout(existingTimeout) } - + // Queue the write with a small delay to handle rapid successive writes - this.writeQueue.set(path, {content, options, timestamp: Date.now()}) - + this.writeQueue.set(path, { content, options, timestamp: Date.now() }) + return new Promise((resolve, reject) => { const timeout = setTimeout(async () => { try { @@ -187,15 +340,15 @@ class FSPluginClient extends ElectronBasePluginClient { resolve() return } - + // Check if this is still the latest write request if (queuedWrite.timestamp !== this.writeQueue.get(path)?.timestamp) { resolve() return } - + const fullPath = this.fixPath(path) - + // First, check if file exists and read current content let currentContent: string | null = null try { @@ -203,21 +356,21 @@ class FSPluginClient extends ElectronBasePluginClient { } catch (e) { // File doesn't exist, that's ok } - + // Use atomic write with temporary file const tempPath = fullPath + '.tmp' await (fs as any).writeFile(tempPath, queuedWrite.content, queuedWrite.options) - + // Atomic rename (this is atomic on most filesystems) await fs.rename(tempPath, fullPath) - + // Only update tracking after successful write this.trackDownStreamUpdate[path] = queuedWrite.content - + // Clean up queue and timeout this.writeQueue.delete(path) this.writeTimeouts.delete(path) - + resolve() } catch (error) { // Clean up temp file if it exists @@ -226,15 +379,15 @@ class FSPluginClient extends ElectronBasePluginClient { } catch (e) { // Ignore cleanup errors } - + // Clean up queue and timeout this.writeQueue.delete(path) this.writeTimeouts.delete(path) - + reject(error) } }, 50) // 50ms debounce delay - + this.writeTimeouts.set(path, timeout) }) } @@ -302,20 +455,59 @@ class FSPluginClient extends ElectronBasePluginClient { async watch(): Promise { try { - if(this.events && this.events.eventNames().includes('[filePanel] expandPathChanged')) { + if (this.events && this.events.eventNames().includes('[filePanel] expandPathChanged')) { this.off('filePanel' as any, 'expandPathChanged') } this.on('filePanel' as any, 'expandPathChanged', async (paths: string[]) => { this.expandedPaths = ['.', ...paths] // add root //console.log(Object.keys(this.watchers)) paths = paths.map((path) => this.fixPath(path)) + + // Try to add new watchers with graceful failure handling for (const path of paths) { if (!Object.keys(this.watchers).includes(path)) { - this.watchers[path] = await this.watcherInit(path) - //console.log('added watcher', path) + const currentWatcherCount = Object.keys(this.watchers).length + console.log(`📊 WATCHERS: ${currentWatcherCount}/${this.maxWatchers}, adding: ${path}`) + + // Check if we're approaching the watcher limit + if (currentWatcherCount >= this.maxWatchers) { + const os = require('os') + const platform = os.platform() + console.warn(`🚫 WATCHER LIMIT: ${currentWatcherCount}/${this.maxWatchers} on ${platform}. Skipping: ${path}`) + this.watcherLimitReached = true + + let suggestions = ['Consider collapsing some folders to reduce active watchers'] + if (platform === 'linux') { + suggestions.push('Linux has stricter file watcher limits - consider increasing system limits if needed') + } + + this.emit('watcherLimitReached', { + path, + isRootWatcher: false, + suggestions + }) + + // Show preventive limit notification only once per session + if (!this.systemSuggestionsShown.has('preventive_limit')) { + this.systemSuggestionsShown.add('preventive_limit') + this.call('notification' as any, 'toast', + `Watcher limit reached (${currentWatcherCount}/${this.maxWatchers}). Consider collapsing folders to avoid system limits.` + ) + } + continue + } + + try { + console.log(`➕ ADDING WATCHER: ${path}`) + this.watchers[path] = await this.watcherInit(path) + console.log(`✅ WATCHER ADDED: ${path} (${Object.keys(this.watchers).length}/${this.maxWatchers})`) + } catch (error: any) { + console.log(`❌ WATCHER FAILED: ${path} - ${error.message}`) + this.handleWatcherError(error, path) + } } } - + for (const watcher in this.watchers) { if (watcher === this.workingDir) continue if (!paths.includes(watcher)) { @@ -325,34 +517,186 @@ class FSPluginClient extends ElectronBasePluginClient { } } }) - this.watchers[this.workingDir] = await this.watcherInit(this.workingDir) // root - //console.log('added root watcher', this.workingDir) + + // Initialize root watcher with error handling + try { + this.watchers[this.workingDir] = await this.watcherInit(this.workingDir) // root + //console.log('added root watcher', this.workingDir) + } catch (error: any) { + this.handleWatcherError(error, this.workingDir, true) + } } catch (e) { console.log('error watching', e) } } - private async watcherInit(path: string) { - const watcher = chokidar - .watch(path, { - ignorePermissionErrors: true, - ignoreInitial: true, - ignored: [ - '**/.git/index.lock', // this file is created and unlinked all the time when git is running on Windows - ], - depth: 0, - }) - .on('all', async (eventName, path, stats) => { - this.watcherExec(eventName, convertPathToPosix(path)) - }) - .on('error', (error) => { - watcher.close() - if (error.message.includes('ENOSPC')) { - this.emit('error', 'ENOSPC') - } - console.log(`Watcher error: ${error}`) - }) - return watcher + private async watcherInit(path: string): Promise { + return new Promise((resolve, reject) => { + try { + const watcher = chokidar + .watch(path, { + ignorePermissionErrors: true, + ignoreInitial: true, + ignored: [ + '**/.git/index.lock', // this file is created and unlinked all the time when git is running on Windows + ], + depth: 0, + }) + .on('ready', () => { + // Watcher is ready - resolve the promise + resolve(watcher) + }) + .on('all', async (eventName, path, stats) => { + try { + this.watcherExec(eventName, convertPathToPosix(path)) + } catch (error) { + console.error('Error in watcherExec:', error) + } + }) + .on('error', (error) => { + console.error('Watcher error:', error) + try { + watcher.close() + } catch (closeError) { + console.error('Error closing watcher:', closeError) + } + delete this.watchers[path] + this.handleWatcherError(error, path) + reject(error) + }) + + // Set a timeout to reject if watcher doesn't become ready + let watcherReady = false + watcher.on('ready', () => { watcherReady = true }) + + setTimeout(() => { + if (!watcherReady) { + const timeoutError = new Error(`Watcher initialization timeout for path: ${path}`) + try { + watcher.close() + } catch (e) { + console.error('Error closing timed-out watcher:', e) + } + reject(timeoutError) + } + }, 5000) // 5 second timeout + + } catch (error) { + console.error('Error creating watcher:', error) + reject(error) + } + }) + } + + private handleWatcherError(error: any, path: string, isRootWatcher: boolean = false): void { + console.error(`Watcher error for ${path}:`, error.message) + const os = require('os') + const platform = os.platform() + + if (error.message.includes('ENOSPC') || error.message.includes('watch ENOSPC')) { + this.watcherLimitReached = true + + // Platform-specific suggestions + let suggestions: string[] = [] + if (platform === 'linux') { + suggestions = [ + 'Increase system watch limit: sudo sysctl fs.inotify.max_user_watches=524288', + 'Add to /etc/sysctl.conf for permanent fix: fs.inotify.max_user_watches=524288', + 'Alternatively, try collapsing some folders in the file explorer to reduce watchers' + ] + } else { + suggestions = [ + 'Try collapsing some folders in the file explorer to reduce watchers', + 'Consider restarting the application if the issue persists' + ] + } + + console.error(`File system watcher limit reached on ${platform}`) + this.emit('watcherLimitReached', { path, isRootWatcher, suggestions }) + + // Show detailed suggestions only once per session for chokidar ENOSPC + if (!this.systemSuggestionsShown.has('ENOSPC_chokidar') && platform === 'linux') { + this.systemSuggestionsShown.add('ENOSPC_chokidar') + this.call('notification' as any, 'alert', { + title: 'File Watcher System Limit Reached', + id: 'watcherLimitEnospcChokidar', + message: `Linux inotify limit reached while watching files. + +To increase the limit: +• Temporary: sudo sysctl fs.inotify.max_user_watches=524288 +• Permanent: Add "fs.inotify.max_user_watches=524288" to /etc/sysctl.conf + +Alternatively, try collapsing folders to reduce active watchers.` + }) + } else { + // Simple toast for subsequent occurrences or non-Linux platforms + const shortMsg = platform === 'linux' + ? 'File watcher limit reached (see console for solution)' + : 'File watcher limit reached. Try collapsing folders.' + this.call('notification' as any, 'toast', shortMsg) + } + + // Proactively reduce watchers on chokidar ENOSPC events as well + this.maybeResetWatchers('ENOSPC (chokidar error)') + + } else if (error.message.includes('EMFILE') || error.message.includes('too many open files')) { + this.watcherLimitReached = true + + let suggestions: string[] = [] + if (platform === 'linux' || platform === 'darwin') { + suggestions = [ + 'Increase file descriptor limit: ulimit -n 8192', + platform === 'linux' ? 'Add to ~/.bashrc for permanent fix: ulimit -n 8192' : 'Add to ~/.bash_profile for permanent fix: ulimit -n 8192' + ] + } else { + suggestions = ['Try restarting the application to free up file handles'] + } + + console.error(`Too many open files on ${platform}`) + this.emit('watcherLimitReached', { path, isRootWatcher, suggestions }) + + // Show detailed EMFILE suggestions only once per session + if (!this.systemSuggestionsShown.has('EMFILE') && (platform === 'linux' || platform === 'darwin')) { + this.systemSuggestionsShown.add('EMFILE') + this.call('notification' as any, 'alert', { + title: 'Too Many Open Files', + id: 'watcherLimitEmfile', + message: `The system file descriptor limit has been reached. + +To increase the limit: +• Temporary: ulimit -n 8192 +• Permanent (${platform === 'linux' ? 'Linux' : 'macOS'}): Add "ulimit -n 8192" to ~/.${platform === 'linux' ? 'bashrc' : 'bash_profile'} + +Watchers have been automatically reduced.` + }) + } else { + // Simple toast for subsequent occurrences + const shortMsg = platform === 'linux' || platform === 'darwin' + ? 'Too many open files (see console for solution)' + : 'Too many open files. Consider restarting.' + this.call('notification' as any, 'toast', shortMsg) + } + + // EMFILE can also be mitigated by reducing watchers + this.maybeResetWatchers('EMFILE (chokidar error)') + + } else if (error.message.includes('EPERM') || error.message.includes('permission denied')) { + console.error(`Permission denied for watching ${path}`) + this.emit('watcherPermissionError', { path, isRootWatcher }) + + // Notify user about permission issues + this.call('notification' as any, 'toast', + `Permission denied watching ${path}. Check folder permissions.` + ) + } else { + // Generic watcher error + this.emit('watcherError', { error: error.message, path, isRootWatcher }) + } + + // If this is the root watcher and it fails, we have bigger problems + if (isRootWatcher) { + console.error('Critical: Root watcher failed. File watching will be severely limited.') + } } private async watcherExec(eventName: string, eventPath: string) { @@ -364,10 +708,10 @@ class FSPluginClient extends ElectronBasePluginClient { try { // Read the current file content const newContent = await fs.readFile(eventPath, 'utf-8') - + // Get the last known content we wrote const trackedContent = this.trackDownStreamUpdate[pathWithoutPrefix] - + // Only emit change if: // 1. We don't have tracked content (external change), OR // 2. The new content differs from what we last wrote @@ -377,7 +721,7 @@ class FSPluginClient extends ElectronBasePluginClient { if (trackedContent && trackedContent !== newContent) { this.trackDownStreamUpdate[pathWithoutPrefix] = newContent } - + const dirname = path.dirname(pathWithoutPrefix) if (this.expandedPaths.includes(dirname) || this.expandedPaths.includes(pathWithoutPrefix)) { this.dataBatcher.write('change', eventName, pathWithoutPrefix) @@ -416,21 +760,134 @@ class FSPluginClient extends ElectronBasePluginClient { } this.writeTimeouts.clear() this.writeQueue.clear() - + for (const watcher in this.watchers) { - this.watchers[watcher].close() + try { + this.watchers[watcher].close() + } catch (error) { + console.log('Error closing watcher:', error) + } } + this.watchers = {} // Clear tracking data when closing watchers this.trackDownStreamUpdate = {} } + async getWatcherStats(): Promise<{ activeWatchers: number, watchedPaths: string[], systemInfo: any, limitReached: boolean, maxWatchers: number }> { + const activeWatchers = Object.keys(this.watchers).length + const watchedPaths = Object.keys(this.watchers) + + // Get platform-specific system info + let systemInfo: any = {} + try { + const os = require('os') + const platform = os.platform() + + if (platform === 'linux') { + const fs = require('fs') + try { + const maxWatches = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_watches', 'utf8') + const maxInstances = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_instances', 'utf8') + systemInfo = { + maxUserWatches: parseInt(maxWatches.trim()), + maxUserInstances: parseInt(maxInstances.trim()), + platform: 'linux', + watcherAPI: 'inotify', + notes: 'Linux has strict watcher limits that can be adjusted' + } + } catch (e) { + systemInfo = { + platform: 'linux', + watcherAPI: 'inotify', + error: 'Could not read inotify limits', + notes: 'Linux has strict watcher limits - check /proc/sys/fs/inotify/ for current limits' + } + } + } else if (platform === 'darwin') { + systemInfo = { + platform: 'darwin', + watcherAPI: 'FSEvents', + notes: 'macOS FSEvents API is efficient with generous limits' + } + } else if (platform === 'win32') { + systemInfo = { + platform: 'win32', + watcherAPI: 'ReadDirectoryChangesW', + notes: 'Windows API has reasonable limits for most use cases' + } + } else { + systemInfo = { + platform: platform, + watcherAPI: 'unknown', + notes: 'Unknown platform - using conservative limits' + } + } + } catch (e) { + systemInfo = { error: 'Could not determine system info' } + } + + return { + activeWatchers, + watchedPaths, + systemInfo, + limitReached: this.watcherLimitReached, + maxWatchers: this.maxWatchers + } + } + + async refreshDirectory(path?: string): Promise { + // Manual directory refresh when watchers are unavailable + if (!path) path = '.' + const fullPath = this.fixPath(path) + + try { + // Emit a synthetic 'addDir' event to trigger UI refresh + this.emit('change', 'refreshDir', path) + } catch (error) { + console.log('Error refreshing directory:', error) + } + } + + async resetWatchers(): Promise { + const oldLimit = this.maxWatchers + const oldWatcherCount = Object.keys(this.watchers).length + + console.log(`🔄 WATCHER RESET: Was ${oldWatcherCount}/${oldLimit} watchers`) + console.log('Resetting all watchers due to system limits...') + + // Close all existing watchers + await this.closeWatch() + + // Reset the limit flag + this.watcherLimitReached = false + + // Reduce the max watchers even further + this.maxWatchers = Math.max(5, Math.floor(this.maxWatchers / 2)) + + console.log(`✅ WATCHER REDUCTION: ${oldLimit} → ${this.maxWatchers} (reduced by ${oldLimit - this.maxWatchers})`) + + // Restart watching with reduced limits + try { + await this.watch() + const newWatcherCount = Object.keys(this.watchers).length + console.log(`🆕 NEW WATCHER STATE: ${newWatcherCount}/${this.maxWatchers} active`) + } catch (error) { + console.error('Error restarting watchers after reset:', error) + } + } + + async resetNotificationLimits(): Promise { + this.systemSuggestionsShown.clear() + console.log('🔔 Notification limits reset - detailed suggestions will be shown again') + } + private async cleanupTempFiles(): Promise { if (!this.workingDir) return - + try { const files = await fs.readdir(this.workingDir) const tempFiles = files.filter(file => file.endsWith('.tmp')) - + for (const tempFile of tempFiles) { try { await fs.unlink(path.join(this.workingDir, tempFile)) @@ -446,16 +903,16 @@ class FSPluginClient extends ElectronBasePluginClient { async convertRecentFolders(): Promise { const config = await this.call('electronconfig' as any, 'readConfig') - if(config.recentFolders) { + if (config.recentFolders) { const remaps = config.recentFolders.map((f: any) => { // if type is string - if(typeof f ==='string') { + if (typeof f === 'string') { return { path: f, timestamp: new Date().getTime(), } - }else{ + } else { return f } }) @@ -560,10 +1017,10 @@ class FSPluginClient extends ElectronBasePluginClient { async setWorkingDir(path: string): Promise { console.log('setWorkingDir', path) - + // Clean up any temp files from previous working directory await this.cleanupTempFiles() - + this.workingDir = convertPathToPosix(path) await this.updateRecentFolders(path) await this.updateOpenedFolders(path) @@ -574,7 +1031,17 @@ class FSPluginClient extends ElectronBasePluginClient { } async revealInExplorer(action: customAction, isAbsolutePath: boolean = false): Promise { - let path = isAbsolutePath? action.path[0] : this.fixPath(action.path[0]) + let path: string + + // Handle missing or empty path array + if (!action.path || action.path.length === 0 || !action.path[0] || action.path[0] === '') { + path = this.workingDir || process.cwd() + } else if (isAbsolutePath) { + path = action.path[0] + } else { + path = this.fixPath(action.path[0]) + } + shell.showItemInFolder(convertPathToLocalFileSystem(path)) } @@ -625,7 +1092,7 @@ export class FSPluginClientE2E extends FSPluginClient { await this.updateRecentFolders(dir) await this.updateOpenedFolders(dir) if (!dir) return - + this.openWindow(dir) } diff --git a/apps/remixdesktop/src/preload.ts b/apps/remixdesktop/src/preload.ts index 585d8fad1bd..a752cc61c7d 100644 --- a/apps/remixdesktop/src/preload.ts +++ b/apps/remixdesktop/src/preload.ts @@ -17,7 +17,6 @@ ipcRenderer.invoke('getWebContentsID').then((id: number) => { contextBridge.exposeInMainWorld('electronAPI', { isPackaged: () => ipcRenderer.invoke('config:isPackaged'), isE2E: () => ipcRenderer.invoke('config:isE2E'), - canTrackMatomo: () => ipcRenderer.invoke('config:canTrackMatomo'), trackEvent: (args: any[]) => ipcRenderer.invoke('matomo:trackEvent', args), openFolder: (path: string) => ipcRenderer.invoke('fs:openFolder', webContentsId, path), openFolderInSameWindow: (path: string) => ipcRenderer.invoke('fs:openFolderInSameWindow', webContentsId, path), diff --git a/apps/remixdesktop/src/utils/matamo.ts b/apps/remixdesktop/src/utils/matamo.ts index b98fce8a30a..46423653173 100644 --- a/apps/remixdesktop/src/utils/matamo.ts +++ b/apps/remixdesktop/src/utils/matamo.ts @@ -1,51 +1,66 @@ import { screen } from 'electron'; import { isPackaged, isE2E } from "../main"; -// Function to send events to Matomo -export function trackEvent(category: string, action: string, name: string, value?: string | number, new_visit: number = 0): void { - if (!category || !action) { - console.warn('Matomo tracking skipped: category or action missing', { category, action }); - return; - } +// Matomo site ID for Electron - must match MATOMO_DOMAINS['electron'] in MatomoConfig.ts +const ELECTRON_SITE_ID = '4'; - if ((process.env.NODE_ENV === 'production' || isPackaged) && !isE2E) { - const chromiumVersion = process.versions.chrome; - const os = process.platform; - const osVersion = process.getSystemVersion(); - const ua = `Mozilla/5.0 (${os === 'darwin' ? 'Macintosh' : os === 'win32' ? 'Windows NT' : os === 'linux' ? 'X11; Linux x86_64' : 'Unknown'}; ${osVersion}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromiumVersion} Safari/537.36`; - const res = `${screen.getPrimaryDisplay().size.width}x${screen.getPrimaryDisplay().size.height}`; - - console.log('trackEvent', category, action, name, value, ua, new_visit); - - const params = new URLSearchParams({ - idsite: '4', - rec: '1', - new_visit: new_visit ? new_visit.toString() : '0', - e_c: category, - e_a: action, - e_n: name || '', - ua: ua, - action_name: `${category}:${action}`, - res: res, - url: 'https://github.com/remix-project-org/remix-desktop', - rand: Math.random().toString() - }); +// Custom dimension ID for click tracking - must match MATOMO_CUSTOM_DIMENSIONS['electron'].clickAction +const CLICK_DIMENSION_ID = 2; - const eventValue = (typeof value === 'number' && !isNaN(value)) ? value : 1; +/** + * Track events to Matomo using HTTP Tracking API + * @see https://developer.matomo.org/api-reference/tracking-api + */ +export function trackEvent( + category: string, + action: string, + name: string, + value?: string | number, + new_visit: number = 0, + isClick?: boolean +): void { + if (!category || !action) return; + + const shouldTrack = (process.env.NODE_ENV === 'production' || isPackaged) && !isE2E; + if (!shouldTrack) return; + + const chromiumVersion = process.versions.chrome; + const os = process.platform; + const osVersion = process.getSystemVersion(); + const ua = `Mozilla/5.0 (${os === 'darwin' ? 'Macintosh' : os === 'win32' ? 'Windows NT' : os === 'linux' ? 'X11; Linux x86_64' : 'Unknown'}; ${osVersion}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromiumVersion} Safari/537.36`; + const res = `${screen.getPrimaryDisplay().size.width}x${screen.getPrimaryDisplay().size.height}`; + const params = new URLSearchParams({ + idsite: ELECTRON_SITE_ID, + rec: '1', + new_visit: new_visit ? new_visit.toString() : '0', + e_c: category, + e_a: action, + e_n: name || '', + ua: ua, + action_name: `${category}:${action}`, + res: res, + url: 'https://github.com/remix-project-org/remix-desktop', + rand: Math.random().toString() + }); - //console.log('Matomo tracking params:', params.toString()); + if (value !== undefined) { + const eventValue = (typeof value === 'number' && !isNaN(value)) ? value : 1; + params.set('e_v', eventValue.toString()); + } - fetch(`https://matomo.remix.live/matomo/matomo.php?${params.toString()}`, { - method: 'GET' - }).then(async res => { - if (res.ok) { - console.log('✅ Event tracked successfully'); - } else { - console.error('❌ Matomo did not acknowledge event'); + // Add click dimension if provided + if (isClick !== undefined) { + params.set(`dimension${CLICK_DIMENSION_ID}`, isClick ? 'true' : 'false'); + } + + fetch(`https://matomo.remix.live/matomo/matomo.php?${params.toString()}`) + .then(res => { + if (!res.ok) { + console.error('[Matomo] Failed to track event:', res.status); } - }).catch(err => { - console.error('Error tracking event:', err); + }) + .catch(err => { + console.error('[Matomo] Error tracking event:', err); }); - } } diff --git a/apps/remixdesktop/test-runner.js b/apps/remixdesktop/test-runner.js new file mode 100755 index 00000000000..f49d166e5f5 --- /dev/null +++ b/apps/remixdesktop/test-runner.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const readline = require('readline'); + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' +}; + +class DesktopTestRunner { + constructor() { + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + // Simple, direct paths - relative to current directory (apps/remixdesktop) + this.sourceTestDir = 'test/tests/app'; + this.testDir = 'build-e2e/remixdesktop/test/tests/app'; + } + + log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); + } + + async checkBuildExists() { + if (!fs.existsSync(this.testDir)) { + this.log('\n❌ Build directory not found!', 'red'); + this.log('Please run: yarn build:e2e', 'yellow'); + this.log('This will compile TypeScript tests to JavaScript\n', 'cyan'); + return false; + } + return true; + } + + getAvailableTests() { + const tests = []; + + if (!fs.existsSync(this.sourceTestDir)) { + this.log('❌ Source test directory not found!', 'red'); + return tests; + } + + const files = fs.readdirSync(this.sourceTestDir) + .filter(file => file.endsWith('.test.ts')) + .sort(); + + files.forEach((file, index) => { + const testName = file.replace('.test.ts', ''); + const builtPath = path.join(this.testDir, file.replace('.ts', '.js')); + const exists = fs.existsSync(builtPath); + + + tests.push({ + index: index + 1, + name: testName, + file: file, + builtPath: `build-e2e/remixdesktop/test/tests/app/${file.replace('.ts', '.js')}`, // Correct path for yarn command + exists: exists + }); + }); + + return tests; + } + + displayTestList(tests) { + this.log('\n📋 Available Desktop Tests:', 'bright'); + this.log('=' .repeat(50), 'cyan'); + + tests.forEach(test => { + const status = test.exists ? '✅' : '❌'; + const statusText = test.exists ? 'Built' : 'Not built'; + this.log(`${test.index.toString().padStart(2)}. ${test.name.padEnd(25)} ${status} ${statusText}`, + test.exists ? 'green' : 'red'); + }); + + this.log('\n💡 Commands available:', 'bright'); + this.log(' [number] - Run specific test', 'cyan'); + this.log(' all - Run all built tests', 'cyan'); + this.log(' build - Run yarn build:e2e', 'cyan'); + this.log(' env - Check environment variables', 'cyan'); + this.log(' refresh - Refresh test list', 'cyan'); + this.log(' quit - Exit', 'cyan'); + this.log(''); + } + + checkEnvironment() { + this.log('\n🔍 Environment Variables Check:', 'bright'); + this.log('=' .repeat(40), 'cyan'); + + const dgitToken = process.env.DGIT_TOKEN; + if (dgitToken) { + this.log(`✅ DGIT_TOKEN: Set (${dgitToken.substring(0, 4)}...)`, 'green'); + } else { + this.log('❌ DGIT_TOKEN: Not set', 'red'); + this.log(' Required for GitHub tests', 'yellow'); + this.log(' Set with: export DGIT_TOKEN=your_github_token', 'cyan'); + } + + this.log(''); + } + + async runTest(testPath) { + // Check if this is a GitHub test and DGIT_TOKEN is required + if (testPath.includes('github') && !process.env.DGIT_TOKEN) { + this.log('\n⚠️ WARNING: DGIT_TOKEN environment variable not set!', 'yellow'); + this.log('GitHub tests require a valid GitHub token.', 'yellow'); + this.log('Set it with: export DGIT_TOKEN=your_github_token\n', 'cyan'); + } + + this.log(`\n� Building tests first...`, 'yellow'); + + // First build the tests + const buildCode = await this.buildTests(); + if (buildCode !== 0) { + this.log('❌ Build failed, cannot run test', 'red'); + return buildCode; + } + + this.log(`\n�🚀 Running test: ${testPath}`, 'yellow'); + this.log('Command: yarn test --test ' + testPath, 'cyan'); + this.log('-'.repeat(60), 'cyan'); + + return new Promise((resolve) => { + const child = spawn('yarn', ['test', '--test', testPath], { + stdio: 'inherit', + env: process.env + }); + + child.on('close', (code) => { + this.log('\n' + '-'.repeat(60), 'cyan'); + if (code === 0) { + this.log('✅ Test completed successfully!', 'green'); + } else { + this.log(`❌ Test failed with exit code: ${code}`, 'red'); + } + this.log(''); + resolve(code); + }); + + child.on('error', (error) => { + this.log(`❌ Error running test: ${error.message}`, 'red'); + resolve(1); + }); + }); + } + + async runAllTests(tests) { + const builtTests = tests.filter(test => test.exists); + + if (builtTests.length === 0) { + this.log('❌ No built tests found!', 'red'); + return; + } + + this.log(`\n🚀 Running ${builtTests.length} tests...`, 'yellow'); + + for (const test of builtTests) { + await this.runTest(test.builtPath); + + // Small delay between tests + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + this.log('🎉 All tests completed!', 'green'); + } + + async buildTests() { + this.log('\n🔨 Building tests with yarn build:e2e...', 'yellow'); + + return new Promise((resolve) => { + const child = spawn('yarn', ['build:e2e'], { + stdio: 'inherit', + env: process.env + }); + + child.on('close', (code) => { + if (code === 0) { + this.log('✅ Build completed successfully!', 'green'); + } else { + this.log(`❌ Build failed with exit code: ${code}`, 'red'); + } + resolve(code); + }); + }); + } + + async promptUser() { + return new Promise((resolve) => { + this.rl.question('Enter command: ', (answer) => { + resolve(answer.trim().toLowerCase()); + }); + }); + } + + async run() { + this.log('🧪 Remix Desktop Test Runner', 'bright'); + this.log('Welcome to the interactive test runner!', 'cyan'); + + while (true) { + const buildExists = await this.checkBuildExists(); + const tests = this.getAvailableTests(); + + this.displayTestList(tests); + + if (!buildExists) { + this.log('⚠️ Build required before running tests', 'yellow'); + } + + const userInput = await this.promptUser(); + + if (userInput === 'quit' || userInput === 'q' || userInput === 'exit') { + this.log('👋 Goodbye!', 'cyan'); + break; + } + + if (userInput === 'build' || userInput === 'b') { + await this.buildTests(); + continue; + } + + if (userInput === 'env' || userInput === 'e') { + this.checkEnvironment(); + continue; + } + + if (userInput === 'refresh' || userInput === 'r') { + this.log('🔄 Refreshing...', 'yellow'); + continue; + } + + if (userInput === 'all' || userInput === 'a') { + if (!buildExists) { + this.log('❌ Please build tests first with "build" command', 'red'); + continue; + } + await this.runAllTests(tests); + continue; + } + + // Check if input is a number + const testIndex = parseInt(userInput); + if (!isNaN(testIndex) && testIndex >= 1 && testIndex <= tests.length) { + const selectedTest = tests[testIndex - 1]; + + if (!selectedTest.exists) { + this.log('❌ Test not built yet. Run "build" first.', 'red'); + continue; + } + + await this.runTest(selectedTest.builtPath); + continue; + } + + this.log('❌ Invalid command. Try a number, "all", "build", "refresh", or "quit"', 'red'); + } + + this.rl.close(); + } +} + +// Run the test runner +if (require.main === module) { + const runner = new DesktopTestRunner(); + runner.run().catch(console.error); +} + +module.exports = DesktopTestRunner; \ No newline at end of file diff --git a/apps/remixdesktop/test/tests/app/circom-compiler.test.ts b/apps/remixdesktop/test/tests/app/circom-compiler.test.ts index 93f43c45a7f..6fab888a1b2 100644 --- a/apps/remixdesktop/test/tests/app/circom-compiler.test.ts +++ b/apps/remixdesktop/test/tests/app/circom-compiler.test.ts @@ -8,12 +8,14 @@ const tests = { }, 'Should create semaphore workspace': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="homeTabGetStartedsemaphore"]', 20000) .click('*[data-id="homeTabGetStartedsemaphore"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemcircuits"]') .click('*[data-id="treeViewLitreeViewItemcircuits"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcircuits/semaphore.circom"]') diff --git a/apps/remixdesktop/test/tests/app/circom-script.test.ts b/apps/remixdesktop/test/tests/app/circom-script.test.ts index ade75b17598..4738cf08701 100644 --- a/apps/remixdesktop/test/tests/app/circom-script.test.ts +++ b/apps/remixdesktop/test/tests/app/circom-script.test.ts @@ -8,12 +8,14 @@ const tests = { }, 'Should create semaphore workspace': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="homeTabGetStartedsemaphore"]', 20000) .click('*[data-id="homeTabGetStartedsemaphore"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemcircuits"]') .click('*[data-id="treeViewLitreeViewItemcircuits"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcircuits/semaphore.circom"]') diff --git a/apps/remixdesktop/test/tests/app/compiler.test.ts b/apps/remixdesktop/test/tests/app/compiler.test.ts index 8cb81d9e5e4..d51c52ece86 100644 --- a/apps/remixdesktop/test/tests/app/compiler.test.ts +++ b/apps/remixdesktop/test/tests/app/compiler.test.ts @@ -8,6 +8,7 @@ module.exports = { }, 'download compiler': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) .clickLaunchIcon('solidity') .pause(1000) @@ -26,6 +27,7 @@ module.exports = { }, 'refresh': function (browser: NightwatchBrowser) { browser.refresh() + .hideToolTips() .clickLaunchIcon('solidity') .waitForElementVisible('*[data-id="versionSelector"]') .click('*[data-id="versionSelector"]') diff --git a/apps/remixdesktop/test/tests/app/foundry.test.ts b/apps/remixdesktop/test/tests/app/foundry.test.ts index 4885c613532..744aa4d37d1 100644 --- a/apps/remixdesktop/test/tests/app/foundry.test.ts +++ b/apps/remixdesktop/test/tests/app/foundry.test.ts @@ -13,7 +13,7 @@ const tests = { done() }, installFoundry: function (browser: NightwatchBrowser) { - browser.perform(async (done) => { + browser.hideToolTips().perform(async (done) => { await downloadFoundry() await installFoundry() await initFoundryProject() diff --git a/apps/remixdesktop/test/tests/app/gist.test.ts b/apps/remixdesktop/test/tests/app/gist.test.ts index b53c741910b..22e20ec1eb6 100644 --- a/apps/remixdesktop/test/tests/app/gist.test.ts +++ b/apps/remixdesktop/test/tests/app/gist.test.ts @@ -21,7 +21,8 @@ const tests = { .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewDivtreeViewItemREADME.txt"]') }) .click('[data-id="treeViewLitreeViewItemcontracts"]') diff --git a/apps/remixdesktop/test/tests/app/git-ui.test.ts b/apps/remixdesktop/test/tests/app/git-ui.test.ts index c99aa090b7a..b8260337203 100644 --- a/apps/remixdesktop/test/tests/app/git-ui.test.ts +++ b/apps/remixdesktop/test/tests/app/git-ui.test.ts @@ -25,7 +25,7 @@ const tests = { }, 'run server #group1 #group2 #group3': function (browser: NightwatchBrowser) { - browser.perform(async (done) => { + browser.hideToolTips().perform(async (done) => { gitserver = await spawnGitServer('/tmp/') console.log('working directory', process.cwd()) done() @@ -47,7 +47,8 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') .hideToolTips() }) diff --git a/apps/remixdesktop/test/tests/app/git-ui_2.test.ts b/apps/remixdesktop/test/tests/app/git-ui_2.test.ts index 9a4e6836527..fa1d8124733 100644 --- a/apps/remixdesktop/test/tests/app/git-ui_2.test.ts +++ b/apps/remixdesktop/test/tests/app/git-ui_2.test.ts @@ -25,7 +25,7 @@ const tests = { }, 'run server #group1 #group2 #group3': function (browser: NightwatchBrowser) { - browser.perform(async (done) => { + browser.hideToolTips().perform(async (done) => { gitserver = await spawnGitServer('/tmp/') console.log('working directory', process.cwd()) done() @@ -47,7 +47,8 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') .hideToolTips() }) diff --git a/apps/remixdesktop/test/tests/app/git-ui_3.test.ts b/apps/remixdesktop/test/tests/app/git-ui_3.test.ts index 52384769589..73307b8a4d3 100644 --- a/apps/remixdesktop/test/tests/app/git-ui_3.test.ts +++ b/apps/remixdesktop/test/tests/app/git-ui_3.test.ts @@ -25,7 +25,7 @@ const tests = { }, 'run server #group1 #group2 #group3': function (browser: NightwatchBrowser) { - browser.perform(async (done) => { + browser.hideToolTips().perform(async (done) => { gitserver = await spawnGitServer('/tmp/') console.log('working directory', process.cwd()) done() @@ -47,7 +47,8 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') .hideToolTips() }) diff --git a/apps/remixdesktop/test/tests/app/git-ui_4.test.ts b/apps/remixdesktop/test/tests/app/git-ui_4.test.ts index aeda4f3810f..fe9404d76d9 100644 --- a/apps/remixdesktop/test/tests/app/git-ui_4.test.ts +++ b/apps/remixdesktop/test/tests/app/git-ui_4.test.ts @@ -25,7 +25,7 @@ const tests = { }, 'run server #group1 #group2 #group3': function (browser: NightwatchBrowser) { - browser.perform(async (done) => { + browser.hideToolTips().perform(async (done) => { gitserver = await spawnGitServer('/tmp/') console.log('working directory', process.cwd()) done() @@ -47,7 +47,8 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') .hideToolTips() }) diff --git a/apps/remixdesktop/test/tests/app/git.test.ts b/apps/remixdesktop/test/tests/app/git.test.ts index 246ae05a3cc..7ea5efaa94f 100644 --- a/apps/remixdesktop/test/tests/app/git.test.ts +++ b/apps/remixdesktop/test/tests/app/git.test.ts @@ -9,6 +9,7 @@ module.exports = { }, 'clone a repo': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) .waitForElementVisible('*[data-id="cloneFromGitButton"]') .click('*[data-id="cloneFromGitButton"]') @@ -21,7 +22,8 @@ module.exports = { .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') }) .end() diff --git a/apps/remixdesktop/test/tests/app/github.test.ts b/apps/remixdesktop/test/tests/app/github.test.ts index 1c3fb18d0aa..b00037bd931 100644 --- a/apps/remixdesktop/test/tests/app/github.test.ts +++ b/apps/remixdesktop/test/tests/app/github.test.ts @@ -1,5 +1,12 @@ import { NightwatchBrowser } from "nightwatch" +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + const tests = { before: function (browser: NightwatchBrowser, done: VoidFunction) { browser.hideToolTips() @@ -8,16 +15,18 @@ const tests = { 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + openTemplatesExplorer(browser) + + browser + .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') }) @@ -40,6 +49,7 @@ const tests = { 'login to github #group1 #group2': function (browser: NightwatchBrowser) { browser .waitForElementVisible('*[data-id="github-panel"]') + .click('*[data-id="github-panel"]') .waitForElementVisible('*[data-id="gitubUsername"]') .setValue('*[data-id="githubToken"]', process.env.DGIT_TOKEN) .pause(1000) @@ -56,6 +66,13 @@ const tests = { .waitForElementVisible('*[data-id="connected-link-bunsenstraat"]') .waitForElementVisible('*[data-id="remotes-panel"]') }, + 'check the FE shows logged in user #group1 #group2': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible({ + selector: '//*[@data-id="github-dropdown-toggle-login"]//span[contains(text(), "bunsenstraat")]', + locateStrategy: 'xpath' + }) + }, // 'check the FE for the auth user #group1 #group2': function (browser: NightwatchBrowser) { // browser // .clickLaunchIcon('filePanel') @@ -63,7 +80,6 @@ const tests = { // }, 'clone a repository #group1': function (browser: NightwatchBrowser) { browser - .clickLaunchIcon('dgit') .click('*[data-id="clone-panel"]') .click({ selector: '//*[@data-id="clone-panel-content"]//*[@data-id="fetch-repositories"]', @@ -108,11 +124,19 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[2]) + browser.hideToolTips().switchWindow(result.value[2]) + .hideToolTips() .pause(1000) .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') }) }, + 'check if user is still logged in after cloning #group1': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible({ + selector: '//*[@data-id="github-dropdown-toggle-login"]//span[contains(text(), "bunsenstraat")]', + locateStrategy: 'xpath' + }) + }, 'check if there is a README.md file #group1': function (browser: NightwatchBrowser) { browser .waitForElementVisible('*[data-id="treeViewLitreeViewItemREADME.md"]') @@ -232,20 +256,23 @@ const tests = { }) }, 'disconnect github #group1': function (browser: NightwatchBrowser) { - browser + + browser .waitForElementVisible('*[data-id="github-panel"]') .pause(1000) .click('*[data-id="github-panel"]') .waitForElementVisible('*[data-id="disconnect-github"]') .pause(1000) .click('*[data-id="disconnect-github"]') + .waitForElementNotPresent('*[data-id="connected-as-bunsenstraat"]') }, 'check the FE for the disconnected auth user #group1': function (browser: NightwatchBrowser) { browser - .clickLaunchIcon('filePanel') - .waitForElementNotPresent('*[data-id="filepanel-connected-img-bunsenstraat"]') - .waitForElementVisible('*[data-id="filepanel-login-github"]') + .waitForElementNotPresent({ + selector: '//*[@data-id="github-dropdown-toggle-login"]//span[contains(text(), "bunsenstraat")]', + locateStrategy: 'xpath' + }) }, } diff --git a/apps/remixdesktop/test/tests/app/github_2.test.ts b/apps/remixdesktop/test/tests/app/github_2.test.ts index 460a6c784a8..a9b048037f3 100644 --- a/apps/remixdesktop/test/tests/app/github_2.test.ts +++ b/apps/remixdesktop/test/tests/app/github_2.test.ts @@ -1,5 +1,12 @@ import { NightwatchBrowser } from "nightwatch" +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + const tests = { before: function (browser: NightwatchBrowser, done: VoidFunction) { browser.hideToolTips() @@ -8,16 +15,18 @@ const tests = { 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + openTemplatesExplorer(browser) + + browser + .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') }) @@ -37,7 +46,6 @@ const tests = { }, 'login to github #group1 #group2': function (browser: NightwatchBrowser) { browser - .waitForElementVisible('*[data-id="github-panel"]') .waitForElementVisible('*[data-id="gitubUsername"]') .setValue('*[data-id="githubToken"]', process.env.DGIT_TOKEN) .pause(1000) @@ -54,6 +62,13 @@ const tests = { .waitForElementVisible('*[data-id="connected-link-bunsenstraat"]') .waitForElementVisible('*[data-id="remotes-panel"]') }, + 'check the FE shows logged in user #group1 #group2': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible({ + selector: '//*[@data-id="github-dropdown-toggle-login"]//span[contains(text(), "bunsenstraat")]', + locateStrategy: 'xpath' + }) + }, // 'check the FE for the auth user #group1 #group2': function (browser: NightwatchBrowser) { // browser // .clickLaunchIcon('filePanel') diff --git a/apps/remixdesktop/test/tests/app/github_3.test.ts b/apps/remixdesktop/test/tests/app/github_3.test.ts index 978f5cdf548..05801d18a2d 100644 --- a/apps/remixdesktop/test/tests/app/github_3.test.ts +++ b/apps/remixdesktop/test/tests/app/github_3.test.ts @@ -1,5 +1,12 @@ import { NightwatchBrowser } from "nightwatch" +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + const useIsoGit = process.argv.includes('--use-isogit'); let commitCount = 0 let branchCount = 0 @@ -11,16 +18,18 @@ const tests = { 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + openTemplatesExplorer(browser) + + browser + .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[1]) + browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') }) @@ -43,6 +52,7 @@ const tests = { 'login to github #group1 #group2': function (browser: NightwatchBrowser) { browser .waitForElementVisible('*[data-id="github-panel"]') + .click('*[data-id="github-panel"]') .waitForElementVisible('*[data-id="gitubUsername"]') .setValue('*[data-id="githubToken"]', process.env.DGIT_TOKEN) .pause(1000) @@ -59,6 +69,13 @@ const tests = { .waitForElementVisible('*[data-id="connected-link-bunsenstraat"]') .waitForElementVisible('*[data-id="remotes-panel"]') }, + 'check the FE shows logged in user #group1 #group2': function (browser: NightwatchBrowser) { + browser + .waitForElementVisible({ + selector: '//*[@data-id="github-dropdown-toggle-login"]//span[contains(text(), "bunsenstraat")]', + locateStrategy: 'xpath' + }) + }, // 'check the FE for the auth user #group1 #group2': function (browser: NightwatchBrowser) { // browser // .clickLaunchIcon('filePanel') @@ -67,7 +84,6 @@ const tests = { // pagination test 'clone repo #group3': function (browser: NightwatchBrowser) { browser - .clickLaunchIcon('dgit') .waitForElementVisible('*[data-id="clone-panel"]') .click('*[data-id="clone-panel"]') .waitForElementVisible('*[data-id="clone-url"]') @@ -80,7 +96,8 @@ const tests = { .pause(5000) .windowHandles(function (result) { console.log(result.value) - browser.switchWindow(result.value[2]) + browser.hideToolTips().switchWindow(result.value[2]) + .hideToolTips() .pause(1000) .waitForElementVisible('*[data-id="treeViewLitreeViewItem.git"]') }) diff --git a/apps/remixdesktop/test/tests/app/metamask.test.ts b/apps/remixdesktop/test/tests/app/metamask.test.ts index 811ad50fa76..d85423a78c8 100644 --- a/apps/remixdesktop/test/tests/app/metamask.test.ts +++ b/apps/remixdesktop/test/tests/app/metamask.test.ts @@ -1,5 +1,12 @@ import { NightwatchBrowser } from 'nightwatch' +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + const tests = { before: function (browser: NightwatchBrowser, done: VoidFunction) { browser.hideToolTips() @@ -7,16 +14,19 @@ const tests = { }, 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') + + openTemplatesExplorer(browser) + + browser .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) browser.hideToolTips() .switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') .click('*[data-id="treeViewLitreeViewItemtests"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcontracts"]') diff --git a/apps/remixdesktop/test/tests/app/offline.test.ts b/apps/remixdesktop/test/tests/app/offline.test.ts index 5ab1c9e54c4..06b1cf62ba4 100644 --- a/apps/remixdesktop/test/tests/app/offline.test.ts +++ b/apps/remixdesktop/test/tests/app/offline.test.ts @@ -1,5 +1,11 @@ import { NightwatchBrowser } from 'nightwatch' +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} module.exports = { '@offline': true, @@ -9,16 +15,18 @@ module.exports = { }, 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + openTemplatesExplorer(browser) + + browser + .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') .click('*[data-id="treeViewLitreeViewItemtests"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcontracts"]') diff --git a/apps/remixdesktop/test/tests/app/search.test.ts b/apps/remixdesktop/test/tests/app/search.test.ts index fff40cd706b..952d9751446 100644 --- a/apps/remixdesktop/test/tests/app/search.test.ts +++ b/apps/remixdesktop/test/tests/app/search.test.ts @@ -1,5 +1,11 @@ import { NightwatchBrowser } from 'nightwatch' +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} module.exports = { before: function (browser: NightwatchBrowser, done: VoidFunction) { @@ -8,17 +14,20 @@ module.exports = { }, 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + openTemplatesExplorer(browser) + + browser + .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) - browser.hideToolTips() + browser + .hideToolTips() .switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') .click('*[data-id="treeViewLitreeViewItemtests"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcontracts"]') @@ -193,6 +202,7 @@ module.exports = { }, 'Should hide button when edited content is the same #group2': function (browser: NightwatchBrowser) { browser.refresh() + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeSidePanel"]') .addFile('test.sol', { content: '123' }) .pause(4000) diff --git a/apps/remixdesktop/test/tests/app/slitherlinux.test.ts b/apps/remixdesktop/test/tests/app/slitherlinux.test.ts index 5c7252ddae1..8b8bfbdb35e 100644 --- a/apps/remixdesktop/test/tests/app/slitherlinux.test.ts +++ b/apps/remixdesktop/test/tests/app/slitherlinux.test.ts @@ -1,26 +1,36 @@ import {NightwatchBrowser} from 'nightwatch' import { ChildProcess, spawn, execSync } from 'child_process' import { homedir } from 'os' + +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + const tests = { before: function (browser: NightwatchBrowser, done: VoidFunction) { browser.hideToolTips() done() }, open: function (browser: NightwatchBrowser) { - browser.waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') + browser.hideToolTips().waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') }, 'open default template': function (browser: NightwatchBrowser) { browser .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('button[data-id="landingPageImportFromTemplate"]') - .click('button[data-id="landingPageImportFromTemplate"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') + + openTemplatesExplorer(browser) + + browser .scrollAndClick('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') .click('*[data-id="treeViewLitreeViewItemtests"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcontracts"]') diff --git a/apps/remixdesktop/test/tests/app/templates.test.ts b/apps/remixdesktop/test/tests/app/templates.test.ts index e9e7c73375e..bc353378cc2 100644 --- a/apps/remixdesktop/test/tests/app/templates.test.ts +++ b/apps/remixdesktop/test/tests/app/templates.test.ts @@ -1,5 +1,13 @@ import { NightwatchBrowser } from 'nightwatch' + +function openTemplatesExplorer(browser: NightwatchBrowser) { + browser + .click('*[data-id="workspacesSelect"]') + .click('*[data-id="workspacecreate"]') + .waitForElementPresent('*[data-id="create-remixDefault"]') +} + module.exports = { before: function (browser: NightwatchBrowser, done: VoidFunction) { browser.hideToolTips() @@ -7,15 +15,18 @@ module.exports = { }, 'open default template': function (browser: NightwatchBrowser) { browser + .hideToolTips() .waitForElementVisible('*[data-id="remixIdeIconPanel"]', 10000) - .waitForElementVisible('*[data-id="createWorkspaceButton"]') - .click('*[data-id="createWorkspaceButton"]') - .waitForElementPresent('*[data-id="create-remixDefault"]') - .scrollAndClick('*[data-id="create-remixDefault"]') + + openTemplatesExplorer(browser) + + browser + .click('*[data-id="create-remixDefault"]') .pause(3000) .windowHandles(function (result) { console.log(result.value) browser.hideToolTips().switchWindow(result.value[1]) + .hideToolTips() .waitForElementVisible('*[data-id="treeViewLitreeViewItemtests"]') .click('*[data-id="treeViewLitreeViewItemtests"]') .waitForElementVisible('*[data-id="treeViewLitreeViewItemcontracts"]') @@ -29,11 +40,9 @@ module.exports = { }) }, 'open template explorer and add template to current': function (browser: NightwatchBrowser) { + openTemplatesExplorer(browser) + browser - .waitForElementVisible('*[data-id="workspacesSelect"]', 10000) - .click('*[data-id="workspacesSelect"]') - .waitForElementVisible('*[data-id="workspacecreate.desktop"]') - .click('*[data-id="workspacecreate.desktop"]') .waitForElementVisible('*[data-id="add-simpleEip7702"]') .scrollAndClick('*[data-id="add-simpleEip7702"]') .waitForElementVisible('*[data-id="treeViewDivtreeViewItemcontracts/Example7702.sol"]') diff --git a/apps/remixdesktop/test/tests/app/xterm.test.ts b/apps/remixdesktop/test/tests/app/xterm.test.ts index 6fa8fe5eda9..89b303b251f 100644 --- a/apps/remixdesktop/test/tests/app/xterm.test.ts +++ b/apps/remixdesktop/test/tests/app/xterm.test.ts @@ -9,7 +9,7 @@ const tests = { done() }, open: function (browser: NightwatchBrowser) { - browser.waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') + browser.hideToolTips().waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') }, 'open xterm linux and create a file': function (browser: NightwatchBrowser) { browser diff --git a/apps/remixdesktop/test/tests/app/xtermwin.test.ts b/apps/remixdesktop/test/tests/app/xtermwin.test.ts index 13d78badd75..805013bbb78 100644 --- a/apps/remixdesktop/test/tests/app/xtermwin.test.ts +++ b/apps/remixdesktop/test/tests/app/xtermwin.test.ts @@ -6,7 +6,7 @@ const tests = { done() }, open: function (browser: NightwatchBrowser) { - browser.waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') + browser.hideToolTips().waitForElementVisible('*[data-id="openFolderButton"]', 10000).click('*[data-id="openFolderButton"]') }, 'open xterm window and create a file': function (browser: NightwatchBrowser) { browser diff --git a/apps/remixdesktop/test_only.json b/apps/remixdesktop/test_only.json index 35753ad6e43..ee0d9088fe1 100644 --- a/apps/remixdesktop/test_only.json +++ b/apps/remixdesktop/test_only.json @@ -4,6 +4,8 @@ "asar": true, "generateUpdatesFilesForAllChannels": false, "icon": "assets", + "afterSign": null, + "afterAllArtifactBuild": null, "files": [ "build/**/*", "node_modules/node-pty-prebuilt-multiarch/**/*" @@ -18,18 +20,13 @@ } ], "mac": { + "target": "dir", "category": "public.app-category.productivity", "icon": "assets/icon.png", "darkModeSupport": true, - "hardenedRuntime": true, + "hardenedRuntime": false, "gatekeeperAssess": false, - "entitlements": "entitlements.mac.plist", - "entitlementsInherit": "entitlements.mac.plist", - "extendInfo": "Info.plist" - }, - "dmg": { - "writeUpdateInfo": true, - "sign": false + "identity": null }, "nsis": { "createDesktopShortcut": "always", @@ -39,10 +36,7 @@ "differentialPackage": false }, "win": { - "target": [ - "nsis" - ], - "artifactName": "Remix-Desktop-Setup-${version}.${ext}", + "target": "dir", "icon": "assets/icon.png", "forceCodeSigning": false }, diff --git a/libs/remix-ui/git/src/components/buttons/gituibutton.tsx b/libs/remix-ui/git/src/components/buttons/gituibutton.tsx index ee8c68a11bb..50f2ebb1ffb 100644 --- a/libs/remix-ui/git/src/components/buttons/gituibutton.tsx +++ b/libs/remix-ui/git/src/components/buttons/gituibutton.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useContext, forwardRef } from 'react' import { gitPluginContext } from '../gitui' import { CustomTooltip } from '@remix-ui/helper'; @@ -8,11 +8,11 @@ interface ButtonWithContextProps { disabledCondition?: boolean; // Optional additional disabling condition // You can add other props if needed, like 'type', 'className', etc. [key: string]: any; // Allow additional props to be passed - tooltip?: string; + tooltip?: string | JSX.Element; } // This component extends a button, disabling it when loading is true -const GitUIButton = ({ children, disabledCondition = false, ...rest }: ButtonWithContextProps) => { +const GitUIButton = forwardRef(({ children, disabledCondition = false, ...rest }, ref) => { const { loading } = React.useContext(gitPluginContext) const isDisabled = loading || disabledCondition @@ -21,18 +21,20 @@ const GitUIButton = ({ children, disabledCondition = false, ...rest }: ButtonWit return ( - ); } else { return ( - ); } -}; +}); + +GitUIButton.displayName = 'GitUIButton'; export default GitUIButton; \ No newline at end of file diff --git a/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx b/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx index af47f20be76..e6cebfd124d 100644 --- a/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx +++ b/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx @@ -96,34 +96,48 @@ export const SourceControlButtons = (props: SourceControlButtonsProps) => { {props.panel === gitUIPanels.COMMITS || props.panel === gitUIPanels.SOURCECONTROL ? ( <> - - -
- {syncState.commitsBehind.length ?
{syncState.commitsBehind.length}
: null} - -
-
-
- - -
- {syncState.commitsAhead.length ?
{syncState.commitsAhead.length}
: null} - -
-
-
- - - - - + +
+ {syncState.commitsBehind.length ?
{syncState.commitsBehind.length}
: null} + +
+
+ +
+ {syncState.commitsAhead.length ?
{syncState.commitsAhead.length}
: null} + +
+
+ + + ) : null} - }> - - - - + } + > + +
) } diff --git a/libs/remix-ui/git/src/lib/listeners.ts b/libs/remix-ui/git/src/lib/listeners.ts index f5079465649..f51d9b3cc2b 100644 --- a/libs/remix-ui/git/src/lib/listeners.ts +++ b/libs/remix-ui/git/src/lib/listeners.ts @@ -46,6 +46,11 @@ export const setCallBacks = (viewPlugin: Plugin, gitDispatcher: React.Dispatch { + if (isActive) { + loadGitHubUserFromToken(); + } + }); plugin.call('manager', 'isActive', 'dgitApi').then( (isActive) => { if (isActive) { @@ -196,6 +201,7 @@ export const setCallBacks = (viewPlugin: Plugin, gitDispatcher: React.Dispatch) => { if (p.name === 'dgitApi') { + loadGitHubUserFromToken(); plugin.off('manager', 'pluginActivated'); } diff --git a/libs/remix-ui/helper/src/lib/components/custom-tooltip.tsx b/libs/remix-ui/helper/src/lib/components/custom-tooltip.tsx index f0fd240037b..d0fe98b7d11 100644 --- a/libs/remix-ui/helper/src/lib/components/custom-tooltip.tsx +++ b/libs/remix-ui/helper/src/lib/components/custom-tooltip.tsx @@ -1,41 +1,51 @@ -import { use } from 'chai' -import React, { useEffect } from 'react' -import { Fragment } from 'react' -import { OverlayTrigger, Popover, Tooltip } from 'react-bootstrap' +import React, { useEffect, useState, Fragment } from 'react' +import { OverlayTrigger, Popover } from 'react-bootstrap' import { CustomTooltipType } from '../../types/customtooltip' export function CustomTooltip({ children, placement, tooltipId, tooltipClasses, tooltipText, tooltipTextClasses, delay, hide, show }: CustomTooltipType) { + // Global tooltip disable flag for E2E tests + const [globalDisable, setGlobalDisable] = useState((window as any).REMIX_DISABLE_TOOLTIPS === true) + + // Listen for custom event when tooltip disable flag changes + useEffect(() => { + const handleTooltipToggle = (event: CustomEvent) => { + setGlobalDisable(event.detail.disabled) + } + + window.addEventListener('remix-tooltip-toggle', handleTooltipToggle as EventListener) + return () => { + window.removeEventListener('remix-tooltip-toggle', handleTooltipToggle as EventListener) + } + }, [tooltipId]) + if (typeof tooltipText !== 'string') { - const newTooltipText = React.cloneElement(tooltipText, { + tooltipText = React.cloneElement(tooltipText, { className: ' bg-body text-wrap p-1 px-2 ' }) - tooltipText = newTooltipText + } + + // If hidden or globally disabled, just return children without tooltip + if (hide || globalDisable) { + return <>{children} } return ( - (!hide ? ( - - - - {typeof tooltipText === 'string' ? {tooltipText} : tooltipText} - - - }> - {children} - - - ) : ( - - <>{children} - - )) + + + {typeof tooltipText === 'string' ? {tooltipText} : tooltipText} + + + }> + {children} + ) } diff --git a/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspacesElectron.tsx b/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspacesElectron.tsx new file mode 100644 index 00000000000..0023e274ccb --- /dev/null +++ b/libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspacesElectron.tsx @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useState, useRef, useReducer, useEffect } from 'react' +import { CustomTooltip } from '@remix-ui/helper' +const _paq = (window._paq = window._paq || []) // eslint-disable-line + +interface HomeTabFileProps { + plugin: any +} + +function HomeTabRecentWorkspacesElectron({ plugin }: HomeTabFileProps) { + const [state, setState] = useState<{ + recentFolders: Array + }>({ + recentFolders: [], + }) + const [loadingWorkspace, setLoadingWorkspace] = useState(null) + const [showAll, setShowAll] = useState(false) + + useEffect(() => { + const loadRecentFolders = async () => { + try { + const recentFolders = await plugin.call('fs', 'getRecentFolders') + setState(prevState => ({ + ...prevState, + recentFolders: recentFolders || [] + })) + } catch (error) { + console.error('Error loading recent folders:', error) + } + } + + loadRecentFolders() + }, [plugin]) + + const handleOpenRecentWorkspace = async (folderPath: string) => { + try { + setLoadingWorkspace(folderPath) + _paq.push(['trackEvent', 'hometab', 'recentWorkspacesElectron', 'loadRecentWorkspace']) + await plugin.call('fs', 'openFolderInSameWindow', folderPath) + } catch (error) { + console.error('Error opening recent workspace:', error) + } finally { + setLoadingWorkspace(null) + } + } + + const handleOpenInNewWindow = async (folderPath: string) => { + try { + _paq.push(['trackEvent', 'hometab', 'recentWorkspacesElectron', 'openInNewWindow']) + await plugin.call('fs', 'openFolder', folderPath) + } catch (error) { + console.error('Error opening folder in new window:', error) + } + } + + const handleRevealInExplorer = async (folderPath: string) => { + try { + _paq.push(['trackEvent', 'hometab', 'recentWorkspacesElectron', 'revealInExplorer']) + await plugin.call('fs', 'revealInExplorer', { path: [folderPath]}, true) + } catch (error) { + console.error('Error revealing folder in explorer:', error) + } + } + + const handleRemoveFromRecents = async (folderPath: string) => { + try { + await plugin.call('fs', 'removeRecentFolder', folderPath) + setState(prevState => ({ + ...prevState, + recentFolders: prevState.recentFolders.filter(folder => folder !== folderPath) + })) + _paq.push(['trackEvent', 'hometab', 'recentWorkspacesElectron', 'removeFromRecents']) + } catch (error) { + console.error('Error removing folder from recents:', error) + } + } + + const getWorkspaceName = (folderPath: string) => { + return folderPath.split('/').pop() || folderPath + } + + return ( +
+
+ +
+
+ { + Array.isArray(state.recentFolders) && state.recentFolders.slice(0, showAll ? state.recentFolders.length : 3).map((folderPath: string, index) => { + const workspaceName = getWorkspaceName(folderPath) + + return ( +
+ { loadingWorkspace === folderPath ? : } +
+ { + e.preventDefault() + handleOpenRecentWorkspace(folderPath) + }}> + {workspaceName} + +
+ + handleOpenInNewWindow(folderPath)}> + + + handleRevealInExplorer(folderPath)}> + + + handleRemoveFromRecents(folderPath)}> + +
+
+
+ ) + }) + } +
+ + {state.recentFolders && state.recentFolders.length > 3 && ( + + )} +
+
+
+ ) +} + +export default HomeTabRecentWorkspacesElectron diff --git a/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx b/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx index 5cdab46e7c0..e005f897e1b 100644 --- a/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx +++ b/libs/remix-ui/home-tab/src/lib/remix-ui-home-tab.tsx @@ -3,6 +3,7 @@ import './remix-ui-home-tab.css' import { ThemeContext, themes } from './themeContext' import HomeTabTitle from './components/homeTabTitle' import HomeTabRecentWorkspaces from './components/homeTabRecentWorkspaces' +import HomeTabRecentWorkspacesElectron from './components/homeTabRecentWorkspacesElectron' import HomeTabScamAlert from './components/homeTabScamAlert' import HomeTabFeaturedPlugins from './components/homeTabFeaturedPlugins' import { AppContext, appPlatformTypes, platformContext } from '@remix-ui/app' @@ -98,8 +99,7 @@ export const RemixUiHomeTab = (props: RemixUiHomeTabProps) => {
- - {/* {!(platform === appPlatformTypes.desktop) ? : } */} + {!(platform === appPlatformTypes.desktop) ? : }
diff --git a/libs/remix-ui/run-tab/src/lib/components/environment.tsx b/libs/remix-ui/run-tab/src/lib/components/environment.tsx index 7e6361dd711..9c6de733433 100644 --- a/libs/remix-ui/run-tab/src/lib/components/environment.tsx +++ b/libs/remix-ui/run-tab/src/lib/components/environment.tsx @@ -44,6 +44,7 @@ export function EnvironmentUI(props: EnvironmentProps) { const devProviders = providers.filter(isDevByLabel) const walletConnect = providers.find(p => p.name === 'walletconnect' || p.name === 'walletConnect') const httpProvider = providers.find(p => p.name === 'basic-http-provider' || p.name === 'web3Provider' || p.name === 'basicHttpProvider') + const electrOnProvider = providers.find(p => p.name === 'desktopHost') const handleChangeExEnv = (env: string) => { if (props.selectedEnv === env || isSwitching) return @@ -245,6 +246,16 @@ export function EnvironmentUI(props: EnvironmentProps) { )} + {electrOnProvider && ( + handleChangeExEnv(electrOnProvider.name)} + data-id={`dropdown-item-${electrOnProvider.name}`} + > + {electrOnProvider.displayName} + + )} + {walletConnect && ( { - diff --git a/libs/remix-ui/terminal/src/lib/terminalWelcome.tsx b/libs/remix-ui/terminal/src/lib/terminalWelcome.tsx index d4f79b38d2c..149bfa4edf9 100644 --- a/libs/remix-ui/terminal/src/lib/terminalWelcome.tsx +++ b/libs/remix-ui/terminal/src/lib/terminalWelcome.tsx @@ -1,7 +1,15 @@ import React, {useEffect} from 'react' // eslint-disable-line import { FormattedMessage } from 'react-intl' +import { Registry } from '@remix-project/remix-lib' const TerminalWelcomeMessage = ({ packageJson, storage }) => { + // Don't show the welcome message in Electron - desktop client shows its own version + const isDesktop = Registry.getInstance().get('platform')?.api?.isDesktop?.() || false + + if (isDesktop) { + return null + } + return (
diff --git a/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx b/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx new file mode 100644 index 00000000000..01ea7003085 --- /dev/null +++ b/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx @@ -0,0 +1,240 @@ +/* eslint-disable @nrwl/nx/enforce-module-boundaries */ +import React, { useContext, useState, useEffect } from 'react' +import { Button, Dropdown } from 'react-bootstrap' +import { TopbarContext } from '../context/topbarContext' +import { appPlatformTypes, platformContext } from '@remix-ui/app' +import { CustomTooltip } from '@remix-ui/helper' +import path from 'path' + +interface ElectronWorkspaceMenuProps { + showMain: boolean + setShowMain: (show: boolean) => void + openFolder: () => Promise + createWorkspace: () => void +} + +export const ElectronWorkspaceMenu: React.FC = ({ + showMain, + setShowMain, + openFolder, + createWorkspace +}) => { + const [showAllRecent, setShowAllRecent] = useState(false) + const global = useContext(TopbarContext) + const platform = useContext(platformContext) + + // Get recent folders methods from context + const { recentFolders, openRecentFolder, openRecentFolderInNewWindow, removeRecentFolder, revealRecentFolderInExplorer } = global || {} + + // Reset show all state when dropdown closes + useEffect(() => { + if (!showMain) { + setShowAllRecent(false) + } + }, [showMain]) + + // Only render on desktop platform + if (platform !== appPlatformTypes.desktop) { + return null + } + + return ( + + {/* Recent Folders Section */} + {recentFolders && recentFolders.length > 0 && ( + <> +
+ Recent Folders +
+
+ {(showAllRecent ? recentFolders : recentFolders.slice(0, 8)).map((folder, index) => { + const folderName = path.basename(folder) + return ( +
{ + e.currentTarget.style.backgroundColor = 'rgba(0, 123, 255, 0.1)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent' + }} + > +
+ + + + + + + + + +
+ + + +
+ ) + })} + {recentFolders.length > 8 && !showAllRecent && ( +
+ +
+ )} + {recentFolders.length > 8 && showAllRecent && ( +
+ +
+ )} +
+ + + )} + +
+ { + openFolder() + setShowMain(false) + }} + style={{ + backgroundColor: 'transparent', + color: 'inherit', + }} + > + + + { + createWorkspace() + setShowMain(false) + }} + style={{ + backgroundColor: 'transparent', + color: 'inherit', + }} + > + + +
+
+ ) +} diff --git a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx index a71c318e7b2..63d4fa400ca 100644 --- a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx +++ b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx @@ -7,7 +7,10 @@ import { FiMoreVertical } from 'react-icons/fi' import { TopbarContext } from '../context/topbarContext' import { getWorkspaces } from 'libs/remix-ui/workspace/src/lib/actions' import { WorkspaceMetadata } from 'libs/remix-ui/workspace/src/lib/types' +import { appPlatformTypes, platformContext } from '@remix-ui/app' +import path from 'path' import { DesktopDownload } from 'libs/remix-ui/desktop-download' +import { ElectronWorkspaceMenu } from './ElectronWorkspaceMenu' interface Branch { name: string @@ -78,8 +81,10 @@ export const WorkspacesDropdown: React.FC = ({ menuItem const [showMain, setShowMain] = useState(false) const [openSub, setOpenSub] = useState(null) const global = useContext(TopbarContext) + const platform = useContext(platformContext) const [openSubmenuId, setOpenSubmenuId] = useState(null); const iconRefs = useRef({}); + const [currentWorkingDir, setCurrentWorkingDir] = useState('') const toggleSubmenu = (id) => { setOpenSubmenuId((current) => (current === id ? null : id)); @@ -101,37 +106,80 @@ export const WorkspacesDropdown: React.FC = ({ menuItem ] }, []) + // For desktop platform, listen to working directory changes useEffect(() => { - global.plugin.on('filePanel', 'setWorkspace', async(workspace) => { - setTogglerText(workspace.name) - let workspaces = [] - const fromLocalStore = localStorage.getItem('currentWorkspace') - workspaces = await getWorkspaces() - const current = workspaces.find((workspace) => workspace.name === fromLocalStore) - setSelectedWorkspace(current) - }) + if (platform === appPlatformTypes.desktop) { + const getWorkingDir = async () => { + try { + const workingDir = await global.plugin.call('fs', 'getWorkingDir') + setCurrentWorkingDir(workingDir) + if (workingDir) { + const dirName = path.basename(workingDir) + setTogglerText(dirName || workingDir) + } else { + setTogglerText('No project open') + } + } catch (error) { + console.error('Error getting working directory:', error) + setTogglerText('No project open') + } + } + + // Listen for working directory changes + global.plugin.on('fs', 'workingDirChanged', (dir: string) => { + setCurrentWorkingDir(dir) + if (dir) { + const dirName = path.basename(dir) + setTogglerText(dirName || dir) + } else { + setTogglerText('No project open') + } + }) - return () => { - global.plugin.off('filePanel', 'setWorkspace') + // Get initial working directory + getWorkingDir() + + return () => { + global.plugin.off('fs', 'workingDirChanged') + } } - }, [global.plugin.filePanel.currentWorkspaceMetadata]) + }, [platform, global.plugin]) useEffect(() => { - let workspaces: any[] = [] - - try { - setTimeout(async () => { + if (platform !== appPlatformTypes.desktop) { + global.plugin.on('filePanel', 'setWorkspace', async(workspace) => { + setTogglerText(workspace.name) + let workspaces = [] + const fromLocalStore = localStorage.getItem('currentWorkspace') workspaces = await getWorkspaces() - const updated = (workspaces || []).map((workspace) => { - (workspace as any).submenu = subItems - return workspace as any - }) - setMenuItems(updated) - }, 150) - } catch (error) { - console.info('Error fetching workspaces:', error) + const current = workspaces.find((workspace) => workspace.name === fromLocalStore) + setSelectedWorkspace(current) + }) + + return () => { + global.plugin.off('filePanel', 'setWorkspace') + } } - }, [togglerText, openSubmenuId]) + }, [global.plugin.filePanel.currentWorkspaceMetadata, platform]) + + useEffect(() => { + if (platform !== appPlatformTypes.desktop) { + let workspaces: any[] = [] + + try { + setTimeout(async () => { + workspaces = await getWorkspaces() + const updated = (workspaces || []).map((workspace) => { + (workspace as any).submenu = subItems + return workspace as any + }) + setMenuItems(updated) + }, 150) + } catch (error) { + console.info('Error fetching workspaces:', error) + } + } + }, [togglerText, openSubmenuId, platform]) useClickOutside([mainRef, ...subRefs], () => { setShowMain(false) @@ -141,6 +189,51 @@ export const WorkspacesDropdown: React.FC = ({ menuItem const toggleSub = (idx: number) => setOpenSub(prev => (prev === idx ? null : idx)) + const openFolder = async () => { + console.log('Opening folder...') + try { + await global.plugin.call('fs', 'openFolderInSameWindow') + } catch (error) { + console.error('Error opening folder:', error) + } + } + + // Render simplified dropdown for desktop + if (platform === appPlatformTypes.desktop) { + return ( + + +
+ {togglerText} +
+
+ +
+ ) + } + + // Original web dropdown implementation + + // Original web dropdown implementation return ( void, cancelLabel?: string, cancelFn?: () => void) => void + modal:(title: string | JSX.Element, message: string | JSX.Element, okLabel: string, okFn: () => void, cancelLabel?: string, cancelFn?: () => void) => void, + recentFolders: string[], + fetchRecentFolders: () => Promise, + openRecentFolder: (path: string) => Promise, + openRecentFolderInNewWindow: (path: string) => Promise, + removeRecentFolder: (path: string) => Promise, + revealRecentFolderInExplorer: (path: string) => Promise }>(null) diff --git a/libs/remix-ui/top-bar/src/context/topbarProvider.tsx b/libs/remix-ui/top-bar/src/context/topbarProvider.tsx index feb880bc7c4..2949ca9c81b 100644 --- a/libs/remix-ui/top-bar/src/context/topbarProvider.tsx +++ b/libs/remix-ui/top-bar/src/context/topbarProvider.tsx @@ -5,6 +5,7 @@ import {ModalDialog} from '@remix-ui/modal-dialog' // eslint-disable-line import {Toaster} from '@remix-ui/toaster' // eslint-disable-line import { browserReducer, browserInitialState } from 'libs/remix-ui/workspace/src/lib/reducers/workspace' import { branch } from '@remix-ui/git' +import { appPlatformTypes, platformContext } from '@remix-ui/app' import { initWorkspace, fetchDirectory, @@ -62,6 +63,7 @@ export interface TopbarProviderProps { export const TopbarProvider = (props: TopbarProviderProps) => { const { plugin } = props + const platform = useContext(platformContext) const [fs, fsDispatch] = useReducer(browserReducer, browserInitialState) const [focusModal, setFocusModal] = useState({ hide: true, @@ -75,6 +77,62 @@ export const TopbarProvider = (props: TopbarProviderProps) => { const [modals, setModals] = useState([]) const [focusToaster, setFocusToaster] = useState('') const [toasters, setToasters] = useState([]) + const [recentFolders, setRecentFolders] = useState([]) + + const fetchRecentFolders = async () => { + try { + const folders = await plugin.call('fs', 'getRecentFolders') + setRecentFolders(folders || []) + } catch (error) { + console.error('Error fetching recent folders:', error) + setRecentFolders([]) + } + } + + const openRecentFolder = async (path: string) => { + try { + await plugin.call('fs', 'setWorkingDir', path) + // Refresh recent folders list since order might have changed + setTimeout(fetchRecentFolders, 200) + } catch (error) { + console.error('Error opening recent folder:', error) + } + } + + const openRecentFolderInNewWindow = async (path: string) => { + try { + await plugin.call('fs', 'openFolder', path) + } catch (error) { + console.error('Error opening recent folder in new window:', error) + } + } + + const removeRecentFolder = async (path: string) => { + try { + await plugin.call('fs', 'removeRecentFolder', path) + // Refresh the recent folders list + setTimeout(fetchRecentFolders, 100) + } catch (error) { + console.error('Error removing recent folder:', error) + } + } + + const revealRecentFolderInExplorer = async (path: string) => { + try { + await plugin.call('fs', 'revealInExplorer', { path: [path]}, true) + } catch (error) { + console.error('Error revealing folder in explorer:', error) + } + } + + // Fetch recent folders on desktop platform initialization + useEffect(() => { + if (platform === appPlatformTypes.desktop) { + // Fetch recent folders after a delay to ensure workspace is initialized + fetchRecentFolders() + + } + }, [platform]) useEffect(() => { if (modals.length > 0) { @@ -151,6 +209,12 @@ export const TopbarProvider = (props: TopbarProviderProps) => { plugin: plugin as unknown as Topbar, modal, toast, + recentFolders, + fetchRecentFolders, + openRecentFolder, + openRecentFolderInNewWindow, + removeRecentFolder, + revealRecentFolderInExplorer } return ( diff --git a/libs/remix-ui/top-bar/src/index.ts b/libs/remix-ui/top-bar/src/index.ts index f372a9ff5bb..c1e7de0c045 100644 --- a/libs/remix-ui/top-bar/src/index.ts +++ b/libs/remix-ui/top-bar/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/remix-ui-topbar' export * from './context/topbarContext' export * from './context/topbarProvider' +export * from './components/ElectronWorkspaceMenu' diff --git a/libs/remix-ui/workspace/src/lib/components/electron-menu.tsx b/libs/remix-ui/workspace/src/lib/components/electron-menu.tsx index 2c0a2d626ca..11625d06f98 100644 --- a/libs/remix-ui/workspace/src/lib/components/electron-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/electron-menu.tsx @@ -30,43 +30,76 @@ export const ElectronMenu = (props: { return ( (platform !== appPlatformTypes.desktop) ? null : (global.fs.browser.isSuccessfulWorkspace ? null : - <> -
{ await openFolderElectron(null) }} className='btn btn-primary mb-1'>
-
{ await props.createWorkspace() }} className='btn btn-primary mb-1'>
-
{ props.clone() }} className='btn btn-primary'>
+
+
+
{ await openFolderElectron(null) }} className='btn btn-primary mb-2 w-100'>
+
{ await props.createWorkspace() }} className='btn btn-primary mb-2 w-100'>
+
{ props.clone() }} className='btn btn-primary mb-3 w-100'>
+
{global.fs.browser.recentFolders.length > 0 ? - <> -
) ) } \ No newline at end of file diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx index 8b6463b5924..45e56f962d0 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer-menu.tsx @@ -65,6 +65,13 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { icon: 'fa-brands fa-git-alt', placement: 'top', platforms: [appPlatformTypes.web, appPlatformTypes.desktop] + }, + { + action: 'revealInExplorer', + title: 'Reveal workspace in explorer', + icon: 'fas fa-eye', + placement: 'top', + platforms: [appPlatformTypes.desktop] } ].filter( (item) => @@ -199,6 +206,9 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { } else if (action === 'importFromHttps') { trackMatomoEvent({ category: 'fileExplorer', action: 'fileAction', name: action, isClick: true }) props.importFromHttps('Https', 'http/https raw content', ['https://raw.githubusercontent.com/OpenZeppelin/openzeppelin-contracts/master/contracts/token/ERC20/ERC20.sol']) + } else if (action === 'revealInExplorer') { + trackMatomoEvent({ category: 'fileExplorer', action: 'fileAction', name: action, isClick: true }) + props.revealInExplorer() } else { state.actions[action]() } diff --git a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx index d5fa98085f1..e5cec14dccf 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx @@ -48,7 +48,7 @@ export const FileExplorer = (props: FileExplorerProps) => { const treeRef = useRef(null) const [cutActivated, setCutActivated] = useState(false) - const { plugin } = useContext(FileSystemContext) + const { plugin, dispatchRevealElectronFolderInExplorer } = useContext(FileSystemContext) const appContext = useContext(AppContext) const { trackMatomoEvent: baseTrackEvent } = useContext(TrackingContext) const trackMatomoEvent = (event: T) => baseTrackEvent?.(event) @@ -622,6 +622,7 @@ export const FileExplorer = (props: FileExplorerProps) => { importFromIpfs={props.importFromIpfs} importFromHttps={props.importFromHttps} handleGitInit={handleGitInit} + revealInExplorer={() => dispatchRevealElectronFolderInExplorer(null)} />
diff --git a/libs/remix-ui/workspace/src/lib/contexts/index.ts b/libs/remix-ui/workspace/src/lib/contexts/index.ts index c69b3377b20..16ccf7eed56 100644 --- a/libs/remix-ui/workspace/src/lib/contexts/index.ts +++ b/libs/remix-ui/workspace/src/lib/contexts/index.ts @@ -52,6 +52,8 @@ export const FileSystemContext = createContext<{ dispatchOpenElectronFolder: (path: string) => Promise dispatchGetElectronRecentFolders: () => Promise dispatchRemoveRecentFolder: (path: string) => Promise + dispatchOpenElectronFolderInNewWindow: (path: string) => Promise + dispatchRevealElectronFolderInExplorer: (path: string) => Promise dispatchUpdateGitSubmodules: () => Promise }>(null) diff --git a/libs/remix-ui/workspace/src/lib/css/electron-menu.css b/libs/remix-ui/workspace/src/lib/css/electron-menu.css index 02c273f9ba5..9bef8eb2855 100644 --- a/libs/remix-ui/workspace/src/lib/css/electron-menu.css +++ b/libs/remix-ui/workspace/src/lib/css/electron-menu.css @@ -1,27 +1,82 @@ .recentfolder { display: flex; + flex-direction: column; + min-width: 0; +} + +.recentfolder_header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.recentfolder_content { + flex: 1; min-width: 0; cursor: pointer; } +.recentfolder_name { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + .recentfolder_path { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } -.recentfolder_name { - flex-shrink: 0; - color: var(--text); +.recentfolder_actions { + display: flex; } -.recentfolder_name:hover { - color: var(--bs-primary); - text-decoration: underline; +.recentfolder_action { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +/* Main action buttons styling */ +[data-id="openFolderButton"], +[data-id="createWorkspaceButton"], +[data-id="cloneFromGitButton"] { + width: 100%; +} + +/* Recent folders label styling */ +.recent-folders-label { + display: flex; + align-items: center; +} + +/* Recent folders section */ +.recent-folders-section { + min-height: 0; +} + +/* Recent folders list styling */ +.recent-folders-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; } -.recentfolder_delete { - flex-shrink: 0; - margin-left: auto; - color: var(--text); +/* Empty state */ +.recent-folders-empty { + text-align: center; +} + +/* Loading state */ +.recent-folders-loading { + display: flex; + align-items: center; + justify-content: center; } \ No newline at end of file diff --git a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx index 3e923c7b764..f2362db1d27 100644 --- a/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx +++ b/libs/remix-ui/workspace/src/lib/providers/FileSystemProvider.tsx @@ -252,6 +252,22 @@ export const FileSystemProvider = (props: WorkspaceProps) => { await removeRecentElectronFolder(path) } + const dispatchOpenElectronFolderInNewWindow = async (path: string) => { + try { + await plugin.call('fs', 'openFolder', path) + } catch (error) { + console.error('Error opening folder in new window:', error) + } + } + + const dispatchRevealElectronFolderInExplorer = async (path: string | null) => { + try { + await plugin.call('fs', 'revealInExplorer', { path: [path]}, true) + } catch (error) { + console.error('Error revealing folder in explorer:', error) + } + } + const dispatchUpdateGitSubmodules = async () => { await updateGitSubmodules() } @@ -382,6 +398,8 @@ export const FileSystemProvider = (props: WorkspaceProps) => { dispatchOpenElectronFolder, dispatchGetElectronRecentFolders, dispatchRemoveRecentFolder, + dispatchOpenElectronFolderInNewWindow, + dispatchRevealElectronFolderInExplorer, dispatchUpdateGitSubmodules } return ( diff --git a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx index f5140f6143a..4e3a67ea4eb 100644 --- a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx +++ b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx @@ -1155,7 +1155,7 @@ export function Workspace() { Promise + revealInExplorer?: () => void tooltipPlacement?: Placement } export interface FileExplorerContextMenuProps {