diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 08740bbb..890702de 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -425,7 +425,8 @@ export default function App() { useEffect(() => { console.log('Setting up keyboard shortcuts'); const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key === 'n') { + const isMac = window.electron.platform === 'darwin'; + if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 595da07a..29cbc5e4 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -18,6 +18,10 @@ interface SearchBarProps { count: number; currentIndex: number; }; + /** Optional ref for the search input element */ + inputRef?: React.RefObject; + /** Initial search term */ + initialSearchTerm?: string; } /** @@ -35,12 +39,15 @@ export const SearchBar: React.FC = ({ onClose, onNavigate, searchResults, + inputRef: externalInputRef, + initialSearchTerm = '', }) => { - const [searchTerm, setSearchTerm] = useState(''); - const [displayTerm, setDisplayTerm] = useState(''); // For immediate visual feedback + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); + const [displayTerm, setDisplayTerm] = useState(initialSearchTerm); // For immediate visual feedback const [caseSensitive, setCaseSensitive] = useState(false); const [isExiting, setIsExiting] = useState(false); - const inputRef = React.useRef(null); + const internalInputRef = React.useRef(null); + const inputRef = externalInputRef || internalInputRef; // Create debounced search function const debouncedSearch = useCallback( @@ -54,7 +61,16 @@ export const SearchBar: React.FC = ({ useEffect(() => { inputRef.current?.focus(); - }, []); + }, [inputRef]); + + // Handle changes to initialSearchTerm + useEffect(() => { + if (initialSearchTerm) { + setSearchTerm(initialSearchTerm); + setDisplayTerm(initialSearchTerm); + debouncedSearch(initialSearchTerm, caseSensitive); + } + }, [initialSearchTerm, caseSensitive, debouncedSearch]); // Cleanup debounced function on unmount useEffect(() => { diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 8f5d53a1..182dc7b3 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -22,11 +22,13 @@ export const SearchView: React.FC> = ({ children, }) => { const [isSearchVisible, setIsSearchVisible] = useState(false); + const [initialSearchTerm, setInitialSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState<{ currentIndex: number; count: number; } | null>(null); + const searchInputRef = React.useRef(null); const highlighterRef = React.useRef(null); const containerRef = React.useRef(null); const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({ @@ -58,6 +60,127 @@ export const SearchView: React.FC> = ({ [] ); + /** + * Handles the search operation when a user enters a search term. + * Uses debouncing to prevent excessive highlighting operations. + * @param term - The text to search for + * @param caseSensitive - Whether to perform a case-sensitive search + */ + const handleSearch = useCallback( + (term: string, caseSensitive: boolean) => { + // Store the latest search parameters + lastSearchRef.current = { term, caseSensitive }; + + if (!term) { + setSearchResults(null); + if (highlighterRef.current) { + highlighterRef.current.clearHighlights(); + } + return; + } + + const container = containerRef.current; + if (!container) return; + + if (!highlighterRef.current) { + highlighterRef.current = new SearchHighlighter(container, (count) => { + // Only update if this is still the latest search + if ( + lastSearchRef.current.term === term && + lastSearchRef.current.caseSensitive === caseSensitive + ) { + if (count > 0) { + setSearchResults((prev) => ({ + currentIndex: prev?.currentIndex || 1, + count, + })); + } else { + setSearchResults(null); + } + } + }); + } + + // Debounce the highlight operation + debouncedHighlight(term, caseSensitive, highlighterRef.current); + }, + [debouncedHighlight] + ); + + /** + * Navigates between search results in the specified direction. + * @param direction - Direction to navigate ('next' or 'prev') + */ + const navigateResults = useCallback( + (direction: 'next' | 'prev') => { + if (!searchResults || searchResults.count === 0 || !highlighterRef.current) return; + + let newIndex: number; + const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based + + if (direction === 'next') { + newIndex = currentIdx + 1; + if (newIndex >= searchResults.count) { + newIndex = 0; + } + } else { + newIndex = currentIdx - 1; + if (newIndex < 0) { + newIndex = searchResults.count - 1; + } + } + + setSearchResults({ + ...searchResults, + currentIndex: newIndex + 1, + }); + + highlighterRef.current.setCurrentMatch(newIndex, true); // Explicitly scroll when navigating + }, + [searchResults] + ); + + const handleFindCommand = useCallback(() => { + if (isSearchVisible && searchInputRef.current) { + searchInputRef.current.focus(); + searchInputRef.current.select(); + } else { + setIsSearchVisible(true); + } + }, [isSearchVisible]); + + const handleFindNext = useCallback(() => { + if (isSearchVisible) { + navigateResults('next'); + } + }, [isSearchVisible, navigateResults]); + + const handleFindPrevious = useCallback(() => { + if (isSearchVisible) { + navigateResults('prev'); + } + }, [isSearchVisible, navigateResults]); + + const handleUseSelectionFind = useCallback(() => { + const selection = window.getSelection()?.toString().trim(); + if (selection) { + setInitialSearchTerm(selection); + } + }, []); + + /** + * Closes the search interface and cleans up highlights. + */ + const handleCloseSearch = useCallback(() => { + setIsSearchVisible(false); + setSearchResults(null); + if (highlighterRef.current) { + highlighterRef.current.clearHighlights(); + } + // Cancel any pending highlight operations + debouncedHighlight.cancel?.(); + }, [debouncedHighlight]); + // Clean up highlighter and debounced functions on unmount useEffect(() => { return () => { @@ -69,11 +192,47 @@ export const SearchView: React.FC> = ({ }; }, [debouncedHighlight]); + // Listen for keyboard events useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + const isMac = window.electron.platform === 'darwin'; + + // Handle ⌘F/Ctrl+F to show/focus search + if ((isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && e.key === 'f') { e.preventDefault(); - setIsSearchVisible(true); + if (isSearchVisible && searchInputRef.current) { + // If search is already visible, focus and select the input + searchInputRef.current.focus(); + searchInputRef.current.select(); + } else { + // Otherwise show the search UI + setIsSearchVisible(true); + } + return; + } + + // Handle ⌘E to use selection for find (Mac only) + if (isMac && e.metaKey && !e.shiftKey && e.key === 'e') { + // Don't handle ⌘E if we're in the search input - let the native behavior work + if (e.target instanceof HTMLInputElement && e.target.id === 'search-input') { + return; + } + + e.preventDefault(); + handleUseSelectionFind(); + return; + } + + // Only handle ⌘G and ⇧⌘G if search is visible (Mac only) + if (isSearchVisible && isMac && e.metaKey && e.key === 'g') { + e.preventDefault(); + if (e.shiftKey) { + // ⇧⌘G - Find Previous + navigateResults('prev'); + } else { + // ⌘G - Find Next + navigateResults('next'); + } } }; @@ -81,94 +240,22 @@ export const SearchView: React.FC> = ({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, []); + }, [isSearchVisible, navigateResults, handleSearch, handleUseSelectionFind]); - /** - * Handles the search operation when a user enters a search term. - * Uses debouncing to prevent excessive highlighting operations. - * @param term - The text to search for - * @param caseSensitive - Whether to perform a case-sensitive search - */ - const handleSearch = (term: string, caseSensitive: boolean) => { - // Store the latest search parameters - lastSearchRef.current = { term, caseSensitive }; + // Listen for Find menu commands + useEffect(() => { + window.electron.on('find-command', handleFindCommand); + window.electron.on('find-next', handleFindNext); + window.electron.on('find-previous', handleFindPrevious); + window.electron.on('use-selection-find', handleUseSelectionFind); - if (!term) { - setSearchResults(null); - if (highlighterRef.current) { - highlighterRef.current.clearHighlights(); - } - return; - } - - const container = containerRef.current; - if (!container) return; - - if (!highlighterRef.current) { - highlighterRef.current = new SearchHighlighter(container, (count) => { - // Only update if this is still the latest search - if ( - lastSearchRef.current.term === term && - lastSearchRef.current.caseSensitive === caseSensitive - ) { - if (count > 0) { - setSearchResults((prev) => ({ - currentIndex: prev?.currentIndex || 1, - count, - })); - } else { - setSearchResults(null); - } - } - }); - } - - // Debounce the highlight operation - debouncedHighlight(term, caseSensitive, highlighterRef.current); - }; - - /** - * Navigates between search results in the specified direction. - * @param direction - Direction to navigate ('next' or 'prev') - */ - const navigateResults = (direction: 'next' | 'prev') => { - if (!searchResults || searchResults.count === 0 || !highlighterRef.current) return; - - let newIndex: number; - const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based - - if (direction === 'next') { - newIndex = currentIdx + 1; - if (newIndex >= searchResults.count) { - newIndex = 0; - } - } else { - newIndex = currentIdx - 1; - if (newIndex < 0) { - newIndex = searchResults.count - 1; - } - } - - setSearchResults({ - ...searchResults, - currentIndex: newIndex + 1, - }); - - highlighterRef.current.setCurrentMatch(newIndex, true); // Explicitly scroll when navigating - }; - - /** - * Closes the search interface and cleans up highlights. - */ - const handleCloseSearch = () => { - setIsSearchVisible(false); - setSearchResults(null); - if (highlighterRef.current) { - highlighterRef.current.clearHighlights(); - } - // Cancel any pending highlight operations - debouncedHighlight.cancel?.(); - }; + return () => { + window.electron.off('find-command', handleFindCommand); + window.electron.off('find-next', handleFindNext); + window.electron.off('find-previous', handleFindPrevious); + window.electron.off('use-selection-find', handleUseSelectionFind); + }; + }, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]); return (
@@ -178,6 +265,8 @@ export const SearchView: React.FC> = ({ onClose={handleCloseSearch} onNavigate={navigateResults} searchResults={searchResults} + inputRef={searchInputRef} + initialSearchTerm={initialSearchTerm} /> )} {children} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f56c5b79..7330f505 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -805,6 +805,59 @@ app.whenReady().then(async () => { appMenu.submenu.insert(1, new MenuItem({ type: 'separator' })); } + // Add Find submenu to Edit menu + const editMenu = menu?.items.find((item) => item.label === 'Edit'); + if (editMenu?.submenu) { + // Find the index of Select All to insert after it + const selectAllIndex = editMenu.submenu.items.findIndex((item) => item.label === 'Select All'); + + // Create Find submenu + const findSubmenu = Menu.buildFromTemplate([ + { + label: 'Find…', + accelerator: process.platform === 'darwin' ? 'Command+F' : 'Control+F', + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('find-command'); + }, + }, + { + label: 'Find Next', + accelerator: process.platform === 'darwin' ? 'Command+G' : 'Control+G', + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('find-next'); + }, + }, + { + label: 'Find Previous', + accelerator: process.platform === 'darwin' ? 'Shift+Command+G' : 'Shift+Control+G', + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('find-previous'); + }, + }, + { + label: 'Use Selection for Find', + accelerator: process.platform === 'darwin' ? 'Command+E' : null, + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('use-selection-find'); + }, + visible: process.platform === 'darwin', // Only show on Mac + }, + ]); + + // Add Find submenu to Edit menu + editMenu.submenu.insert( + selectAllIndex + 1, + new MenuItem({ + label: 'Find', + submenu: findSubmenu, + }) + ); + } + // Add Environment menu items to View menu const viewMenu = menu?.items.find((item) => item.label === 'View'); if (viewMenu?.submenu) { @@ -826,8 +879,20 @@ app.whenReady().then(async () => { const fileMenu = menu?.items.find((item) => item.label === 'File'); if (fileMenu?.submenu) { - // open goose to specific dir and set that as its working space - fileMenu.submenu.append( + fileMenu.submenu.insert( + 0, + new MenuItem({ + label: 'New Chat Window', + accelerator: process.platform === 'darwin' ? 'Cmd+N' : 'Ctrl+N', + click() { + ipcMain.emit('create-chat-window'); + }, + }) + ); + + // Open goose to specific dir and set that as its working space + fileMenu.submenu.insert( + 1, new MenuItem({ label: 'Open Directory...', accelerator: 'CmdOrCtrl+O', @@ -838,8 +903,8 @@ app.whenReady().then(async () => { // Add Recent Files submenu const recentFilesSubmenu = buildRecentFilesMenu(); if (recentFilesSubmenu.length > 0) { - fileMenu.submenu.append(new MenuItem({ type: 'separator' })); - fileMenu.submenu.append( + fileMenu.submenu.insert( + 2, new MenuItem({ label: 'Recent Directories', submenu: recentFilesSubmenu, @@ -847,19 +912,12 @@ app.whenReady().then(async () => { ); } - // Add menu items to File menu - fileMenu.submenu.append( - new MenuItem({ - label: 'New Chat Window', - accelerator: 'CmdOrCtrl+N', - click() { - ipcMain.emit('create-chat-window'); - }, - }) - ); + fileMenu.submenu.insert(3, new MenuItem({ type: 'separator' })); - // Add menu item for hotkey - fileMenu?.submenu.append( + // The Close Window item is here. + + // Add menu item to tell the user about the keyboard shortcut + fileMenu.submenu.append( new MenuItem({ label: 'Focus Goose Window', accelerator: 'CmdOrCtrl+Alt+Shift+G', @@ -977,12 +1035,12 @@ app.whenReady().then(async () => { /** * Fetches the allowed extensions list from the remote YAML file if GOOSE_ALLOWLIST is set. - * If the ALLOWLIST is not set, any are allowed. If one is set, it will warn if the deeplink - * doesn't match a command from the list. + * If the ALLOWLIST is not set, any are allowed. If one is set, it will warn if the deeplink + * doesn't match a command from the list. * If it fails to load, then it will return an empty list. * If the format is incorrect, it will return an empty list. * Format of yaml is: - * + * ```yaml: extensions: - id: slack @@ -990,7 +1048,7 @@ app.whenReady().then(async () => { - id: knowledge_graph_memory command: npx -y @modelcontextprotocol/server-memory ``` - * + * * @returns A promise that resolves to an array of extension commands that are allowed. */ async function getAllowList(): Promise { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 7bcd5b84..062d838a 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -25,6 +25,7 @@ const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{} // Define the API types in a single place type ElectronAPI = { + platform: string; reactReady: () => void; getConfig: () => Record; hideWindow: () => void; @@ -68,6 +69,7 @@ type AppConfigAPI = { }; const electronAPI: ElectronAPI = { + platform: process.platform, reactReady: () => ipcRenderer.send('react-ready'), getConfig: () => config, hideWindow: () => ipcRenderer.send('hide-window'),