diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 53a952b2..3213440e 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -53,7 +53,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React // Handle Cmd+K to open full command palette useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') { e.preventDefault(); openCommandPalette(); } diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 4777fb91..70703c9b 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -330,7 +330,7 @@ export const CommandPalette = (): React.JSX.Element | null => { // Handle keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'g' && (e.metaKey || e.ctrlKey)) { + if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setGlobalSearchEnabled((prev) => !prev); return; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6c6bd6d..f86ab473 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -26,6 +26,7 @@ import { AlertTriangle, Bell, CheckCheck, + Code, Columns3, FolderOpen, GitBranch, @@ -805,13 +806,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele {formatProjectPath(data.config.projectPath)} - + + + + + Open project in built-in editor + )} {leadBranch && ( diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx index 5b0475bd..f8d0ee6e 100644 --- a/src/renderer/components/team/editor/EditorBreadcrumb.tsx +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; import { ChevronRight } from 'lucide-react'; -import { getFileIcon } from './fileIcons'; +import { FileIcon } from './FileIcon'; // ============================================================================= // Component @@ -33,8 +33,6 @@ export const EditorBreadcrumb = (): React.ReactElement | null => { if (segments.length === 0) return null; const fileName = segments[segments.length - 1]; - const iconInfo = getFileIcon(fileName); - const Icon = iconInfo.icon; const handleSegmentClick = (segmentIndex: number): void => { if (!projectPath) return; @@ -53,7 +51,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => { {idx > 0 && } {isLast ? ( - + {segment} ) : ( diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index ef31bb4c..50c98025 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -22,7 +22,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react'; import { EditorContextMenu } from './EditorContextMenu'; -import { getFileIcon } from './fileIcons'; +import { FileIcon } from './FileIcon'; import { GitStatusBadge } from './GitStatusBadge'; import { NewFileDialog } from './NewFileDialog'; @@ -121,9 +121,25 @@ export const EditorFileTree = ({ return map; }, [flatItems]); + // Compute insertion index for inline new-item input + const newItemInsert = useMemo(() => { + if (!newItemState) return null; + const { parentDir } = newItemState; + + const parentIdx = flatItems.findIndex((fi) => fi.node.fullPath === parentDir); + + if (parentIdx === -1) { + // parentDir is the project root (not a node in flatItems) — insert at top + return { index: 0, depth: 0 }; + } + + // Insert right after the parent directory node (top of its children) + return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 }; + }, [newItemState, flatItems]); + // Virtual scrolling — increase overscan during drag for more drop targets const virtualizer = useVirtualizer({ - count: flatItems.length, + count: flatItems.length + (newItemInsert ? 1 : 0), getScrollElement: () => scrollRef.current, estimateSize: () => ITEM_HEIGHT, overscan: draggedItem ? 20 : 10, @@ -163,14 +179,26 @@ export const EditorFileTree = ({ [onFileSelect, expandedDirs, expandDirectory, collapseDirectory] ); - // Context menu handlers - const handleNewFile = useCallback((parentDir: string) => { - setNewItemState({ parentDir, type: 'file' }); - }, []); + // Context menu handlers — expand parent directory so the input appears inline + const handleNewFile = useCallback( + (parentDir: string) => { + if (parentDir !== projectPath && !expandedDirs[parentDir]) { + void expandDirectory(parentDir); + } + setNewItemState({ parentDir, type: 'file' }); + }, + [projectPath, expandedDirs, expandDirectory] + ); - const handleNewFolder = useCallback((parentDir: string) => { - setNewItemState({ parentDir, type: 'directory' }); - }, []); + const handleNewFolder = useCallback( + (parentDir: string) => { + if (parentDir !== projectPath && !expandedDirs[parentDir]) { + void expandDirectory(parentDir); + } + setNewItemState({ parentDir, type: 'directory' }); + }, + [projectPath, expandedDirs, expandDirectory] + ); const handleDelete = useCallback( async (path: string) => { @@ -357,7 +385,36 @@ export const EditorFileTree = ({ }} > {virtualizer.getVirtualItems().map((virtualItem) => { - const item = flatItems[virtualItem.index]; + const { index } = virtualItem; + + // Render inline new-item input at the correct tree position + if (index === newItemInsert?.index) { + return ( +
+ +
+ ); + } + + // Adjust index for items after the insertion point + const flatIdx = newItemInsert && index > newItemInsert.index ? index - 1 : index; + const item = flatItems[flatIdx]; + return ( } - {newItemState && ( - - )} ); }; @@ -521,9 +570,7 @@ const DraggableTreeItem = React.memo( if (node.data?.isSensitive) { icon = ; } else if (node.isFile) { - const fileIcon = getFileIcon(node.name); - const FileIcon = fileIcon.icon; - icon = ; + icon = ; } else if (isExpanded) { icon = ; } else { @@ -583,9 +630,7 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme let icon: React.ReactNode; if (node.isFile) { - const fileIcon = getFileIcon(node.name); - const FileIcon = fileIcon.icon; - icon = ; + icon = ; } else { icon = ; } diff --git a/src/renderer/components/team/editor/EditorTabBar.tsx b/src/renderer/components/team/editor/EditorTabBar.tsx index b5192a93..c7d985ef 100644 --- a/src/renderer/components/team/editor/EditorTabBar.tsx +++ b/src/renderer/components/team/editor/EditorTabBar.tsx @@ -1,13 +1,15 @@ /** * Tab bar for the project editor. - * Shows open files as tabs with dirty indicator (dot) and close button. + * Shows open files as tabs with dirty indicator (dot), close button, + * and right-click context menu (close others, close to left/right, close all). */ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { X } from 'lucide-react'; -import { getFileIcon } from './fileIcons'; +import { EditorTabContextMenu } from './EditorTabContextMenu'; +import { FileIcon } from './FileIcon'; import type { EditorFileTab } from '@shared/types/editor'; @@ -31,6 +33,10 @@ export const EditorTabBar = ({ const activeTabId = useStore((s) => s.editorActiveTabId); const modifiedFiles = useStore((s) => s.editorModifiedFiles); const setActiveEditorTab = useStore((s) => s.setActiveEditorTab); + const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs); + const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft); + const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight); + const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs); if (tabs.length === 0) return null; @@ -39,14 +45,20 @@ export const EditorTabBar = ({ className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar" role="tablist" > - {tabs.map((tab) => ( + {tabs.map((tab, index) => ( setActiveEditorTab(tab.id)} - onClose={() => onRequestCloseTab(tab.id)} + onRequestClose={onRequestCloseTab} + onCloseOthers={closeOtherEditorTabs} + onCloseToLeft={closeEditorTabsToLeft} + onCloseToRight={closeEditorTabsToRight} + onCloseAll={closeAllEditorTabs} /> ))} @@ -59,67 +71,93 @@ export const EditorTabBar = ({ interface TabProps { tab: EditorFileTab; + tabIndex: number; + totalTabs: number; isActive: boolean; isModified: boolean; onActivate: () => void; - onClose: () => void; + onRequestClose: (tabId: string) => void; + onCloseOthers: (tabId: string) => void; + onCloseToLeft: (tabId: string) => void; + onCloseToRight: (tabId: string) => void; + onCloseAll: () => void; } -const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => { +const Tab = ({ + tab, + tabIndex, + totalTabs, + isActive, + isModified, + onActivate, + onRequestClose, + onCloseOthers, + onCloseToLeft, + onCloseToRight, + onCloseAll, +}: TabProps): React.ReactElement => { const handleClose = (e: React.MouseEvent) => { e.stopPropagation(); - onClose(); + onRequestClose(tab.id); }; const handleAuxClick = (e: React.MouseEvent) => { if (e.button === 1) { e.preventDefault(); - onClose(); + onRequestClose(tab.id); } }; - const iconInfo = getFileIcon(tab.fileName); - const FileIcon = iconInfo.icon; - return ( - - - - - {tab.filePath} - + {isModified && ( + + )} + + + {tab.fileName} + {tab.disambiguatedLabel && ( + {tab.disambiguatedLabel} + )} + + + + + + + {tab.filePath} + + ); }; diff --git a/src/renderer/components/team/editor/EditorTabContextMenu.tsx b/src/renderer/components/team/editor/EditorTabContextMenu.tsx new file mode 100644 index 00000000..69021db2 --- /dev/null +++ b/src/renderer/components/team/editor/EditorTabContextMenu.tsx @@ -0,0 +1,88 @@ +/** + * Context menu for editor tabs. + * Supports: close, close others, close to left/right, close all. + */ + +import * as ContextMenu from '@radix-ui/react-context-menu'; + +interface EditorTabContextMenuProps { + children: React.ReactNode; + tabId: string; + tabIndex: number; + totalTabs: number; + onClose: (tabId: string) => void; + onCloseOthers: (tabId: string) => void; + onCloseToLeft: (tabId: string) => void; + onCloseToRight: (tabId: string) => void; + onCloseAll: () => void; +} + +export const EditorTabContextMenu = ({ + children, + tabId, + tabIndex, + totalTabs, + onClose, + onCloseOthers, + onCloseToLeft, + onCloseToRight, + onCloseAll, +}: EditorTabContextMenuProps): React.ReactElement => { + const hasLeft = tabIndex > 0; + const hasRight = tabIndex < totalTabs - 1; + const hasOthers = totalTabs > 1; + + return ( + + +
{children}
+
+ + + + onClose(tabId)} + > + Close + + + onCloseOthers(tabId)} + > + Close Others + + + + + onCloseToLeft(tabId)} + > + Close Tabs to the Left + + + onCloseToRight(tabId)} + > + Close Tabs to the Right + + + + + + Close All + + + +
+ ); +}; diff --git a/src/renderer/components/team/editor/FileIcon.tsx b/src/renderer/components/team/editor/FileIcon.tsx new file mode 100644 index 00000000..0191fc1c --- /dev/null +++ b/src/renderer/components/team/editor/FileIcon.tsx @@ -0,0 +1,66 @@ +/** + * FileIcon — renders a file-type icon. + * + * For programming languages/frameworks: uses Devicon CDN SVG (real colorful logos). + * For generic types (images, fonts, configs): uses lucide-react icons with tinted color. + * Falls back to lucide if the Devicon image fails to load. + * + * Applies a subtle glow (drop-shadow) in dark mode so dark-colored icons + * remain visible against dark backgrounds (e.g. Go, Rust, C). + */ + +import { memo, useCallback, useState } from 'react'; + +import { cn } from '@renderer/lib/utils'; + +import { getDeviconUrl, getFileIcon } from './fileIcons'; + +// ============================================================================= +// Types +// ============================================================================= + +interface FileIconProps { + /** File name (e.g. "index.ts", "Dockerfile", "logo.png") */ + fileName: string; + /** Tailwind size class (e.g. "size-3.5", "size-4"). Defaults to "size-3.5" */ + className?: string; +} + +// Track slugs that failed to load so we don't retry them across mounts +const failedSlugs = new Set(); + +// ============================================================================= +// Component +// ============================================================================= + +export const FileIcon = memo(({ fileName, className = 'size-3.5' }: FileIconProps) => { + const info = getFileIcon(fileName); + const slug = info.deviconSlug; + const canUseDevicon = slug != null && !failedSlugs.has(slug); + + const [imgFailed, setImgFailed] = useState(false); + + const handleError = useCallback(() => { + if (slug) failedSlugs.add(slug); + setImgFailed(true); + }, [slug]); + + if (canUseDevicon && !imgFailed) { + return ( + + ); + } + + // Fallback to lucide icon + const Icon = info.icon; + return ; +}); + +FileIcon.displayName = 'FileIcon'; diff --git a/src/renderer/components/team/editor/NewFileDialog.tsx b/src/renderer/components/team/editor/NewFileDialog.tsx index 880d8756..585b9ddb 100644 --- a/src/renderer/components/team/editor/NewFileDialog.tsx +++ b/src/renderer/components/team/editor/NewFileDialog.tsx @@ -49,9 +49,16 @@ export const NewFileDialog = ({ const [error, setError] = useState(null); const inputRef = useRef(null); + // Track whether focus has been established (prevents premature blur cancel) + const focusedRef = useRef(false); + useEffect(() => { - // Auto-focus on mount - inputRef.current?.focus(); + // Defer focus to next frame — ensures Radix context menu has fully closed + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + focusedRef.current = true; + }); + return () => cancelAnimationFrame(raf); }, []); const handleSubmit = useCallback(() => { @@ -82,6 +89,13 @@ export const NewFileDialog = ({ setError(null); }, []); + const handleBlur = useCallback(() => { + // Only cancel if focus was already established (prevents race with RAF focus) + if (focusedRef.current) { + onCancel(); + } + }, [onCancel]); + const Icon = type === 'file' ? FilePlus : FolderPlus; return ( @@ -94,7 +108,7 @@ export const NewFileDialog = ({ value={value} onChange={handleChange} onKeyDown={handleKeyDown} - onBlur={onCancel} + onBlur={handleBlur} placeholder={type === 'file' ? 'File name...' : 'Folder name...'} className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500" aria-label={type === 'file' ? 'New file name' : 'New folder name'} diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx index 84345468..dc589ae4 100644 --- a/src/renderer/components/team/editor/QuickOpenDialog.tsx +++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx @@ -11,7 +11,7 @@ import { useStore } from '@renderer/store'; import { Command } from 'cmdk'; import { Loader2 } from 'lucide-react'; -import { getFileIcon } from './fileIcons'; +import { FileIcon } from './FileIcon'; import type { QuickOpenFile } from '@shared/types/editor'; @@ -97,15 +97,7 @@ export const QuickOpenDialog = ({ [allFiles, onSelectFile, onClose] ); - // Memoize file icon lookups - const fileItems = useMemo( - () => - allFiles.map((file) => ({ - ...file, - iconInfo: getFileIcon(file.name), - })), - [allFiles] - ); + const fileItems = allFiles; return (
@@ -146,7 +138,6 @@ export const QuickOpenDialog = ({ )} {fileItems.map((file) => { - const Icon = file.iconInfo.icon; return ( handleSelect(file.relativePath)} className="flex cursor-pointer items-center gap-2 rounded px-3 py-1.5 text-sm text-text-secondary aria-selected:bg-surface-raised aria-selected:text-text" > - + {file.name} {file.relativePath} diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx index cfcbc802..cb96667f 100644 --- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx +++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx @@ -11,7 +11,7 @@ import { api } from '@renderer/api'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Loader2, Search, X } from 'lucide-react'; -import { getFileIcon } from './fileIcons'; +import { FileIcon } from './FileIcon'; import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor'; @@ -259,9 +259,6 @@ const SearchFileGroup = ({ const dirPath = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : ''; - const iconInfo = getFileIcon(fileName); - const Icon = iconInfo.icon; - return (
{task.needsClarification ? ( ) : null} - {compact && ( -
- {task.subject} -
- )} + {compact && }
{hasBlockedBy ? ( diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 149d3205..e831cca6 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -379,7 +379,7 @@ export const ChangeReviewDialog = ({ useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent): void => { - if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) { // Don't intercept if focus is inside a CM editor — let CM handle its own undo if (document.activeElement?.closest('.cm-editor')) return; // Don't intercept native undo in input/textarea diff --git a/src/renderer/components/team/review/ReviewFileTree.tsx b/src/renderer/components/team/review/ReviewFileTree.tsx index 97b92370..b410502b 100644 --- a/src/renderer/components/team/review/ReviewFileTree.tsx +++ b/src/renderer/components/team/review/ReviewFileTree.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; @@ -11,7 +12,6 @@ import { Circle, CircleDot, Eye, - File, Folder, FolderOpen, X as XIcon, @@ -139,7 +139,7 @@ const TreeItem = ({ style={{ paddingLeft: `${depth * 12 + 8}px` }} > - + {viewedSet && viewedSet.has(node.data.filePath) && ( diff --git a/src/renderer/components/terminal/EmbeddedTerminal.tsx b/src/renderer/components/terminal/EmbeddedTerminal.tsx index 57d15f48..895cf062 100644 --- a/src/renderer/components/terminal/EmbeddedTerminal.tsx +++ b/src/renderer/components/terminal/EmbeddedTerminal.tsx @@ -66,7 +66,7 @@ export const EmbeddedTerminal = ({ // Ctrl+C with selection → copy to clipboard (instead of sending SIGINT) term.attachCustomKeyEventHandler((event) => { - if (event.type === 'keydown' && event.key === 'c' && (event.ctrlKey || event.metaKey)) { + if (event.type === 'keydown' && event.code === 'KeyC' && (event.ctrlKey || event.metaKey)) { const selection = term.getSelection(); if (selection) { void navigator.clipboard.writeText(selection); diff --git a/src/renderer/hooks/useDiffNavigation.ts b/src/renderer/hooks/useDiffNavigation.ts index c1fee310..b9358dd1 100644 --- a/src/renderer/hooks/useDiffNavigation.ts +++ b/src/renderer/hooks/useDiffNavigation.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge'; import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils'; +import { physicalKey } from '@renderer/utils/keyboardUtils'; import type { EditorView } from '@codemirror/view'; import type { FileChangeSummary } from '@shared/types/review'; @@ -276,44 +277,46 @@ export function useDiffNavigation( } const isMeta = event.metaKey || event.ctrlKey; + // Layout-independent key (uses event.code for letters/symbols) + const key = physicalKey(event); // Alt+J -> next hunk (cross-file in continuous mode) - if (event.altKey && event.key.toLowerCase() === 'j') { + if (event.altKey && key === 'j') { event.preventDefault(); goToNextHunk(); return; } // Alt+K -> prev hunk (cross-file in continuous mode) - if (event.altKey && event.key.toLowerCase() === 'k') { + if (event.altKey && key === 'k') { event.preventDefault(); goToPrevHunk(); return; } // Alt+ArrowDown -> next file - if (event.altKey && event.key === 'ArrowDown') { + if (event.altKey && key === 'ArrowDown') { event.preventDefault(); goToNextFile(); return; } // Alt+ArrowUp -> prev file - if (event.altKey && event.key === 'ArrowUp') { + if (event.altKey && key === 'ArrowUp') { event.preventDefault(); goToPrevFile(); return; } // Cmd+Enter -> save file - if (isMeta && event.key === 'Enter') { + if (isMeta && key === 'Enter') { event.preventDefault(); onSaveFileRef.current?.(); return; } // Cmd+Y -> accept chunk + next (cross-file aware) - if (isMeta && event.key.toLowerCase() === 'y') { + if (isMeta && key === 'y') { event.preventDefault(); const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current); if (view) { diff --git a/src/renderer/hooks/useEditorKeyboardShortcuts.ts b/src/renderer/hooks/useEditorKeyboardShortcuts.ts index 58cf09e1..f63ff2ea 100644 --- a/src/renderer/hooks/useEditorKeyboardShortcuts.ts +++ b/src/renderer/hooks/useEditorKeyboardShortcuts.ts @@ -10,6 +10,7 @@ import { useCallback, useEffect } from 'react'; import { gotoLine, openSearchPanel } from '@codemirror/search'; import { useStore } from '@renderer/store'; import { editorBridge } from '@renderer/utils/editorBridge'; +import { physicalKey } from '@renderer/utils/keyboardUtils'; import type { EditorFileTab } from '@shared/types/editor'; @@ -52,8 +53,11 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard const isMod = e.metaKey || e.ctrlKey; if (!isMod) return; + // Layout-independent key (uses event.code for letters/symbols) + const key = physicalKey(e); + // Cmd+P: Quick Open - if (e.key === 'p' && !e.shiftKey) { + if (key === 'p' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); deps.onToggleQuickOpen(); @@ -61,7 +65,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+Shift+F: Search in files - if (e.key === 'f' && e.shiftKey) { + if (key === 'f' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); deps.onToggleSearchPanel(); @@ -69,7 +73,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+F: Find in current file (CM6) - if (e.key === 'f' && !e.shiftKey) { + if (key === 'f' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); const view = deps.getEditorView(); @@ -78,7 +82,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+G: Go to line - if (e.key === 'g' && !e.shiftKey) { + if (key === 'g' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); const view = deps.getEditorView(); @@ -87,7 +91,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+S: Save current file - if (e.key === 's' && !e.shiftKey) { + if (key === 's' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); if (deps.activeTabId) void deps.saveFile(deps.activeTabId); @@ -95,7 +99,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+Shift+S: Save all files - if (e.key === 's' && e.shiftKey) { + if (key === 's' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); if (deps.hasUnsavedChanges()) void deps.saveAllFiles(); @@ -103,7 +107,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+Shift+W: Toggle line wrap - if (e.key === 'w' && e.shiftKey && !e.altKey) { + if (key === 'w' && e.shiftKey && !e.altKey) { e.preventDefault(); e.stopPropagation(); deps.onToggleLineWrap(); @@ -111,7 +115,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+W: Close current editor tab - if (e.key === 'w' && !e.shiftKey && !e.altKey) { + if (key === 'w' && !e.shiftKey && !e.altKey) { e.preventDefault(); e.stopPropagation(); if (deps.activeTabId) { @@ -123,7 +127,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+B: Toggle sidebar - if (e.key === 'b') { + if (key === 'b') { e.preventDefault(); e.stopPropagation(); deps.onToggleSidebar(); @@ -131,7 +135,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+Shift+]: Next tab - if (e.key === ']' && e.shiftKey) { + if (key === ']' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); @@ -144,7 +148,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Cmd+Shift+[: Previous tab - if (e.key === '[' && e.shiftKey) { + if (key === '[' && e.shiftKey) { e.preventDefault(); e.stopPropagation(); const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); @@ -157,7 +161,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard } // Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling - if (e.ctrlKey && e.key === 'Tab') { + if (e.ctrlKey && key === 'Tab') { e.preventDefault(); e.stopPropagation(); const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId); diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index de229a33..dbf9c56d 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,7 @@ import { useEffect } from 'react'; +import { physicalKey } from '@renderer/utils/keyboardUtils'; import { createLogger } from '@shared/utils/logger'; import { useShallow } from 'zustand/react/shallow'; @@ -78,27 +79,29 @@ export function useKeyboardShortcuts(): void { function handleKeyDown(event: KeyboardEvent): void { // Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed const isMod = event.metaKey || event.ctrlKey; + // Layout-independent key (uses event.code for letters/symbols) + const key = physicalKey(event); // Editor scope guard: when the editor overlay is open, these shortcuts are // handled by useEditorKeyboardShortcuts — yield control to avoid conflicts. if (editorOpen) { const isConflicting = // Ctrl+Tab — editor tab cycling - (event.ctrlKey && event.key === 'Tab') || + (event.ctrlKey && key === 'Tab') || // Cmd+W — editor close tab - (isMod && event.key === 'w' && !event.altKey && !event.shiftKey) || + (isMod && key === 'w' && !event.altKey && !event.shiftKey) || // Cmd+B — editor sidebar toggle - (isMod && event.key === 'b') || + (isMod && key === 'b') || // Cmd+F — editor find in file (CM6) - (isMod && event.key === 'f') || + (isMod && key === 'f') || // Cmd+Shift+[ / ] — editor tab switching - (isMod && event.shiftKey && (event.key === '[' || event.key === ']')); + (isMod && event.shiftKey && (key === '[' || key === ']')); if (isConflicting) return; } // Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut) - if (event.ctrlKey && event.key === 'Tab') { + if (event.ctrlKey && key === 'Tab') { event.preventDefault(); const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); @@ -128,7 +131,7 @@ export function useKeyboardShortcuts(): void { // Cmd+Option+1-4: Focus pane by index if (event.altKey && !event.shiftKey) { - const numKey = parseInt(event.key); + const numKey = parseInt(key); if (numKey >= 1 && numKey <= 4) { event.preventDefault(); const targetPane = paneLayout.panes[numKey - 1]; @@ -139,7 +142,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Option+W: Close current pane - if (event.key === 'w') { + if (key === 'w') { event.preventDefault(); if (paneLayout.panes.length > 1) { closePane(paneLayout.focusedPaneId); @@ -149,7 +152,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+\: Split right with current tab - if (event.key === '\\' && !event.altKey && !event.shiftKey) { + if (key === '\\' && !event.altKey && !event.shiftKey) { event.preventDefault(); if (activeTabId) { splitPane(paneLayout.focusedPaneId, activeTabId, 'right'); @@ -158,21 +161,21 @@ export function useKeyboardShortcuts(): void { } // Cmd+T: New tab (Dashboard) - if (event.key === 't') { + if (key === 't') { event.preventDefault(); openDashboard(); return; } // Cmd+Shift+W: Close all tabs - if (event.key === 'w' && event.shiftKey && !event.altKey) { + if (key === 'w' && event.shiftKey && !event.altKey) { event.preventDefault(); closeAllTabs(); return; } // Cmd+W: Close selected tabs (if multi-selected) or active tab - if (event.key === 'w' && !event.altKey) { + if (key === 'w' && !event.altKey) { event.preventDefault(); if (selectedTabIds.length > 0) { closeTabs(selectedTabIds); @@ -183,7 +186,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+[1-9]: Switch to tab by index within focused pane - const numKey = parseInt(event.key); + const numKey = parseInt(key); if (numKey >= 1 && numKey <= 9 && !event.altKey) { event.preventDefault(); const targetTab = openTabs[numKey - 1]; @@ -194,7 +197,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Shift+]: Next tab within focused pane - if (event.key === ']' && event.shiftKey) { + if (key === ']' && event.shiftKey) { event.preventDefault(); const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); if (currentIndex !== -1 && currentIndex < openTabs.length - 1) { @@ -204,7 +207,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Shift+[: Previous tab within focused pane - if (event.key === '[' && event.shiftKey) { + if (key === '[' && event.shiftKey) { event.preventDefault(); const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); if (currentIndex > 0) { @@ -214,7 +217,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Option+Right: Next tab (browser-style) within focused pane - if (event.key === 'ArrowRight' && event.altKey) { + if (key === 'ArrowRight' && event.altKey) { event.preventDefault(); const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); if (currentIndex !== -1 && currentIndex < openTabs.length - 1) { @@ -224,7 +227,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Option+Left: Previous tab (browser-style) within focused pane - if (event.key === 'ArrowLeft' && event.altKey) { + if (key === 'ArrowLeft' && event.altKey) { event.preventDefault(); const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); if (currentIndex > 0) { @@ -234,7 +237,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+Shift+K: Cycle to next workspace context - if (event.key === 'k' && event.shiftKey) { + if (key === 'k' && event.shiftKey) { event.preventDefault(); if (!isContextSwitching && availableContexts.length > 1) { const currentIndex = availableContexts.findIndex((c) => c.id === activeContextId); @@ -245,21 +248,21 @@ export function useKeyboardShortcuts(): void { } // Cmd+K: Open command palette for global search - if (event.key === 'k') { + if (key === 'k') { event.preventDefault(); openCommandPalette(); return; } // Cmd+,: Open settings (standard macOS shortcut) - if (event.key === ',') { + if (key === ',') { event.preventDefault(); openSettingsTab(); return; } // Cmd+F: Find in session - if (event.key === 'f') { + if (key === 'f') { event.preventDefault(); const activeTab = getActiveTab(); // Only enable search in session views, not dashboard @@ -270,14 +273,14 @@ export function useKeyboardShortcuts(): void { } // Cmd+O: Open project (placeholder for future implementation) - if (event.key === 'o') { + if (key === 'o') { event.preventDefault(); logger.debug('Open project shortcut triggered (not yet implemented)'); return; } // Cmd+R: Refresh current session and sidebar session list - if (event.key === 'r') { + if (key === 'r') { event.preventDefault(); if (selectedProjectId && selectedSessionId) { void Promise.all([ @@ -289,7 +292,7 @@ export function useKeyboardShortcuts(): void { } // Cmd+B: Toggle sidebar - if (event.key === 'b') { + if (key === 'b') { event.preventDefault(); toggleSidebar(); } diff --git a/src/renderer/index.css b/src/renderer/index.css index 80c0415f..aeea07e4 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -199,6 +199,11 @@ --skeleton-base-dim: rgba(26, 28, 40, 0.6); } +/* File icon glow — halo so dark icons stay visible on dark backgrounds */ +.file-icon-glow { + filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45)); +} + /* Light theme overrides - Warm neutral palette for eye comfort */ :root.light { --color-surface: #f9f9f7; /* Warm off-white (not pure white) */ @@ -580,6 +585,10 @@ body { animation: shimmer 1.2s ease-in-out infinite; } +:root.light .file-icon-glow { + filter: none; +} + :root.light .skeleton-card::after { background: linear-gradient( 90deg, diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 1dde315a..cee0d3e4 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -78,6 +78,10 @@ export interface EditorSlice { openFile: (filePath: string) => void; closeEditorTab: (tabId: string) => void; + closeOtherEditorTabs: (keepTabId: string) => void; + closeEditorTabsToLeft: (tabId: string) => void; + closeEditorTabsToRight: (tabId: string) => void; + closeAllEditorTabs: () => void; setActiveEditorTab: (tabId: string) => void; // ═══════════════════════════════════════════════════════ @@ -392,6 +396,33 @@ export const createEditorSlice: StateCreator = (s }); }, + closeOtherEditorTabs: (keepTabId: string) => { + const { editorOpenTabs } = get(); + const toClose = editorOpenTabs.filter((t) => t.id !== keepTabId); + for (const tab of toClose) get().closeEditorTab(tab.id); + }, + + closeEditorTabsToLeft: (tabId: string) => { + const { editorOpenTabs } = get(); + const idx = editorOpenTabs.findIndex((t) => t.id === tabId); + if (idx <= 0) return; + const toClose = editorOpenTabs.slice(0, idx); + for (const tab of toClose) get().closeEditorTab(tab.id); + }, + + closeEditorTabsToRight: (tabId: string) => { + const { editorOpenTabs } = get(); + const idx = editorOpenTabs.findIndex((t) => t.id === tabId); + if (idx < 0 || idx >= editorOpenTabs.length - 1) return; + const toClose = editorOpenTabs.slice(idx + 1); + for (const tab of toClose) get().closeEditorTab(tab.id); + }, + + closeAllEditorTabs: () => { + const { editorOpenTabs } = get(); + for (const tab of [...editorOpenTabs]) get().closeEditorTab(tab.id); + }, + setActiveEditorTab: (tabId: string) => { set({ editorActiveTabId: tabId }); }, diff --git a/src/renderer/utils/keyboardUtils.ts b/src/renderer/utils/keyboardUtils.ts index c525ad06..a5bd7f12 100644 --- a/src/renderer/utils/keyboardUtils.ts +++ b/src/renderer/utils/keyboardUtils.ts @@ -9,6 +9,60 @@ export function isMacOS(): boolean { return navigator.userAgent.toLowerCase().includes('mac'); } +/** + * Resolve the physical key from a keyboard event, independent of keyboard layout. + * + * Uses `event.code` (physical key position on a QWERTY keyboard) to determine + * the key, so shortcuts work correctly regardless of active layout (Russian, + * Hebrew, Arabic, etc.). + * + * Returns a lowercase single character for letter keys, digit for number keys, + * the symbol for punctuation keys, or falls back to `event.key` for special + * keys (Tab, Enter, Escape, Arrow*, etc.) which are layout-independent. + */ +export function physicalKey(e: KeyboardEvent): string { + const { code, key } = e; + + // Letter keys: KeyA → 'a', KeyF → 'f', KeyZ → 'z' + if (code.startsWith('Key') && code.length === 4) { + return code[3].toLowerCase(); + } + + // Digit keys: Digit0 → '0', Digit9 → '9' + if (code.startsWith('Digit') && code.length === 6) { + return code[5]; + } + + // Punctuation / symbol keys + switch (code) { + case 'BracketLeft': + return '['; + case 'BracketRight': + return ']'; + case 'Backslash': + return '\\'; + case 'Comma': + return ','; + case 'Period': + return '.'; + case 'Slash': + return '/'; + case 'Semicolon': + return ';'; + case 'Quote': + return "'"; + case 'Minus': + return '-'; + case 'Equal': + return '='; + case 'Backquote': + return '`'; + default: + // Special keys: Tab, Enter, Escape, ArrowUp, ArrowDown, Space, etc. + return key; + } +} + /** * Get the primary modifier key name for the current platform * @returns 'Cmd' on macOS, 'Ctrl' on other platforms diff --git a/test/renderer/components/team/editor/fileIcons.test.ts b/test/renderer/components/team/editor/fileIcons.test.ts index 66debd16..3cab4a55 100644 --- a/test/renderer/components/team/editor/fileIcons.test.ts +++ b/test/renderer/components/team/editor/fileIcons.test.ts @@ -1,88 +1,154 @@ /** - * Tests for fileIcons utility — extension-to-icon mapping. + * Tests for fileIcons utility — extension-to-icon mapping with Devicon support. */ import { describe, expect, it } from 'vitest'; -import { getFileIcon } from '@renderer/components/team/editor/fileIcons'; +import { getDeviconUrl, getFileIcon } from '@renderer/components/team/editor/fileIcons'; describe('getFileIcon', () => { it('returns TypeScript icon for .ts files', () => { const info = getFileIcon('index.ts'); expect(info.color).toBe('#3178c6'); + expect(info.deviconSlug).toBe('typescript'); }); it('returns TypeScript icon for .tsx files', () => { const info = getFileIcon('App.tsx'); expect(info.color).toBe('#3178c6'); + expect(info.deviconSlug).toBe('react'); }); it('returns JavaScript icon for .js files', () => { const info = getFileIcon('app.js'); expect(info.color).toBe('#f7df1e'); + expect(info.deviconSlug).toBe('javascript'); }); it('returns JSON icon for .json files', () => { const info = getFileIcon('package.json'); // package.json has special mapping expect(info.color).toBe('#cb3837'); + expect(info.deviconSlug).toBe('nodejs'); }); it('returns markdown icon for .md files', () => { const info = getFileIcon('README.md'); expect(info.color).toBe('#519aba'); + expect(info.deviconSlug).toBe('markdown'); }); it('returns Python icon for .py files', () => { const info = getFileIcon('main.py'); expect(info.color).toBe('#3572a5'); + expect(info.deviconSlug).toBe('python'); }); it('returns Rust icon for .rs files', () => { const info = getFileIcon('lib.rs'); expect(info.color).toBe('#dea584'); + expect(info.deviconSlug).toBe('rust'); }); it('returns default icon for unknown extensions', () => { const info = getFileIcon('file.xyz123'); expect(info.color).toBe('#89949f'); + expect(info.deviconSlug).toBeUndefined(); }); it('returns default icon for files without extension', () => { const info = getFileIcon('Procfile'); expect(info.color).toBe('#89949f'); + expect(info.deviconSlug).toBeUndefined(); }); it('matches special filenames exactly', () => { const docker = getFileIcon('Dockerfile'); expect(docker.color).toBe('#2496ed'); + expect(docker.deviconSlug).toBe('docker'); const gitignore = getFileIcon('.gitignore'); expect(gitignore.color).toBe('#f05032'); + expect(gitignore.deviconSlug).toBe('git'); const claudeMd = getFileIcon('CLAUDE.md'); expect(claudeMd.color).toBe('#d97706'); + expect(claudeMd.deviconSlug).toBeUndefined(); }); it('prefers filename match over extension match', () => { // tsconfig.json should match FILENAME_MAP, not generic .json const tsconfig = getFileIcon('tsconfig.json'); expect(tsconfig.color).toBe('#3178c6'); + expect(tsconfig.deviconSlug).toBe('typescript'); }); it('returns lock icon for sensitive files', () => { const env = getFileIcon('.env'); expect(env.color).toBe('#e5a00d'); + expect(env.deviconSlug).toBeUndefined(); const pnpmLock = getFileIcon('pnpm-lock.yaml'); expect(pnpmLock.color).toBe('#f69220'); + expect(pnpmLock.deviconSlug).toBeUndefined(); }); it('handles image files', () => { const png = getFileIcon('logo.png'); expect(png.color).toBe('#a074c4'); + expect(png.deviconSlug).toBeUndefined(); const svg = getFileIcon('icon.svg'); expect(svg.color).toBe('#ffb13b'); + expect(svg.deviconSlug).toBeUndefined(); + }); + + it('provides devicon slugs for major languages', () => { + const cases: [string, string][] = [ + ['app.go', 'go'], + ['lib.rb', 'ruby'], + ['Main.java', 'java'], + ['style.css', 'css3'], + ['page.html', 'html5'], + ['Component.vue', 'vuejs'], + ['App.svelte', 'svelte'], + ['main.dart', 'dart'], + ['app.swift', 'swift'], + ['main.php', 'php'], + ['main.kt', 'kotlin'], + ['main.scala', 'scala'], + ['app.ex', 'elixir'], + ['query.graphql', 'graphql'], + ]; + + for (const [fileName, expectedSlug] of cases) { + const info = getFileIcon(fileName); + expect(info.deviconSlug, `Expected ${fileName} to have slug "${expectedSlug}"`).toBe( + expectedSlug + ); + } + }); + + it('provides devicon slugs for special config files', () => { + expect(getFileIcon('vite.config.ts').deviconSlug).toBe('vitejs'); + expect(getFileIcon('docker-compose.yml').deviconSlug).toBe('docker'); + expect(getFileIcon('.eslintrc').deviconSlug).toBe('eslint'); + expect(getFileIcon('Cargo.toml').deviconSlug).toBe('rust'); + expect(getFileIcon('go.mod').deviconSlug).toBe('go'); + expect(getFileIcon('tailwind.config.js').deviconSlug).toBe('tailwindcss'); + }); +}); + +describe('getDeviconUrl', () => { + it('builds correct CDN URL for a slug', () => { + const url = getDeviconUrl('typescript'); + expect(url).toBe( + 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg' + ); + }); + + it('works for multi-word slugs', () => { + const url = getDeviconUrl('cplusplus'); + expect(url).toContain('/cplusplus/cplusplus-original.svg'); }); }); diff --git a/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts b/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts index 46b759bf..c09faead 100644 --- a/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts +++ b/test/renderer/hooks/useEditorKeyboardShortcuts.test.ts @@ -57,9 +57,23 @@ function createMockDeps(overrides: Partial = {}): EditorKe }; } +/** Map a shortcut key to its physical KeyboardEvent.code value. */ +function keyToCode(key: string): string { + if (key.length === 1 && /[a-z]/i.test(key)) return `Key${key.toUpperCase()}`; + if (key.length === 1 && /[0-9]/.test(key)) return `Digit${key}`; + if (key === '[') return 'BracketLeft'; + if (key === ']') return 'BracketRight'; + if (key === '\\') return 'Backslash'; + if (key === ',') return 'Comma'; + if (key === '.') return 'Period'; + if (key === '/') return 'Slash'; + return key; // Tab, Enter, Escape, Arrow*, etc. +} + function createKeyEvent(key: string, opts: Partial = {}): KeyboardEvent { return new KeyboardEvent('keydown', { key, + code: keyToCode(key), metaKey: opts.metaKey ?? true, ctrlKey: opts.ctrlKey ?? false, shiftKey: opts.shiftKey ?? false, diff --git a/test/renderer/utils/keyboardUtils.test.ts b/test/renderer/utils/keyboardUtils.test.ts index 5303c261..85ed52b0 100644 --- a/test/renderer/utils/keyboardUtils.test.ts +++ b/test/renderer/utils/keyboardUtils.test.ts @@ -5,6 +5,7 @@ import { getModifierKeyName, getModifierKeySymbol, isMacOS, + physicalKey, } from '../../../src/renderer/utils/keyboardUtils'; describe('keyboardUtils', () => { @@ -158,4 +159,111 @@ describe('keyboardUtils', () => { }); }); }); + + describe('physicalKey', () => { + function makeEvent( + key: string, + code: string, + mods: Partial = {} + ): KeyboardEvent { + return new KeyboardEvent('keydown', { key, code, ...mods, bubbles: true, cancelable: true }); + } + + describe('letter keys — English layout', () => { + it('resolves KeyF to f', () => { + expect(physicalKey(makeEvent('f', 'KeyF'))).toBe('f'); + }); + + it('resolves KeyW to w', () => { + expect(physicalKey(makeEvent('w', 'KeyW'))).toBe('w'); + }); + + it('always returns lowercase even with Shift', () => { + expect(physicalKey(makeEvent('F', 'KeyF', { shiftKey: true }))).toBe('f'); + }); + }); + + describe('letter keys — Russian layout', () => { + it('resolves Cyrillic а (physical F) to f', () => { + expect(physicalKey(makeEvent('а', 'KeyF'))).toBe('f'); + }); + + it('resolves Cyrillic ц (physical W) to w', () => { + expect(physicalKey(makeEvent('ц', 'KeyW'))).toBe('w'); + }); + + it('resolves Cyrillic з (physical P) to p', () => { + expect(physicalKey(makeEvent('з', 'KeyP'))).toBe('p'); + }); + + it('resolves Cyrillic и (physical B) to b', () => { + expect(physicalKey(makeEvent('и', 'KeyB'))).toBe('b'); + }); + + it('resolves Cyrillic л (physical K) to k', () => { + expect(physicalKey(makeEvent('л', 'KeyK'))).toBe('k'); + }); + + it('resolves Cyrillic ы (physical S) to s', () => { + expect(physicalKey(makeEvent('ы', 'KeyS'))).toBe('s'); + }); + }); + + describe('digit keys', () => { + it('resolves Digit1 to 1', () => { + expect(physicalKey(makeEvent('1', 'Digit1'))).toBe('1'); + }); + + it('resolves Digit9 to 9', () => { + expect(physicalKey(makeEvent('9', 'Digit9'))).toBe('9'); + }); + + it('resolves shifted digit (e.g. !) back to digit', () => { + expect(physicalKey(makeEvent('!', 'Digit1', { shiftKey: true }))).toBe('1'); + }); + }); + + describe('punctuation keys', () => { + it('resolves BracketLeft to [', () => { + expect(physicalKey(makeEvent('[', 'BracketLeft'))).toBe('['); + }); + + it('resolves Russian х (physical [) to [', () => { + expect(physicalKey(makeEvent('х', 'BracketLeft'))).toBe('['); + }); + + it('resolves BracketRight to ]', () => { + expect(physicalKey(makeEvent(']', 'BracketRight'))).toBe(']'); + }); + + it('resolves Backslash to \\', () => { + expect(physicalKey(makeEvent('\\', 'Backslash'))).toBe('\\'); + }); + + it('resolves Comma to ,', () => { + expect(physicalKey(makeEvent(',', 'Comma'))).toBe(','); + // Russian: physical , produces б + expect(physicalKey(makeEvent('б', 'Comma'))).toBe(','); + }); + }); + + describe('special keys (pass-through)', () => { + it('returns event.key for Tab', () => { + expect(physicalKey(makeEvent('Tab', 'Tab'))).toBe('Tab'); + }); + + it('returns event.key for Enter', () => { + expect(physicalKey(makeEvent('Enter', 'Enter'))).toBe('Enter'); + }); + + it('returns event.key for Escape', () => { + expect(physicalKey(makeEvent('Escape', 'Escape'))).toBe('Escape'); + }); + + it('returns event.key for Arrow keys', () => { + expect(physicalKey(makeEvent('ArrowUp', 'ArrowUp'))).toBe('ArrowUp'); + expect(physicalKey(makeEvent('ArrowDown', 'ArrowDown'))).toBe('ArrowDown'); + }); + }); + }); });