From 90ebb525365b45d51d86bfea9fa1e6ea9c04c29f Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 00:45:59 +0100 Subject: [PATCH 1/9] chore: add Kbd and KbdGroup ui components for keyboard shortcuts --- apps/ui/src/components/ui/kbd.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/ui/src/components/ui/kbd.tsx diff --git a/apps/ui/src/components/ui/kbd.tsx b/apps/ui/src/components/ui/kbd.tsx new file mode 100644 index 00000000..1e726b2c --- /dev/null +++ b/apps/ui/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; + +function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { + return ( + + ); +} + +function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +export { Kbd, KbdGroup }; From 862a33982d8e6e2f783ad5ec237f0aa2456accc3 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 00:47:45 +0100 Subject: [PATCH 2/9] feat: enhance FileBrowserDialog and PathInput with search functionality - Added Kbd and KbdGroup components for keyboard shortcuts in FileBrowserDialog. - Implemented search functionality in PathInput, allowing users to search files and directories. - Updated PathInput to handle file system entries and selection from search results. - Improved UI/UX with better focus management and search input handling. --- .../dialogs/file-browser-dialog.tsx | 23 +- apps/ui/src/components/ui/path-input.tsx | 311 +++++++++++++----- 2 files changed, 249 insertions(+), 85 deletions(-) diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index 9dae11bb..ff41c22b 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { PathInput } from '@/components/ui/path-input'; +import { Kbd, KbdGroup } from '@/components/ui/kbd'; import { getJSON, setJSON } from '@/lib/storage'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; @@ -232,7 +233,7 @@ export function FileBrowserDialog({ return ( - + @@ -252,6 +253,12 @@ export function FileBrowserDialog({ error={!!error} onNavigate={handleNavigate} onHome={handleGoHome} + entries={directories.map((dir) => ({ ...dir, isDirectory: true }))} + onSelectEntry={(entry) => { + if (entry.isDirectory) { + handleSelectDirectory(entry); + } + }} /> {/* Recent folders */} @@ -366,12 +373,14 @@ export function FileBrowserDialog({ > Select Current Folder - - {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') - ? '⌘' - : 'Ctrl'} - +↵ - + + + {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') + ? '⌘' + : 'Ctrl'} + + + diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 7840c16d..87829ec0 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -1,6 +1,6 @@ import { useEffect, Fragment, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; import { useState, useRef, useCallback, useMemo } from 'react'; -import { Home, ArrowLeft, Pencil, ArrowRight } from 'lucide-react'; +import { Home, ArrowLeft, Pencil, ArrowRight, Search, Folder, File, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -11,6 +11,14 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from '@/components/ui/command'; import { cn } from '@/lib/utils'; interface BreadcrumbSegment { @@ -52,6 +60,12 @@ function parseBreadcrumbs(path: string): BreadcrumbSegment[] { }); } +interface FileSystemEntry { + name: string; + path: string; + isDirectory: boolean; +} + interface PathInputProps { /** Current resolved path */ currentPath: string; @@ -63,12 +77,18 @@ interface PathInputProps { error?: boolean; /** Placeholder text for the input field */ placeholder?: string; + /** Placeholder text for the search input field */ + searchPlaceholder?: string; /** Called when user navigates to a path (via breadcrumb click, enter key, or navigation buttons) */ onNavigate: (path: string) => void; /** Called when user clicks home button (navigates to home directory) */ onHome: () => void; /** Additional className for the container */ className?: string; + /** List of files and directories in current path for search functionality */ + entries?: FileSystemEntry[]; + /** Called when user selects an entry from search results */ + onSelectEntry?: (entry: FileSystemEntry) => void; } function PathInput({ @@ -77,11 +97,15 @@ function PathInput({ loading = false, error, placeholder = 'Paste or type a full path (e.g., /home/user/projects/myapp)', + searchPlaceholder = 'Search...', onNavigate, onHome, className, + entries = [], + onSelectEntry, }: PathInputProps) { const [isEditing, setIsEditing] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [pathInput, setPathInput] = useState(currentPath); const inputRef = useRef(null); const containerRef = useRef(null); @@ -103,6 +127,21 @@ function PathInput({ } }, [error, isEditing]); + // Focus search input when search opens + useEffect(() => { + if (isSearchOpen) { + // Small delay to ensure the CommandInput is rendered + setTimeout(() => { + const searchInput = containerRef.current?.querySelector( + '[data-slot="command-input"]' + ) as HTMLInputElement; + if (searchInput) { + searchInput.focus(); + } + }, 0); + } + }, [isSearchOpen]); + const handleGoToParent = useCallback(() => { if (parentPath) { onNavigate(parentPath); @@ -163,6 +202,7 @@ function PathInput({ // Don't trigger if clicking on a button or already editing if ( isEditing || + isSearchOpen || (e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a') ) { @@ -170,9 +210,59 @@ function PathInput({ } setIsEditing(true); }, - [isEditing] + [isEditing, isSearchOpen] ); + const handleSelectEntry = useCallback( + (entry: FileSystemEntry) => { + if (onSelectEntry) { + onSelectEntry(entry); + } + setIsSearchOpen(false); + }, + [onSelectEntry] + ); + + // Global keyboard shortcut to activate search (/) + useEffect(() => { + const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { + // Activate search with '/' key (unless in an input field) + if ( + e.key === '/' && + !isEditing && + !isSearchOpen && + !(e.target as HTMLElement).matches('input, textarea') + ) { + e.preventDefault(); + setIsSearchOpen(true); + } + // Close search with Escape key + if (e.key === 'Escape' && isSearchOpen) { + e.preventDefault(); + e.stopPropagation(); // Prevent parent modal from closing + setIsSearchOpen(false); + } + }; + + window.addEventListener('keydown', handleGlobalKeyDown, true); // Use capture phase + return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); + }, [isEditing, isSearchOpen]); + + // Close search when clicking outside + useEffect(() => { + if (!isSearchOpen) return; + + const handleClickOutside = (e: globalThis.MouseEvent) => { + const target = e.target as HTMLElement; + if (containerRef.current && !containerRef.current.contains(target)) { + setIsSearchOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isSearchOpen]); + const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]); const showBreadcrumbs = currentPath && !isEditing && !loading && !error; @@ -210,87 +300,152 @@ function PathInput({ {/* Path display / input */} -
- {showBreadcrumbs ? ( - <> - - - {breadcrumbs.map((crumb) => ( - - - {crumb.isLast ? ( - - {crumb.name} - - ) : ( - { - e.preventDefault(); - handleBreadcrumbClick(crumb.path); - }} - className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]" +
+ {/* Search Popover - positioned to overlap the input */} + {isSearchOpen && ( +
+
+
+ +
+ +
+ + + ESC + +
+
+ + No files or directories found + + {entries.map((entry) => ( + handleSelectEntry(entry)} > - {crumb.name} - - )} - - {!crumb.isLast && } - - ))} - - - - - ) : ( - <> - setPathInput(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleInputBlur} - className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent" - data-testid="path-input" - disabled={loading} - aria-label="Path input" - aria-invalid={error} - /> - - + {entry.isDirectory ? ( + + ) : ( + + )} + {entry.name} + + ))} + + +
+
+
+
)} + +
+ {showBreadcrumbs ? ( + <> + + + {breadcrumbs.map((crumb) => ( + + + {crumb.isLast ? ( + + {crumb.name} + + ) : ( + { + e.preventDefault(); + handleBreadcrumbClick(crumb.path); + }} + className="font-mono text-xs text-muted-foreground hover:text-foreground transition-colors truncate max-w-[150px]" + > + {crumb.name} + + )} + + {!crumb.isLast && ( + + )} + + ))} + + +
+ + +
+ + ) : ( + <> + setPathInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className="flex-1 font-mono text-xs h-7 px-0 border-0 shadow-none focus-visible:ring-0 bg-transparent" + data-testid="path-input" + disabled={loading} + aria-label="Path input" + aria-invalid={error} + /> + + + )} +
); } export { PathInput, parseBreadcrumbs }; -export type { PathInputProps, BreadcrumbSegment }; +export type { PathInputProps, BreadcrumbSegment, FileSystemEntry }; From bed8038d16be2aa319328b7c5d57b5704a0f6fe8 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 01:05:36 +0100 Subject: [PATCH 3/9] fix: add custom scrollbar styling to CommandList in PathInput component --- apps/ui/src/components/ui/path-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 87829ec0..4f1f2306 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -324,7 +324,7 @@ function PathInput({
- + No files or directories found {entries.map((entry) => ( From cd30306afe2269a3d3ea84c6f883bd98fc7fb1c6 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 01:50:03 +0100 Subject: [PATCH 4/9] refactor: update KbdGroup component to use span instead of kbd; enhance PathInput with autoFocus on CommandInput --- apps/ui/src/components/ui/kbd.tsx | 4 ++-- apps/ui/src/components/ui/path-input.tsx | 21 +++++---------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/components/ui/kbd.tsx b/apps/ui/src/components/ui/kbd.tsx index 1e726b2c..670a3872 100644 --- a/apps/ui/src/components/ui/kbd.tsx +++ b/apps/ui/src/components/ui/kbd.tsx @@ -15,9 +15,9 @@ function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { ); } -function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) { +function KbdGroup({ className, ...props }: React.ComponentProps<'span'>) { return ( - { - if (isSearchOpen) { - // Small delay to ensure the CommandInput is rendered - setTimeout(() => { - const searchInput = containerRef.current?.querySelector( - '[data-slot="command-input"]' - ) as HTMLInputElement; - if (searchInput) { - searchInput.focus(); - } - }, 0); - } - }, [isSearchOpen]); - const handleGoToParent = useCallback(() => { if (parentPath) { onNavigate(parentPath); @@ -308,7 +293,11 @@ function PathInput({
- +
- - ESC - + ESC
From a7de6406ed01cb0b61409f4bb32aa3ba5b65cae6 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 02:39:42 +0100 Subject: [PATCH 6/9] fix(path-input): improve keydown handling - Updated keydown event logic to prevent search activation when input fields or contenteditable elements are focused. - Enhanced ESC key handling to ensure parent modal does not close when search is open. - Adjusted dependencies in useEffect to include entries length for better state management. --- apps/ui/src/components/ui/path-input.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 09f8186f..c9f79f34 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -217,7 +217,8 @@ function PathInput({ e.key === '/' && !isEditing && !isSearchOpen && - !(e.target as HTMLElement).matches('input, textarea') + entries.length > 0 && + !(e.target as HTMLElement).matches('input, textarea, [contenteditable="true"]') ) { e.preventDefault(); setIsSearchOpen(true); @@ -225,14 +226,13 @@ function PathInput({ // Close search with Escape key if (e.key === 'Escape' && isSearchOpen) { e.preventDefault(); - e.stopPropagation(); // Prevent parent modal from closing setIsSearchOpen(false); } }; - window.addEventListener('keydown', handleGlobalKeyDown, true); // Use capture phase + window.addEventListener('keydown', handleGlobalKeyDown, true); // Use capture phase for ESC handling and prevent parent modal from closing when search is open return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); - }, [isEditing, isSearchOpen]); + }, [isEditing, isSearchOpen, entries.length]); // Close search when clicking outside useEffect(() => { From 1c59eabf5f812be3746af9f5602691db2d2a4af7 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 12:35:40 +0100 Subject: [PATCH 7/9] refactor(path-input): optimize entry rendering and clarify keydown handling in comments - Replaced inline entry mapping with a memoized entryItems component for improved performance. - Clarified keydown event handling comments to enhance understanding of ESC key behavior in relation to modal interactions. --- apps/ui/src/components/ui/path-input.tsx | 38 +++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index c9f79f34..73eefd7c 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -212,7 +212,7 @@ function PathInput({ // Global keyboard shortcut to activate search (/) useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - // Activate search with '/' key (unless in an input field) + // Activate search with '/' key (unless in an input field or contenteditable) if ( e.key === '/' && !isEditing && @@ -230,7 +230,9 @@ function PathInput({ } }; - window.addEventListener('keydown', handleGlobalKeyDown, true); // Use capture phase for ESC handling and prevent parent modal from closing when search is open + // Use capture phase to intercept ESC before parent modal handlers + // This allows us to close search first, then let ESC bubble to close modal on second press + window.addEventListener('keydown', handleGlobalKeyDown, true); return () => window.removeEventListener('keydown', handleGlobalKeyDown, true); }, [isEditing, isSearchOpen, entries.length]); @@ -251,6 +253,21 @@ function PathInput({ const breadcrumbs = useMemo(() => parseBreadcrumbs(currentPath), [currentPath]); + const entryItems = useMemo( + () => + entries.map((entry) => ( + handleSelectEntry(entry)}> + {entry.isDirectory ? ( + + ) : ( + + )} + {entry.name} + + )), + [entries, handleSelectEntry] + ); + const showBreadcrumbs = currentPath && !isEditing && !loading && !error; return ( @@ -314,22 +331,7 @@ function PathInput({
No files or directories found - - {entries.map((entry) => ( - handleSelectEntry(entry)} - > - {entry.isDirectory ? ( - - ) : ( - - )} - {entry.name} - - ))} - + {entryItems} From 7f4b60b8c08315818ace1f13cddca5604f749dad Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 12:50:10 +0100 Subject: [PATCH 8/9] fix(path-input): added e.stopPropagation() to ensure the parent modal does not close when the search is active and the ESC key is pressed --- apps/ui/src/components/ui/path-input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ui/src/components/ui/path-input.tsx b/apps/ui/src/components/ui/path-input.tsx index 73eefd7c..c748d867 100644 --- a/apps/ui/src/components/ui/path-input.tsx +++ b/apps/ui/src/components/ui/path-input.tsx @@ -226,6 +226,7 @@ function PathInput({ // Close search with Escape key if (e.key === 'Escape' && isSearchOpen) { e.preventDefault(); + e.stopPropagation(); // Stop propagation so parent modal doesn't close setIsSearchOpen(false); } }; From 3d361028b37eff694f58bd125920888e9fcbbecc Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Thu, 25 Dec 2025 19:38:03 +0100 Subject: [PATCH 9/9] feat: add OS detection hook and integrate into FileBrowserDialog for improved keyboard shortcut handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced useOSDetection hook to determine the user's operating system. - Updated FileBrowserDialog to utilize the OS detection for displaying the correct keyboard shortcut (⌘ or Ctrl) based on the detected OS. --- .../dialogs/file-browser-dialog.tsx | 8 ++-- apps/ui/src/hooks/index.ts | 1 + apps/ui/src/hooks/use-os-detection.ts | 48 +++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 apps/ui/src/hooks/use-os-detection.ts diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index ff41c22b..02552c28 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -13,6 +13,7 @@ import { PathInput } from '@/components/ui/path-input'; import { Kbd, KbdGroup } from '@/components/ui/kbd'; import { getJSON, setJSON } from '@/lib/storage'; import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; +import { useOSDetection } from '@/hooks'; interface DirectoryEntry { name: string; @@ -68,6 +69,7 @@ export function FileBrowserDialog({ description = 'Navigate to your project folder or paste a path directly', initialPath, }: FileBrowserDialogProps) { + const { isMac } = useOSDetection(); const [currentPath, setCurrentPath] = useState(''); const [parentPath, setParentPath] = useState(null); const [directories, setDirectories] = useState([]); @@ -374,11 +376,7 @@ export function FileBrowserDialog({ Select Current Folder - - {typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') - ? '⌘' - : 'Ctrl'} - + {isMac ? '⌘' : 'Ctrl'} diff --git a/apps/ui/src/hooks/index.ts b/apps/ui/src/hooks/index.ts index b18a85e6..8f2264d6 100644 --- a/apps/ui/src/hooks/index.ts +++ b/apps/ui/src/hooks/index.ts @@ -3,6 +3,7 @@ export { useBoardBackgroundSettings } from './use-board-background-settings'; export { useElectronAgent } from './use-electron-agent'; export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; export { useMessageQueue } from './use-message-queue'; +export { useOSDetection, type OperatingSystem, type OSDetectionResult } from './use-os-detection'; export { useResponsiveKanban } from './use-responsive-kanban'; export { useScrollTracking } from './use-scroll-tracking'; export { useSettingsMigration } from './use-settings-migration'; diff --git a/apps/ui/src/hooks/use-os-detection.ts b/apps/ui/src/hooks/use-os-detection.ts new file mode 100644 index 00000000..a7bcf68b --- /dev/null +++ b/apps/ui/src/hooks/use-os-detection.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; + +export type OperatingSystem = 'mac' | 'windows' | 'linux' | 'unknown'; + +export interface OSDetectionResult { + readonly os: OperatingSystem; + readonly isMac: boolean; + readonly isWindows: boolean; + readonly isLinux: boolean; +} + +function detectOS(): OperatingSystem { + // Check Electron's process.platform first (most reliable in Electron apps) + if (typeof process !== 'undefined' && process.platform) { + if (process.platform === 'darwin') return 'mac'; + if (process.platform === 'win32') return 'windows'; + if (process.platform === 'linux') return 'linux'; + } + + if (typeof navigator === 'undefined') { + return 'unknown'; + } + + // Fallback: use modern userAgentData API with fallback to navigator.platform + const nav = navigator as Navigator & { userAgentData?: { platform: string } }; + const platform = (nav.userAgentData?.platform ?? navigator.platform ?? '').toLowerCase(); + + if (platform.includes('mac')) return 'mac'; + if (platform.includes('win')) return 'windows'; + if (platform.includes('linux') || platform.includes('x11')) return 'linux'; + return 'unknown'; +} + +/** + * Hook to detect the user's operating system. + * Returns OS information and convenience boolean flags. + */ +export function useOSDetection(): OSDetectionResult { + return useMemo(() => { + const os = detectOS(); + return { + os, + isMac: os === 'mac', + isWindows: os === 'windows', + isLinux: os === 'linux', + }; + }, []); +}