From f843539b49b377befd928292006189d46051b693 Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Sat, 6 Sep 2025 12:09:34 +0200 Subject: [PATCH 01/10] dropdown start --- apps/remixdesktop/yarn.lock | 4 +- .../src/components/WorkspaceDropdown.tsx | 181 +++++++++++++++--- 2 files changed, 159 insertions(+), 26 deletions(-) diff --git a/apps/remixdesktop/yarn.lock b/apps/remixdesktop/yarn.lock index 7788070aaa5..4186e7bc8ac 100644 --- a/apps/remixdesktop/yarn.lock +++ b/apps/remixdesktop/yarn.lock @@ -74,9 +74,9 @@ optionalDependencies: global-agent "^3.0.0" -"@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2": +"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2": version "10.2.0-electron.1" - resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2" + resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2" dependencies: env-paths "^2.2.0" exponential-backoff "^3.1.1" diff --git a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx index 21d705a1a7b..d8fac530faf 100644 --- a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx +++ b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx @@ -7,6 +7,8 @@ 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' interface Branch { name: string @@ -77,8 +79,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)); @@ -100,37 +104,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') + } + } + }, [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]) + }, [togglerText, openSubmenuId, platform]) useClickOutside([mainRef, ...subRefs], () => { setShowMain(false) @@ -140,6 +187,92 @@ export const WorkspacesDropdown: React.FC = ({ menuItem const toggleSub = (idx: number) => setOpenSub(prev => (prev === idx ? null : idx)) + const openFolder = async () => { + 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} +
+
+ +
+ { + openFolder() + setShowMain(false) + }} + style={{ + backgroundColor: 'transparent', + color: 'inherit', + }} + > + + + { + createWorkspace() + setShowMain(false) + }} + style={{ + backgroundColor: 'transparent', + color: 'inherit', + }} + > + + +
+
+
+ ) + } + + // Original web dropdown implementation + + // Original web dropdown implementation return ( Date: Sat, 6 Sep 2025 12:15:09 +0200 Subject: [PATCH 02/10] fix desktop compilation errors --- apps/remixdesktop/src/lib/InferenceServerManager.ts | 4 ++-- apps/remixdesktop/yarn.lock | 4 ++-- libs/remix-ai-core/src/inferencers/local/ollama.ts | 2 +- libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/remixdesktop/src/lib/InferenceServerManager.ts b/apps/remixdesktop/src/lib/InferenceServerManager.ts index a8dbeeb5305..4a1802dbb4c 100644 --- a/apps/remixdesktop/src/lib/InferenceServerManager.ts +++ b/apps/remixdesktop/src/lib/InferenceServerManager.ts @@ -510,12 +510,12 @@ export class InferenceManager implements ICompletions { console.log('model not ready yet') return } - params.chatHistory = params.provider === 'anthropic' ? buildChatPrompt(prompt) : [] + params.chatHistory = params.provider === 'anthropic' ? buildChatPrompt() : [] if (params.stream_result) { return this._streamInferenceRequest('answer', { prompt:userPrompt, ...params }) } else { - return this._makeInferenceRequest('answer', { prompt, ...params }, AIRequestType.GENERAL) + return this._makeInferenceRequest('answer', { prompt: userPrompt, ...params }, AIRequestType.GENERAL) } } diff --git a/apps/remixdesktop/yarn.lock b/apps/remixdesktop/yarn.lock index 7788070aaa5..4186e7bc8ac 100644 --- a/apps/remixdesktop/yarn.lock +++ b/apps/remixdesktop/yarn.lock @@ -74,9 +74,9 @@ optionalDependencies: global-agent "^3.0.0" -"@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2": +"@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2": version "10.2.0-electron.1" - resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2" + resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2" dependencies: env-paths "^2.2.0" exponential-backoff "^3.1.1" diff --git a/libs/remix-ai-core/src/inferencers/local/ollama.ts b/libs/remix-ai-core/src/inferencers/local/ollama.ts index dfd9e5b82e7..605197ddef3 100644 --- a/libs/remix-ai-core/src/inferencers/local/ollama.ts +++ b/libs/remix-ai-core/src/inferencers/local/ollama.ts @@ -1,6 +1,6 @@ import axios from 'axios'; -const _paq = (window._paq = window._paq || []) +const _paq = (typeof window !== 'undefined' && (window as any)._paq) ? (window as any)._paq : [] // default Ollama ports to check (11434 is the legacy/standard port) const OLLAMA_PORTS = [11434, 11435, 11436]; diff --git a/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts b/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts index ee47b0d7600..517e3e992ac 100644 --- a/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts +++ b/libs/remix-ai-core/src/inferencers/local/ollamaInferencer.ts @@ -19,7 +19,7 @@ import { import axios from "axios"; import { RemoteInferencer } from "../remote/remoteInference"; -const _paq = (window._paq = window._paq || []) +const _paq = (typeof window !== 'undefined' && (window as any)._paq) ? (window as any)._paq : [] const defaultErrorMessage = `Unable to get a response from Ollama server`; export class OllamaInferencer extends RemoteInferencer implements ICompletions, IGeneration { From fc14481d4490d111a405408ffe673c0870fc0dde Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 11:53:47 +0200 Subject: [PATCH 03/10] electron dropdown done --- .../src/components/ElectronWorkspaceMenu.tsx | 240 ++++++++++++++++++ .../src/components/WorkspaceDropdown.tsx | 61 +---- .../top-bar/src/context/topbarContext.tsx | 8 +- .../top-bar/src/context/topbarProvider.tsx | 64 +++++ libs/remix-ui/top-bar/src/index.ts | 1 + 5 files changed, 326 insertions(+), 48 deletions(-) create mode 100644 libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx 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..956ea6c56de --- /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 8cd15f1b9a3..599a05d6059 100644 --- a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx +++ b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx @@ -10,6 +10,7 @@ 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 @@ -188,7 +189,12 @@ 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) { @@ -196,6 +202,8 @@ export const WorkspacesDropdown: React.FC = ({ menuItem } } + + // Render simplified dropdown for desktop if (platform === appPlatformTypes.desktop) { return ( @@ -220,53 +228,12 @@ export const WorkspacesDropdown: React.FC = ({ menuItem {togglerText} - -
- { - 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/context/topbarContext.tsx b/libs/remix-ui/top-bar/src/context/topbarContext.tsx index 3918507c4e9..52eac90abc5 100644 --- a/libs/remix-ui/top-bar/src/context/topbarContext.tsx +++ b/libs/remix-ui/top-bar/src/context/topbarContext.tsx @@ -5,6 +5,12 @@ import { createContext, SyntheticEvent } from 'react' export const TopbarContext = createContext<{ fs: any, plugin: Topbar, - modal:(title: string | JSX.Element, message: string | JSX.Element, okLabel: string, okFn: () => 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..2856dea95d1 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 + const timeout = setTimeout(fetchRecentFolders, 1000) + return () => clearTimeout(timeout) + } + }, [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' From 58e2d65c028d36662fda057c5c15091464e7986c Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 12:01:42 +0200 Subject: [PATCH 04/10] timing issues --- .../src/app/plugins/electron/compilerLoaderPlugin.ts | 2 +- .../src/app/plugins/electron/electronConfigPlugin.ts | 2 +- libs/remix-ui/top-bar/src/context/topbarProvider.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) 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/libs/remix-ui/top-bar/src/context/topbarProvider.tsx b/libs/remix-ui/top-bar/src/context/topbarProvider.tsx index 2856dea95d1..61dfaa0c8b0 100644 --- a/libs/remix-ui/top-bar/src/context/topbarProvider.tsx +++ b/libs/remix-ui/top-bar/src/context/topbarProvider.tsx @@ -129,8 +129,8 @@ export const TopbarProvider = (props: TopbarProviderProps) => { useEffect(() => { if (platform === appPlatformTypes.desktop) { // Fetch recent folders after a delay to ensure workspace is initialized - const timeout = setTimeout(fetchRecentFolders, 1000) - return () => clearTimeout(timeout) + fetchRecentFolders() + } }, [platform]) From bb87687b79b9bfb403e1aecb0bc7e9a240332d98 Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 12:13:07 +0200 Subject: [PATCH 05/10] fix buttons git --- .../src/components/buttons/gituibutton.tsx | 14 ++-- .../buttons/sourcecontrolbuttons.tsx | 66 +++++++++++-------- 2 files changed, 48 insertions(+), 32 deletions(-) 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 d0c5662c817..0dcb8bad3fd 100644 --- a/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx +++ b/libs/remix-ui/git/src/components/buttons/sourcecontrolbuttons.tsx @@ -85,34 +85,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} - }> - - - - + } + > + +
) } From a829ebf57ebd97619a4cc1c4ea3deddadc573fdf Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 12:16:13 +0200 Subject: [PATCH 06/10] fix key errors --- .../templates-selection/templates-selection-plugin.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 25f755db482..499e65585bc 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 @@ -265,10 +265,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 ( Date: Wed, 17 Sep 2025 13:04:00 +0200 Subject: [PATCH 07/10] open in finder --- apps/remixdesktop/src/plugins/fsPlugin.ts | 12 ++- .../src/components/ElectronWorkspaceMenu.tsx | 4 +- .../src/lib/components/electron-menu.tsx | 85 +++++++++++++------ .../src/lib/components/file-explorer-menu.tsx | 10 +++ .../src/lib/components/file-explorer.tsx | 3 +- .../workspace/src/lib/contexts/index.ts | 2 + .../workspace/src/lib/css/electron-menu.css | 75 +++++++++++++--- .../src/lib/providers/FileSystemProvider.tsx | 18 ++++ .../workspace/src/lib/remix-ui-workspace.tsx | 4 +- .../remix-ui/workspace/src/lib/types/index.ts | 1 + 10 files changed, 172 insertions(+), 42 deletions(-) diff --git a/apps/remixdesktop/src/plugins/fsPlugin.ts b/apps/remixdesktop/src/plugins/fsPlugin.ts index dcfda9ebc24..ff5c7ba3ba7 100644 --- a/apps/remixdesktop/src/plugins/fsPlugin.ts +++ b/apps/remixdesktop/src/plugins/fsPlugin.ts @@ -574,7 +574,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)) } diff --git a/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx b/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx index 956ea6c56de..3e1623ca749 100644 --- a/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx +++ b/libs/remix-ui/top-bar/src/components/ElectronWorkspaceMenu.tsx @@ -115,7 +115,7 @@ export const ElectronWorkspaceMenu: React.FC = ({ e.currentTarget.style.opacity = '0.75' }} > - + = ({ e.currentTarget.style.opacity = '0.75' }} > - + 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..3ccb82125bf 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 37d08b5bee7..66eb20491d8 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 @@ -60,6 +60,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) => @@ -194,6 +201,9 @@ export const FileExplorerMenu = (props: FileExplorerMenuProps) => { } else if (action === 'importFromHttps') { _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) props.importFromHttps('Https', 'http/https raw content', ['https://raw.githubusercontent.com/OpenZeppelin/openzeppelin-contracts/master/contracts/token/ERC20/ERC20.sol']) + } else if (action === 'revealInExplorer') { + _paq.push(['trackEvent', 'fileExplorer', 'fileAction', action]) + 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 fa82ef2274d..c8009ab7479 100644 --- a/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx +++ b/libs/remix-ui/workspace/src/lib/components/file-explorer.tsx @@ -45,7 +45,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 [filesSelected, setFilesSelected] = useState([]) const feWindow = (window as any) @@ -616,6 +616,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..24272d9dcd7 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 e6e9906f02d..88284236360 100644 --- a/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx +++ b/libs/remix-ui/workspace/src/lib/remix-ui-workspace.tsx @@ -1151,7 +1151,7 @@ export function Workspace() { Promise + revealInExplorer?: () => void tooltipPlacement?: Placement } export interface FileExplorerContextMenuProps { From 8c7de9cc66c89566ade64fe91839cb085ff211a8 Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 14:59:56 +0200 Subject: [PATCH 08/10] home tab --- .../homeTabRecentWorkspacesElectron.tsx | 152 ++++++++++++++++++ .../home-tab/src/lib/remix-ui-home-tab.tsx | 3 +- .../src/components/WorkspaceDropdown.tsx | 1 - 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 libs/remix-ui/home-tab/src/lib/components/homeTabRecentWorkspacesElectron.tsx 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..29222df3275 --- /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 4f47bd6cbd7..4089e6e9b9a 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' @@ -88,7 +89,7 @@ export const RemixUiHomeTab = (props: RemixUiHomeTabProps) => {
- + {!(platform === appPlatformTypes.desktop) ? : } {/* {!(platform === appPlatformTypes.desktop) ? : } */}
diff --git a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx index 599a05d6059..5c5c90980bb 100644 --- a/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx +++ b/libs/remix-ui/top-bar/src/components/WorkspaceDropdown.tsx @@ -219,7 +219,6 @@ export const WorkspacesDropdown: React.FC = ({ menuItem className="btn btn-sm w-100 border position-relative" variant="secondary" data-id="workspacesMenuDropdown" - icon="fas fa-folder-open" >
Date: Wed, 17 Sep 2025 15:21:46 +0200 Subject: [PATCH 09/10] fix button --- .../src/lib/components/remix-ui-terminal-menu-buttons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/remix-ui/terminal/src/lib/components/remix-ui-terminal-menu-buttons.tsx b/libs/remix-ui/terminal/src/lib/components/remix-ui-terminal-menu-buttons.tsx index 6c5c27c75fa..12cd64533a2 100644 --- a/libs/remix-ui/terminal/src/lib/components/remix-ui-terminal-menu-buttons.tsx +++ b/libs/remix-ui/terminal/src/lib/components/remix-ui-terminal-menu-buttons.tsx @@ -33,7 +33,7 @@ export const RemixUITerminalMenuButtons = (props: RemixUiTerminalProps) => { - From 852119d071b8afc945b2d87b7509f73694968e7d Mon Sep 17 00:00:00 2001 From: bunsenstraat Date: Wed, 17 Sep 2025 16:46:47 +0200 Subject: [PATCH 10/10] fs watchers --- apps/remixdesktop/src/plugins/fsPlugin.ts | 593 +++++++++++++++++++--- 1 file changed, 525 insertions(+), 68 deletions(-) diff --git a/apps/remixdesktop/src/plugins/fsPlugin.ts b/apps/remixdesktop/src/plugins/fsPlugin.ts index ff5c7ba3ba7..fbdc0542fdd 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,11 +117,26 @@ 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() @@ -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) @@ -575,7 +1032,7 @@ class FSPluginClient extends ElectronBasePluginClient { async revealInExplorer(action: customAction, isAbsolutePath: boolean = false): Promise { 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() @@ -584,7 +1041,7 @@ class FSPluginClient extends ElectronBasePluginClient { } else { path = this.fixPath(action.path[0]) } - + shell.showItemInFolder(convertPathToLocalFileSystem(path)) } @@ -635,7 +1092,7 @@ export class FSPluginClientE2E extends FSPluginClient { await this.updateRecentFolders(dir) await this.updateOpenedFolders(dir) if (!dir) return - + this.openWindow(dir) }