feat: enhance file icon handling and keyboard shortcuts
- Introduced a new FileIcon component to render file-type icons with support for Devicon CDN and fallback to lucide-react icons. - Added a glow effect for file icons in dark mode to improve visibility. - Updated various components to utilize the new FileIcon component for consistent icon rendering. - Refined keyboard shortcut handling to use event.code for layout-independent key detection, improving cross-platform compatibility. - Enhanced the EditorTabBar with a context menu for tab management, including options to close tabs and manage open files more efficiently.
This commit is contained in:
parent
4c8b50f3dd
commit
cb8017b0db
25 changed files with 840 additions and 229 deletions
|
|
@ -53,7 +53,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
|
||||||
// Handle Cmd+K to open full command palette
|
// Handle Cmd+K to open full command palette
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openCommandPalette();
|
openCommandPalette();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
|
if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setGlobalSearchEnabled((prev) => !prev);
|
setGlobalSearchEnabled((prev) => !prev);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
Code,
|
||||||
Columns3,
|
Columns3,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
|
@ -805,13 +806,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<span className="max-w-60 truncate font-mono">
|
<span className="max-w-60 truncate font-mono">
|
||||||
{formatProjectPath(data.config.projectPath)}
|
{formatProjectPath(data.config.projectPath)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Tooltip>
|
||||||
onClick={() => setEditorOpen(true)}
|
<TooltipTrigger asChild>
|
||||||
className="ml-1 rounded px-1 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
<button
|
||||||
title="Open in Editor"
|
onClick={() => setEditorOpen(true)}
|
||||||
>
|
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||||
<Pencil size={10} />
|
>
|
||||||
</button>
|
<Code size={10} className="shrink-0" /> Edit code
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Open project in built-in editor</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{leadBranch && (
|
{leadBranch && (
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useMemo } from 'react';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { getFileIcon } from './fileIcons';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component
|
// Component
|
||||||
|
|
@ -33,8 +33,6 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
||||||
if (segments.length === 0) return null;
|
if (segments.length === 0) return null;
|
||||||
|
|
||||||
const fileName = segments[segments.length - 1];
|
const fileName = segments[segments.length - 1];
|
||||||
const iconInfo = getFileIcon(fileName);
|
|
||||||
const Icon = iconInfo.icon;
|
|
||||||
|
|
||||||
const handleSegmentClick = (segmentIndex: number): void => {
|
const handleSegmentClick = (segmentIndex: number): void => {
|
||||||
if (!projectPath) return;
|
if (!projectPath) return;
|
||||||
|
|
@ -53,7 +51,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
||||||
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
|
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<span className="flex items-center gap-1 text-text-secondary">
|
<span className="flex items-center gap-1 text-text-secondary">
|
||||||
<Icon className="size-3" style={{ color: iconInfo.color }} />
|
<FileIcon fileName={fileName} className="size-3" />
|
||||||
{segment}
|
{segment}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
||||||
|
|
||||||
import { EditorContextMenu } from './EditorContextMenu';
|
import { EditorContextMenu } from './EditorContextMenu';
|
||||||
import { getFileIcon } from './fileIcons';
|
import { FileIcon } from './FileIcon';
|
||||||
import { GitStatusBadge } from './GitStatusBadge';
|
import { GitStatusBadge } from './GitStatusBadge';
|
||||||
import { NewFileDialog } from './NewFileDialog';
|
import { NewFileDialog } from './NewFileDialog';
|
||||||
|
|
||||||
|
|
@ -121,9 +121,25 @@ export const EditorFileTree = ({
|
||||||
return map;
|
return map;
|
||||||
}, [flatItems]);
|
}, [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
|
// Virtual scrolling — increase overscan during drag for more drop targets
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: flatItems.length,
|
count: flatItems.length + (newItemInsert ? 1 : 0),
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => ITEM_HEIGHT,
|
estimateSize: () => ITEM_HEIGHT,
|
||||||
overscan: draggedItem ? 20 : 10,
|
overscan: draggedItem ? 20 : 10,
|
||||||
|
|
@ -163,14 +179,26 @@ export const EditorFileTree = ({
|
||||||
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
|
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Context menu handlers
|
// Context menu handlers — expand parent directory so the input appears inline
|
||||||
const handleNewFile = useCallback((parentDir: string) => {
|
const handleNewFile = useCallback(
|
||||||
setNewItemState({ parentDir, type: 'file' });
|
(parentDir: string) => {
|
||||||
}, []);
|
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
||||||
|
void expandDirectory(parentDir);
|
||||||
|
}
|
||||||
|
setNewItemState({ parentDir, type: 'file' });
|
||||||
|
},
|
||||||
|
[projectPath, expandedDirs, expandDirectory]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNewFolder = useCallback((parentDir: string) => {
|
const handleNewFolder = useCallback(
|
||||||
setNewItemState({ parentDir, type: 'directory' });
|
(parentDir: string) => {
|
||||||
}, []);
|
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
||||||
|
void expandDirectory(parentDir);
|
||||||
|
}
|
||||||
|
setNewItemState({ parentDir, type: 'directory' });
|
||||||
|
},
|
||||||
|
[projectPath, expandedDirs, expandDirectory]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
|
|
@ -357,7 +385,36 @@ export const EditorFileTree = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{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 (
|
||||||
|
<div
|
||||||
|
key="__new-item-input__"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${virtualItem.start}px`,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualItem.size}px`,
|
||||||
|
paddingLeft: `${Math.min(newItemInsert.depth, MAX_DEPTH) * INDENT_PX}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NewFileDialog
|
||||||
|
type={newItemState!.type}
|
||||||
|
parentDir={newItemState!.parentDir}
|
||||||
|
onSubmit={handleNewItemSubmit}
|
||||||
|
onCancel={handleNewItemCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust index for items after the insertion point
|
||||||
|
const flatIdx = newItemInsert && index > newItemInsert.index ? index - 1 : index;
|
||||||
|
const item = flatItems[flatIdx];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableTreeItem
|
<DraggableTreeItem
|
||||||
key={item.node.fullPath}
|
key={item.node.fullPath}
|
||||||
|
|
@ -387,14 +444,6 @@ export const EditorFileTree = ({
|
||||||
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
{newItemState && (
|
|
||||||
<NewFileDialog
|
|
||||||
type={newItemState.type}
|
|
||||||
parentDir={newItemState.parentDir}
|
|
||||||
onSubmit={handleNewItemSubmit}
|
|
||||||
onCancel={handleNewItemCancel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EditorContextMenu>
|
</EditorContextMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -521,9 +570,7 @@ const DraggableTreeItem = React.memo(
|
||||||
if (node.data?.isSensitive) {
|
if (node.data?.isSensitive) {
|
||||||
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
|
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
|
||||||
} else if (node.isFile) {
|
} else if (node.isFile) {
|
||||||
const fileIcon = getFileIcon(node.name);
|
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
||||||
const FileIcon = fileIcon.icon;
|
|
||||||
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
|
|
||||||
} else if (isExpanded) {
|
} else if (isExpanded) {
|
||||||
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
|
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -583,9 +630,7 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
|
||||||
|
|
||||||
let icon: React.ReactNode;
|
let icon: React.ReactNode;
|
||||||
if (node.isFile) {
|
if (node.isFile) {
|
||||||
const fileIcon = getFileIcon(node.name);
|
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
||||||
const FileIcon = fileIcon.icon;
|
|
||||||
icon = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
|
|
||||||
} else {
|
} else {
|
||||||
icon = <FolderOpen className="size-3.5 text-text-muted" />;
|
icon = <FolderOpen className="size-3.5 text-text-muted" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Tab bar for the project editor.
|
* 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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { getFileIcon } from './fileIcons';
|
import { EditorTabContextMenu } from './EditorTabContextMenu';
|
||||||
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
import type { EditorFileTab } from '@shared/types/editor';
|
import type { EditorFileTab } from '@shared/types/editor';
|
||||||
|
|
||||||
|
|
@ -31,6 +33,10 @@ export const EditorTabBar = ({
|
||||||
const activeTabId = useStore((s) => s.editorActiveTabId);
|
const activeTabId = useStore((s) => s.editorActiveTabId);
|
||||||
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
|
||||||
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
|
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;
|
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"
|
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab, index) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
tabIndex={index}
|
||||||
|
totalTabs={tabs.length}
|
||||||
isActive={tab.id === activeTabId}
|
isActive={tab.id === activeTabId}
|
||||||
isModified={!!modifiedFiles[tab.filePath]}
|
isModified={!!modifiedFiles[tab.filePath]}
|
||||||
onActivate={() => setActiveEditorTab(tab.id)}
|
onActivate={() => setActiveEditorTab(tab.id)}
|
||||||
onClose={() => onRequestCloseTab(tab.id)}
|
onRequestClose={onRequestCloseTab}
|
||||||
|
onCloseOthers={closeOtherEditorTabs}
|
||||||
|
onCloseToLeft={closeEditorTabsToLeft}
|
||||||
|
onCloseToRight={closeEditorTabsToRight}
|
||||||
|
onCloseAll={closeAllEditorTabs}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,67 +71,93 @@ export const EditorTabBar = ({
|
||||||
|
|
||||||
interface TabProps {
|
interface TabProps {
|
||||||
tab: EditorFileTab;
|
tab: EditorFileTab;
|
||||||
|
tabIndex: number;
|
||||||
|
totalTabs: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isModified: boolean;
|
isModified: boolean;
|
||||||
onActivate: () => void;
|
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) => {
|
const handleClose = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClose();
|
onRequestClose(tab.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuxClick = (e: React.MouseEvent) => {
|
const handleAuxClick = (e: React.MouseEvent) => {
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onRequestClose(tab.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconInfo = getFileIcon(tab.fileName);
|
|
||||||
const FileIcon = iconInfo.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<EditorTabContextMenu
|
||||||
<TooltipTrigger asChild>
|
tabId={tab.id}
|
||||||
<button
|
tabIndex={tabIndex}
|
||||||
onClick={onActivate}
|
totalTabs={totalTabs}
|
||||||
onAuxClick={handleAuxClick}
|
onClose={onRequestClose}
|
||||||
role="tab"
|
onCloseOthers={onCloseOthers}
|
||||||
aria-selected={isActive}
|
onCloseToLeft={onCloseToLeft}
|
||||||
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
onCloseToRight={onCloseToRight}
|
||||||
isActive
|
onCloseAll={onCloseAll}
|
||||||
? 'bg-surface text-text'
|
>
|
||||||
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
<Tooltip>
|
||||||
}`}
|
<TooltipTrigger asChild>
|
||||||
>
|
<button
|
||||||
{isModified && (
|
onClick={onActivate}
|
||||||
<span
|
onAuxClick={handleAuxClick}
|
||||||
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
role="tab"
|
||||||
aria-label="Unsaved changes"
|
aria-selected={isActive}
|
||||||
/>
|
className={`group flex h-full shrink-0 items-center gap-1.5 border-r border-border px-3 text-xs transition-colors ${
|
||||||
)}
|
isActive
|
||||||
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
? 'bg-surface text-text'
|
||||||
<span className="max-w-40 truncate">
|
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
|
||||||
{tab.fileName}
|
}`}
|
||||||
{tab.disambiguatedLabel && (
|
|
||||||
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
onClick={handleClose}
|
|
||||||
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
|
||||||
role="button"
|
|
||||||
aria-label={`Close ${tab.fileName}`}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
{isModified && (
|
||||||
</span>
|
<span
|
||||||
</button>
|
className="size-1.5 shrink-0 rounded-full bg-amber-400"
|
||||||
</TooltipTrigger>
|
aria-label="Unsaved changes"
|
||||||
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
/>
|
||||||
</Tooltip>
|
)}
|
||||||
|
<FileIcon fileName={tab.fileName} className="size-3.5" />
|
||||||
|
<span className="max-w-40 truncate">
|
||||||
|
{tab.fileName}
|
||||||
|
{tab.disambiguatedLabel && (
|
||||||
|
<span className="ml-1 text-text-muted">{tab.disambiguatedLabel}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={handleClose}
|
||||||
|
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-surface-raised group-hover:opacity-100"
|
||||||
|
role="button"
|
||||||
|
aria-label={`Close ${tab.fileName}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</EditorTabContextMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
88
src/renderer/components/team/editor/EditorTabContextMenu.tsx
Normal file
88
src/renderer/components/team/editor/EditorTabContextMenu.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger asChild>
|
||||||
|
<div className="flex h-full">{children}</div>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
|
||||||
|
<ContextMenu.Portal>
|
||||||
|
<ContextMenu.Content className="z-50 min-w-[180px] rounded-md border border-border-emphasis bg-surface-overlay p-1 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95">
|
||||||
|
<ContextMenu.Item
|
||||||
|
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||||
|
onSelect={() => onClose(tabId)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</ContextMenu.Item>
|
||||||
|
|
||||||
|
<ContextMenu.Item
|
||||||
|
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!hasOthers}
|
||||||
|
onSelect={() => onCloseOthers(tabId)}
|
||||||
|
>
|
||||||
|
Close Others
|
||||||
|
</ContextMenu.Item>
|
||||||
|
|
||||||
|
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
<ContextMenu.Item
|
||||||
|
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!hasLeft}
|
||||||
|
onSelect={() => onCloseToLeft(tabId)}
|
||||||
|
>
|
||||||
|
Close Tabs to the Left
|
||||||
|
</ContextMenu.Item>
|
||||||
|
|
||||||
|
<ContextMenu.Item
|
||||||
|
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!hasRight}
|
||||||
|
onSelect={() => onCloseToRight(tabId)}
|
||||||
|
>
|
||||||
|
Close Tabs to the Right
|
||||||
|
</ContextMenu.Item>
|
||||||
|
|
||||||
|
<ContextMenu.Separator className="my-1 h-px bg-border" />
|
||||||
|
|
||||||
|
<ContextMenu.Item
|
||||||
|
className="flex cursor-pointer items-center rounded px-2 py-1.5 text-xs text-text outline-none hover:bg-surface-raised focus:bg-surface-raised"
|
||||||
|
onSelect={onCloseAll}
|
||||||
|
>
|
||||||
|
Close All
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Portal>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
66
src/renderer/components/team/editor/FileIcon.tsx
Normal file
66
src/renderer/components/team/editor/FileIcon.tsx
Normal file
|
|
@ -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<string>();
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 (
|
||||||
|
<img
|
||||||
|
src={getDeviconUrl(slug)}
|
||||||
|
className={cn('file-icon-glow shrink-0', className)}
|
||||||
|
onError={handleError}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to lucide icon
|
||||||
|
const Icon = info.icon;
|
||||||
|
return <Icon className={cn('shrink-0', className)} style={{ color: info.color }} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
FileIcon.displayName = 'FileIcon';
|
||||||
|
|
@ -49,9 +49,16 @@ export const NewFileDialog = ({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Track whether focus has been established (prevents premature blur cancel)
|
||||||
|
const focusedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-focus on mount
|
// Defer focus to next frame — ensures Radix context menu has fully closed
|
||||||
inputRef.current?.focus();
|
const raf = requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
focusedRef.current = true;
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
|
|
@ -82,6 +89,13 @@ export const NewFileDialog = ({
|
||||||
setError(null);
|
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;
|
const Icon = type === 'file' ? FilePlus : FolderPlus;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -94,7 +108,7 @@ export const NewFileDialog = ({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={onCancel}
|
onBlur={handleBlur}
|
||||||
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
|
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"
|
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'}
|
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { Command } from 'cmdk';
|
import { Command } from 'cmdk';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { getFileIcon } from './fileIcons';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
import type { QuickOpenFile } from '@shared/types/editor';
|
import type { QuickOpenFile } from '@shared/types/editor';
|
||||||
|
|
||||||
|
|
@ -97,15 +97,7 @@ export const QuickOpenDialog = ({
|
||||||
[allFiles, onSelectFile, onClose]
|
[allFiles, onSelectFile, onClose]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize file icon lookups
|
const fileItems = allFiles;
|
||||||
const fileItems = useMemo(
|
|
||||||
() =>
|
|
||||||
allFiles.map((file) => ({
|
|
||||||
...file,
|
|
||||||
iconInfo: getFileIcon(file.name),
|
|
||||||
})),
|
|
||||||
[allFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
|
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
|
||||||
|
|
@ -146,7 +138,6 @@ export const QuickOpenDialog = ({
|
||||||
</Command.Empty>
|
</Command.Empty>
|
||||||
)}
|
)}
|
||||||
{fileItems.map((file) => {
|
{fileItems.map((file) => {
|
||||||
const Icon = file.iconInfo.icon;
|
|
||||||
return (
|
return (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={file.path}
|
key={file.path}
|
||||||
|
|
@ -154,7 +145,7 @@ export const QuickOpenDialog = ({
|
||||||
onSelect={() => handleSelect(file.relativePath)}
|
onSelect={() => 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"
|
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"
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" style={{ color: file.iconInfo.color }} />
|
<FileIcon fileName={file.name} className="size-4" />
|
||||||
<span className="truncate font-medium">{file.name}</span>
|
<span className="truncate font-medium">{file.name}</span>
|
||||||
<span className="ml-auto truncate text-xs text-text-muted">
|
<span className="ml-auto truncate text-xs text-text-muted">
|
||||||
{file.relativePath}
|
{file.relativePath}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { api } from '@renderer/api';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { Loader2, Search, X } from 'lucide-react';
|
import { Loader2, Search, X } from 'lucide-react';
|
||||||
|
|
||||||
import { getFileIcon } from './fileIcons';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
||||||
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
|
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
|
||||||
|
|
||||||
|
|
@ -259,9 +259,6 @@ const SearchFileGroup = ({
|
||||||
const dirPath = relativePath.includes('/')
|
const dirPath = relativePath.includes('/')
|
||||||
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
||||||
: '';
|
: '';
|
||||||
const iconInfo = getFileIcon(fileName);
|
|
||||||
const Icon = iconInfo.icon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/50 border-b">
|
<div className="border-border/50 border-b">
|
||||||
<button
|
<button
|
||||||
|
|
@ -269,7 +266,7 @@ const SearchFileGroup = ({
|
||||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
|
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-surface-raised"
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
|
<span className="text-[10px] text-text-muted">{expanded ? '▼' : '▶'}</span>
|
||||||
<Icon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
|
<FileIcon fileName={fileName} className="size-3.5" />
|
||||||
<span className="truncate text-xs font-medium text-text">{fileName}</span>
|
<span className="truncate text-xs font-medium text-text">{fileName}</span>
|
||||||
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
|
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
|
||||||
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
|
<span className="ml-auto shrink-0 text-[10px] text-text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* File icon mapping — maps file extensions to lucide-react icon names and colors.
|
* File icon mapping — maps file extensions/names to icon info.
|
||||||
|
*
|
||||||
|
* For programming languages and dev tools, uses Devicon CDN SVGs (colorful logos).
|
||||||
|
* For generic file types (images, fonts, configs), falls back to lucide-react icons.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,7 +10,6 @@ import {
|
||||||
Code,
|
Code,
|
||||||
Database,
|
Database,
|
||||||
File,
|
File,
|
||||||
FileCode,
|
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
FileType,
|
FileType,
|
||||||
|
|
@ -26,6 +28,22 @@ import type { LucideIcon } from 'lucide-react';
|
||||||
export interface FileIconInfo {
|
export interface FileIconInfo {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Devicon slug — when set, FileIcon component renders the real logo from CDN */
|
||||||
|
deviconSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Devicon CDN
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const DEVICON_BASE = 'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Devicon CDN URL for a given slug.
|
||||||
|
* Uses `-original` variant (colorful) with `-wordmark` fallback.
|
||||||
|
*/
|
||||||
|
export function getDeviconUrl(slug: string): string {
|
||||||
|
return `${DEVICON_BASE}/${slug}/${slug}-original.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -34,74 +52,75 @@ export interface FileIconInfo {
|
||||||
|
|
||||||
const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
||||||
// TypeScript / JavaScript
|
// TypeScript / JavaScript
|
||||||
ts: { icon: FileCode, color: '#3178c6' },
|
ts: { icon: Code, color: '#3178c6', deviconSlug: 'typescript' },
|
||||||
tsx: { icon: FileCode, color: '#3178c6' },
|
tsx: { icon: Code, color: '#3178c6', deviconSlug: 'react' },
|
||||||
js: { icon: FileCode, color: '#f7df1e' },
|
js: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||||
jsx: { icon: FileCode, color: '#61dafb' },
|
jsx: { icon: Code, color: '#61dafb', deviconSlug: 'react' },
|
||||||
mjs: { icon: FileCode, color: '#f7df1e' },
|
mjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||||
cjs: { icon: FileCode, color: '#f7df1e' },
|
cjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
|
||||||
|
|
||||||
// Web
|
// Web
|
||||||
html: { icon: Code, color: '#e34c26' },
|
html: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
|
||||||
htm: { icon: Code, color: '#e34c26' },
|
htm: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
|
||||||
css: { icon: FileCode, color: '#563d7c' },
|
css: { icon: Code, color: '#1572b6', deviconSlug: 'css3' },
|
||||||
scss: { icon: FileCode, color: '#c6538c' },
|
scss: { icon: Code, color: '#c6538c', deviconSlug: 'sass' },
|
||||||
less: { icon: FileCode, color: '#1d365d' },
|
less: { icon: Code, color: '#1d365d', deviconSlug: 'less' },
|
||||||
vue: { icon: FileCode, color: '#42b883' },
|
vue: { icon: Code, color: '#42b883', deviconSlug: 'vuejs' },
|
||||||
svelte: { icon: FileCode, color: '#ff3e00' },
|
svelte: { icon: Code, color: '#ff3e00', deviconSlug: 'svelte' },
|
||||||
|
|
||||||
// Data / Config
|
// Data / Config (no devicon — lucide fallbacks)
|
||||||
json: { icon: FileJson, color: '#cbcb41' },
|
json: { icon: FileJson, color: '#cbcb41', deviconSlug: 'json' },
|
||||||
jsonl: { icon: FileJson, color: '#cbcb41' },
|
jsonl: { icon: FileJson, color: '#cbcb41' },
|
||||||
yaml: { icon: Settings, color: '#cb171e' },
|
yaml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
|
||||||
yml: { icon: Settings, color: '#cb171e' },
|
yml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
|
||||||
toml: { icon: Settings, color: '#9c4121' },
|
toml: { icon: Settings, color: '#9c4121' },
|
||||||
xml: { icon: Code, color: '#e37933' },
|
xml: { icon: Code, color: '#e37933', deviconSlug: 'xml' },
|
||||||
csv: { icon: Database, color: '#4caf50' },
|
csv: { icon: Database, color: '#4caf50' },
|
||||||
|
|
||||||
// Markdown / Text
|
// Markdown / Text
|
||||||
md: { icon: FileText, color: '#519aba' },
|
md: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
|
||||||
mdx: { icon: FileText, color: '#519aba' },
|
mdx: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
|
||||||
txt: { icon: FileText, color: '#89949f' },
|
txt: { icon: FileText, color: '#89949f' },
|
||||||
rst: { icon: FileText, color: '#89949f' },
|
rst: { icon: FileText, color: '#89949f' },
|
||||||
|
|
||||||
// Python
|
// Python
|
||||||
py: { icon: FileCode, color: '#3572a5' },
|
py: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||||
pyx: { icon: FileCode, color: '#3572a5' },
|
pyx: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||||
pyi: { icon: FileCode, color: '#3572a5' },
|
pyi: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
|
||||||
|
|
||||||
// Rust
|
// Rust
|
||||||
rs: { icon: FileCode, color: '#dea584' },
|
rs: { icon: Code, color: '#dea584', deviconSlug: 'rust' },
|
||||||
|
|
||||||
// Go
|
// Go
|
||||||
go: { icon: FileCode, color: '#00add8' },
|
go: { icon: Code, color: '#00add8', deviconSlug: 'go' },
|
||||||
|
|
||||||
// Ruby
|
// Ruby
|
||||||
rb: { icon: FileCode, color: '#cc342d' },
|
rb: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||||
gemspec: { icon: FileCode, color: '#cc342d' },
|
gemspec: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||||
|
|
||||||
// Java / Kotlin
|
// Java / Kotlin
|
||||||
java: { icon: FileCode, color: '#b07219' },
|
java: { icon: Code, color: '#b07219', deviconSlug: 'java' },
|
||||||
kt: { icon: FileCode, color: '#a97bff' },
|
kt: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
|
||||||
kts: { icon: FileCode, color: '#a97bff' },
|
kts: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
|
||||||
|
|
||||||
// C / C++
|
// C / C++ / C#
|
||||||
c: { icon: FileCode, color: '#555555' },
|
c: { icon: Code, color: '#555555', deviconSlug: 'c' },
|
||||||
h: { icon: FileCode, color: '#555555' },
|
h: { icon: Code, color: '#555555', deviconSlug: 'c' },
|
||||||
cpp: { icon: FileCode, color: '#f34b7d' },
|
cpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||||
hpp: { icon: FileCode, color: '#f34b7d' },
|
hpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||||
cc: { icon: FileCode, color: '#f34b7d' },
|
cc: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
|
||||||
|
cs: { icon: Code, color: '#178600', deviconSlug: 'csharp' },
|
||||||
|
|
||||||
// Shell
|
// Shell
|
||||||
sh: { icon: Terminal, color: '#89e051' },
|
sh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||||
bash: { icon: Terminal, color: '#89e051' },
|
bash: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||||
zsh: { icon: Terminal, color: '#89e051' },
|
zsh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
|
||||||
fish: { icon: Terminal, color: '#89e051' },
|
fish: { icon: Terminal, color: '#89e051' },
|
||||||
|
|
||||||
// SQL
|
// SQL
|
||||||
sql: { icon: Database, color: '#e38c00' },
|
sql: { icon: Database, color: '#e38c00', deviconSlug: 'azuresqldatabase' },
|
||||||
|
|
||||||
// Images
|
// Images (no devicon — lucide Image icon)
|
||||||
png: { icon: Image, color: '#a074c4' },
|
png: { icon: Image, color: '#a074c4' },
|
||||||
jpg: { icon: Image, color: '#a074c4' },
|
jpg: { icon: Image, color: '#a074c4' },
|
||||||
jpeg: { icon: Image, color: '#a074c4' },
|
jpeg: { icon: Image, color: '#a074c4' },
|
||||||
|
|
@ -110,46 +129,76 @@ const EXTENSION_MAP: Record<string, FileIconInfo> = {
|
||||||
ico: { icon: Image, color: '#a074c4' },
|
ico: { icon: Image, color: '#a074c4' },
|
||||||
webp: { icon: Image, color: '#a074c4' },
|
webp: { icon: Image, color: '#a074c4' },
|
||||||
|
|
||||||
// Fonts
|
// Fonts (no devicon — lucide FileType icon)
|
||||||
woff: { icon: FileType, color: '#89949f' },
|
woff: { icon: FileType, color: '#89949f' },
|
||||||
woff2: { icon: FileType, color: '#89949f' },
|
woff2: { icon: FileType, color: '#89949f' },
|
||||||
ttf: { icon: FileType, color: '#89949f' },
|
ttf: { icon: FileType, color: '#89949f' },
|
||||||
otf: { icon: FileType, color: '#89949f' },
|
otf: { icon: FileType, color: '#89949f' },
|
||||||
|
|
||||||
// Config files
|
// Config files (no devicon — lucide icons)
|
||||||
env: { icon: Lock, color: '#e5a00d' },
|
env: { icon: Lock, color: '#e5a00d' },
|
||||||
ini: { icon: Settings, color: '#89949f' },
|
ini: { icon: Settings, color: '#89949f' },
|
||||||
conf: { icon: Settings, color: '#89949f' },
|
conf: { icon: Settings, color: '#89949f' },
|
||||||
cfg: { icon: Settings, color: '#89949f' },
|
cfg: { icon: Settings, color: '#89949f' },
|
||||||
|
|
||||||
// Other
|
// Other languages
|
||||||
graphql: { icon: Braces, color: '#e535ab' },
|
graphql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
|
||||||
gql: { icon: Braces, color: '#e535ab' },
|
gql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
|
||||||
proto: { icon: Code, color: '#89949f' },
|
proto: { icon: Code, color: '#89949f', deviconSlug: 'protobuf' },
|
||||||
dart: { icon: FileCode, color: '#00b4ab' },
|
dart: { icon: Code, color: '#00b4ab', deviconSlug: 'dart' },
|
||||||
swift: { icon: FileCode, color: '#f05138' },
|
swift: { icon: Code, color: '#f05138', deviconSlug: 'swift' },
|
||||||
php: { icon: FileCode, color: '#4f5d95' },
|
php: { icon: Code, color: '#4f5d95', deviconSlug: 'php' },
|
||||||
|
r: { icon: Code, color: '#276dc3', deviconSlug: 'r' },
|
||||||
|
lua: { icon: Code, color: '#000080', deviconSlug: 'lua' },
|
||||||
|
pl: { icon: Code, color: '#39457e', deviconSlug: 'perl' },
|
||||||
|
scala: { icon: Code, color: '#dc322f', deviconSlug: 'scala' },
|
||||||
|
groovy: { icon: Code, color: '#4298b8', deviconSlug: 'groovy' },
|
||||||
|
ex: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
|
||||||
|
exs: { icon: Code, color: '#6e4a7e', deviconSlug: 'elixir' },
|
||||||
|
erl: { icon: Code, color: '#b83998', deviconSlug: 'erlang' },
|
||||||
|
hs: { icon: Code, color: '#5e5086', deviconSlug: 'haskell' },
|
||||||
|
clj: { icon: Code, color: '#db5855', deviconSlug: 'clojure' },
|
||||||
|
fs: { icon: Code, color: '#b845fc', deviconSlug: 'fsharp' },
|
||||||
|
zig: { icon: Code, color: '#f7a41d', deviconSlug: 'zig' },
|
||||||
|
nim: { icon: Code, color: '#ffc200', deviconSlug: 'nimble' },
|
||||||
|
tf: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
|
||||||
|
hcl: { icon: Code, color: '#7b42bc', deviconSlug: 'terraform' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Special full filename mapping
|
// Special full filename mapping
|
||||||
const FILENAME_MAP: Record<string, FileIconInfo> = {
|
const FILENAME_MAP: Record<string, FileIconInfo> = {
|
||||||
Dockerfile: { icon: FileCode, color: '#2496ed' },
|
Dockerfile: { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||||
'docker-compose.yml': { icon: FileCode, color: '#2496ed' },
|
'docker-compose.yml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||||
'docker-compose.yaml': { icon: FileCode, color: '#2496ed' },
|
'docker-compose.yaml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
|
||||||
Makefile: { icon: Terminal, color: '#427819' },
|
Makefile: { icon: Terminal, color: '#427819' },
|
||||||
Rakefile: { icon: Terminal, color: '#cc342d' },
|
Rakefile: { icon: Terminal, color: '#cc342d', deviconSlug: 'ruby' },
|
||||||
Gemfile: { icon: FileCode, color: '#cc342d' },
|
Gemfile: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
|
||||||
'.gitignore': { icon: Settings, color: '#f05032' },
|
'.gitignore': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
|
||||||
'.gitattributes': { icon: Settings, color: '#f05032' },
|
'.gitattributes': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
|
||||||
'.eslintrc': { icon: Settings, color: '#4b32c3' },
|
'.eslintrc': { icon: Settings, color: '#4b32c3', deviconSlug: 'eslint' },
|
||||||
'.prettierrc': { icon: Settings, color: '#56b3b4' },
|
'.prettierrc': { icon: Settings, color: '#56b3b4' },
|
||||||
'tsconfig.json': { icon: Settings, color: '#3178c6' },
|
'tsconfig.json': { icon: Settings, color: '#3178c6', deviconSlug: 'typescript' },
|
||||||
'package.json': { icon: FileJson, color: '#cb3837' },
|
'package.json': { icon: FileJson, color: '#cb3837', deviconSlug: 'nodejs' },
|
||||||
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
|
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
|
||||||
'package-lock.json': { icon: Lock, color: '#cb3837' },
|
'package-lock.json': { icon: Lock, color: '#cb3837', deviconSlug: 'npm' },
|
||||||
'yarn.lock': { icon: Lock, color: '#2c8ebb' },
|
'yarn.lock': { icon: Lock, color: '#2c8ebb', deviconSlug: 'yarn' },
|
||||||
LICENSE: { icon: FileText, color: '#d9b611' },
|
LICENSE: { icon: FileText, color: '#d9b611' },
|
||||||
'CLAUDE.md': { icon: FileText, color: '#d97706' },
|
'CLAUDE.md': { icon: FileText, color: '#d97706' },
|
||||||
|
'Cargo.toml': { icon: Settings, color: '#dea584', deviconSlug: 'rust' },
|
||||||
|
'go.mod': { icon: Settings, color: '#00add8', deviconSlug: 'go' },
|
||||||
|
'go.sum': { icon: Lock, color: '#00add8', deviconSlug: 'go' },
|
||||||
|
'.dockerignore': { icon: Settings, color: '#2496ed', deviconSlug: 'docker' },
|
||||||
|
'vite.config.ts': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
|
||||||
|
'vite.config.js': { icon: Settings, color: '#646cff', deviconSlug: 'vitejs' },
|
||||||
|
'webpack.config.js': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
|
||||||
|
'webpack.config.ts': { icon: Settings, color: '#8dd6f9', deviconSlug: 'webpack' },
|
||||||
|
'.babelrc': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
|
||||||
|
'babel.config.js': { icon: Settings, color: '#f5da55', deviconSlug: 'babel' },
|
||||||
|
'tailwind.config.js': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
|
||||||
|
'tailwind.config.ts': { icon: Settings, color: '#06b6d4', deviconSlug: 'tailwindcss' },
|
||||||
|
'next.config.js': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
|
||||||
|
'next.config.mjs': { icon: Settings, color: '#000000', deviconSlug: 'nextjs' },
|
||||||
|
'nuxt.config.ts': { icon: Settings, color: '#00dc82', deviconSlug: 'nuxtjs' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' };
|
const DEFAULT_ICON: FileIconInfo = { icon: File, color: '#89949f' };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -76,6 +77,41 @@ const DependencyBadge = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TruncatedTitle = ({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}): React.JSX.Element => {
|
||||||
|
const ref = useRef<HTMLHeadingElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
const checkTruncation = useCallback(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el) {
|
||||||
|
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip open={isTruncated ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={`truncate text-sm font-medium text-[var(--color-text)] ${className ?? ''}`}
|
||||||
|
onMouseEnter={checkTruncation}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</h5>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="start">
|
||||||
|
{text}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CancelTaskButton = ({
|
const CancelTaskButton = ({
|
||||||
taskId,
|
taskId,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
|
@ -228,11 +264,7 @@ export const KanbanTaskCard = ({
|
||||||
#{task.id}
|
#{task.id}
|
||||||
</Badge>
|
</Badge>
|
||||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||||
{!compact && (
|
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
|
||||||
{task.subject}
|
|
||||||
</h5>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{task.needsClarification ? (
|
{task.needsClarification ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -246,11 +278,7 @@ export const KanbanTaskCard = ({
|
||||||
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{compact && (
|
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
|
||||||
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
|
|
||||||
{task.subject}
|
|
||||||
</h5>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasBlockedBy ? (
|
{hasBlockedBy ? (
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,7 @@ export const ChangeReviewDialog = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handler = (e: KeyboardEvent): void => {
|
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
|
// Don't intercept if focus is inside a CM editor — let CM handle its own undo
|
||||||
if (document.activeElement?.closest('.cm-editor')) return;
|
if (document.activeElement?.closest('.cm-editor')) return;
|
||||||
// Don't intercept native undo in input/textarea
|
// Don't intercept native undo in input/textarea
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -11,7 +12,6 @@ import {
|
||||||
Circle,
|
Circle,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
X as XIcon,
|
X as XIcon,
|
||||||
|
|
@ -139,7 +139,7 @@ const TreeItem = ({
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
>
|
>
|
||||||
<FileStatusIcon status={status} />
|
<FileStatusIcon status={status} />
|
||||||
<File className="size-3.5 shrink-0" />
|
<FileIcon fileName={node.name} className="size-3.5" />
|
||||||
{viewedSet && viewedSet.has(node.data.filePath) && (
|
{viewedSet && viewedSet.has(node.data.filePath) && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export const EmbeddedTerminal = ({
|
||||||
|
|
||||||
// Ctrl+C with selection → copy to clipboard (instead of sending SIGINT)
|
// Ctrl+C with selection → copy to clipboard (instead of sending SIGINT)
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
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();
|
const selection = term.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
void navigator.clipboard.writeText(selection);
|
void navigator.clipboard.writeText(selection);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||||
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
|
||||||
|
import { physicalKey } from '@renderer/utils/keyboardUtils';
|
||||||
|
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
@ -276,44 +277,46 @@ export function useDiffNavigation(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMeta = event.metaKey || event.ctrlKey;
|
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)
|
// Alt+J -> next hunk (cross-file in continuous mode)
|
||||||
if (event.altKey && event.key.toLowerCase() === 'j') {
|
if (event.altKey && key === 'j') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToNextHunk();
|
goToNextHunk();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+K -> prev hunk (cross-file in continuous mode)
|
// Alt+K -> prev hunk (cross-file in continuous mode)
|
||||||
if (event.altKey && event.key.toLowerCase() === 'k') {
|
if (event.altKey && key === 'k') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToPrevHunk();
|
goToPrevHunk();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+ArrowDown -> next file
|
// Alt+ArrowDown -> next file
|
||||||
if (event.altKey && event.key === 'ArrowDown') {
|
if (event.altKey && key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToNextFile();
|
goToNextFile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+ArrowUp -> prev file
|
// Alt+ArrowUp -> prev file
|
||||||
if (event.altKey && event.key === 'ArrowUp') {
|
if (event.altKey && key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goToPrevFile();
|
goToPrevFile();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Enter -> save file
|
// Cmd+Enter -> save file
|
||||||
if (isMeta && event.key === 'Enter') {
|
if (isMeta && key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSaveFileRef.current?.();
|
onSaveFileRef.current?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Y -> accept chunk + next (cross-file aware)
|
// Cmd+Y -> accept chunk + next (cross-file aware)
|
||||||
if (isMeta && event.key.toLowerCase() === 'y') {
|
if (isMeta && key === 'y') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
|
||||||
if (view) {
|
if (view) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { useCallback, useEffect } from 'react';
|
||||||
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
import { gotoLine, openSearchPanel } from '@codemirror/search';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||||
|
import { physicalKey } from '@renderer/utils/keyboardUtils';
|
||||||
|
|
||||||
import type { EditorFileTab } from '@shared/types/editor';
|
import type { EditorFileTab } from '@shared/types/editor';
|
||||||
|
|
||||||
|
|
@ -52,8 +53,11 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
const isMod = e.metaKey || e.ctrlKey;
|
const isMod = e.metaKey || e.ctrlKey;
|
||||||
if (!isMod) return;
|
if (!isMod) return;
|
||||||
|
|
||||||
|
// Layout-independent key (uses event.code for letters/symbols)
|
||||||
|
const key = physicalKey(e);
|
||||||
|
|
||||||
// Cmd+P: Quick Open
|
// Cmd+P: Quick Open
|
||||||
if (e.key === 'p' && !e.shiftKey) {
|
if (key === 'p' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deps.onToggleQuickOpen();
|
deps.onToggleQuickOpen();
|
||||||
|
|
@ -61,7 +65,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+F: Search in files
|
// Cmd+Shift+F: Search in files
|
||||||
if (e.key === 'f' && e.shiftKey) {
|
if (key === 'f' && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deps.onToggleSearchPanel();
|
deps.onToggleSearchPanel();
|
||||||
|
|
@ -69,7 +73,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+F: Find in current file (CM6)
|
// Cmd+F: Find in current file (CM6)
|
||||||
if (e.key === 'f' && !e.shiftKey) {
|
if (key === 'f' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const view = deps.getEditorView();
|
const view = deps.getEditorView();
|
||||||
|
|
@ -78,7 +82,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+G: Go to line
|
// Cmd+G: Go to line
|
||||||
if (e.key === 'g' && !e.shiftKey) {
|
if (key === 'g' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const view = deps.getEditorView();
|
const view = deps.getEditorView();
|
||||||
|
|
@ -87,7 +91,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+S: Save current file
|
// Cmd+S: Save current file
|
||||||
if (e.key === 's' && !e.shiftKey) {
|
if (key === 's' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (deps.activeTabId) void deps.saveFile(deps.activeTabId);
|
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
|
// Cmd+Shift+S: Save all files
|
||||||
if (e.key === 's' && e.shiftKey) {
|
if (key === 's' && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (deps.hasUnsavedChanges()) void deps.saveAllFiles();
|
if (deps.hasUnsavedChanges()) void deps.saveAllFiles();
|
||||||
|
|
@ -103,7 +107,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+W: Toggle line wrap
|
// Cmd+Shift+W: Toggle line wrap
|
||||||
if (e.key === 'w' && e.shiftKey && !e.altKey) {
|
if (key === 'w' && e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deps.onToggleLineWrap();
|
deps.onToggleLineWrap();
|
||||||
|
|
@ -111,7 +115,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+W: Close current editor tab
|
// Cmd+W: Close current editor tab
|
||||||
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
|
if (key === 'w' && !e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (deps.activeTabId) {
|
if (deps.activeTabId) {
|
||||||
|
|
@ -123,7 +127,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+B: Toggle sidebar
|
// Cmd+B: Toggle sidebar
|
||||||
if (e.key === 'b') {
|
if (key === 'b') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
deps.onToggleSidebar();
|
deps.onToggleSidebar();
|
||||||
|
|
@ -131,7 +135,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+]: Next tab
|
// Cmd+Shift+]: Next tab
|
||||||
if (e.key === ']' && e.shiftKey) {
|
if (key === ']' && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
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
|
// Cmd+Shift+[: Previous tab
|
||||||
if (e.key === '[' && e.shiftKey) {
|
if (key === '[' && e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
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
|
// Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling
|
||||||
if (e.ctrlKey && e.key === 'Tab') {
|
if (e.ctrlKey && key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { physicalKey } from '@renderer/utils/keyboardUtils';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -78,27 +79,29 @@ export function useKeyboardShortcuts(): void {
|
||||||
function handleKeyDown(event: KeyboardEvent): void {
|
function handleKeyDown(event: KeyboardEvent): void {
|
||||||
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
|
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
|
||||||
const isMod = event.metaKey || event.ctrlKey;
|
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
|
// Editor scope guard: when the editor overlay is open, these shortcuts are
|
||||||
// handled by useEditorKeyboardShortcuts — yield control to avoid conflicts.
|
// handled by useEditorKeyboardShortcuts — yield control to avoid conflicts.
|
||||||
if (editorOpen) {
|
if (editorOpen) {
|
||||||
const isConflicting =
|
const isConflicting =
|
||||||
// Ctrl+Tab — editor tab cycling
|
// Ctrl+Tab — editor tab cycling
|
||||||
(event.ctrlKey && event.key === 'Tab') ||
|
(event.ctrlKey && key === 'Tab') ||
|
||||||
// Cmd+W — editor close 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
|
// Cmd+B — editor sidebar toggle
|
||||||
(isMod && event.key === 'b') ||
|
(isMod && key === 'b') ||
|
||||||
// Cmd+F — editor find in file (CM6)
|
// Cmd+F — editor find in file (CM6)
|
||||||
(isMod && event.key === 'f') ||
|
(isMod && key === 'f') ||
|
||||||
// Cmd+Shift+[ / ] — editor tab switching
|
// Cmd+Shift+[ / ] — editor tab switching
|
||||||
(isMod && event.shiftKey && (event.key === '[' || event.key === ']'));
|
(isMod && event.shiftKey && (key === '[' || key === ']'));
|
||||||
|
|
||||||
if (isConflicting) return;
|
if (isConflicting) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut)
|
// 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();
|
event.preventDefault();
|
||||||
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
||||||
|
|
||||||
|
|
@ -128,7 +131,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
|
|
||||||
// Cmd+Option+1-4: Focus pane by index
|
// Cmd+Option+1-4: Focus pane by index
|
||||||
if (event.altKey && !event.shiftKey) {
|
if (event.altKey && !event.shiftKey) {
|
||||||
const numKey = parseInt(event.key);
|
const numKey = parseInt(key);
|
||||||
if (numKey >= 1 && numKey <= 4) {
|
if (numKey >= 1 && numKey <= 4) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const targetPane = paneLayout.panes[numKey - 1];
|
const targetPane = paneLayout.panes[numKey - 1];
|
||||||
|
|
@ -139,7 +142,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Option+W: Close current pane
|
// Cmd+Option+W: Close current pane
|
||||||
if (event.key === 'w') {
|
if (key === 'w') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (paneLayout.panes.length > 1) {
|
if (paneLayout.panes.length > 1) {
|
||||||
closePane(paneLayout.focusedPaneId);
|
closePane(paneLayout.focusedPaneId);
|
||||||
|
|
@ -149,7 +152,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+\: Split right with current tab
|
// Cmd+\: Split right with current tab
|
||||||
if (event.key === '\\' && !event.altKey && !event.shiftKey) {
|
if (key === '\\' && !event.altKey && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (activeTabId) {
|
if (activeTabId) {
|
||||||
splitPane(paneLayout.focusedPaneId, activeTabId, 'right');
|
splitPane(paneLayout.focusedPaneId, activeTabId, 'right');
|
||||||
|
|
@ -158,21 +161,21 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+T: New tab (Dashboard)
|
// Cmd+T: New tab (Dashboard)
|
||||||
if (event.key === 't') {
|
if (key === 't') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openDashboard();
|
openDashboard();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+W: Close all tabs
|
// Cmd+Shift+W: Close all tabs
|
||||||
if (event.key === 'w' && event.shiftKey && !event.altKey) {
|
if (key === 'w' && event.shiftKey && !event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closeAllTabs();
|
closeAllTabs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+W: Close selected tabs (if multi-selected) or active tab
|
// Cmd+W: Close selected tabs (if multi-selected) or active tab
|
||||||
if (event.key === 'w' && !event.altKey) {
|
if (key === 'w' && !event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedTabIds.length > 0) {
|
if (selectedTabIds.length > 0) {
|
||||||
closeTabs(selectedTabIds);
|
closeTabs(selectedTabIds);
|
||||||
|
|
@ -183,7 +186,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+[1-9]: Switch to tab by index within focused pane
|
// 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) {
|
if (numKey >= 1 && numKey <= 9 && !event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const targetTab = openTabs[numKey - 1];
|
const targetTab = openTabs[numKey - 1];
|
||||||
|
|
@ -194,7 +197,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+]: Next tab within focused pane
|
// Cmd+Shift+]: Next tab within focused pane
|
||||||
if (event.key === ']' && event.shiftKey) {
|
if (key === ']' && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
||||||
if (currentIndex !== -1 && currentIndex < openTabs.length - 1) {
|
if (currentIndex !== -1 && currentIndex < openTabs.length - 1) {
|
||||||
|
|
@ -204,7 +207,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+[: Previous tab within focused pane
|
// Cmd+Shift+[: Previous tab within focused pane
|
||||||
if (event.key === '[' && event.shiftKey) {
|
if (key === '[' && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
|
|
@ -214,7 +217,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Option+Right: Next tab (browser-style) within focused pane
|
// Cmd+Option+Right: Next tab (browser-style) within focused pane
|
||||||
if (event.key === 'ArrowRight' && event.altKey) {
|
if (key === 'ArrowRight' && event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
||||||
if (currentIndex !== -1 && currentIndex < openTabs.length - 1) {
|
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
|
// Cmd+Option+Left: Previous tab (browser-style) within focused pane
|
||||||
if (event.key === 'ArrowLeft' && event.altKey) {
|
if (key === 'ArrowLeft' && event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
|
|
@ -234,7 +237,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+K: Cycle to next workspace context
|
// Cmd+Shift+K: Cycle to next workspace context
|
||||||
if (event.key === 'k' && event.shiftKey) {
|
if (key === 'k' && event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isContextSwitching && availableContexts.length > 1) {
|
if (!isContextSwitching && availableContexts.length > 1) {
|
||||||
const currentIndex = availableContexts.findIndex((c) => c.id === activeContextId);
|
const currentIndex = availableContexts.findIndex((c) => c.id === activeContextId);
|
||||||
|
|
@ -245,21 +248,21 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+K: Open command palette for global search
|
// Cmd+K: Open command palette for global search
|
||||||
if (event.key === 'k') {
|
if (key === 'k') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openCommandPalette();
|
openCommandPalette();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+,: Open settings (standard macOS shortcut)
|
// Cmd+,: Open settings (standard macOS shortcut)
|
||||||
if (event.key === ',') {
|
if (key === ',') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openSettingsTab();
|
openSettingsTab();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+F: Find in session
|
// Cmd+F: Find in session
|
||||||
if (event.key === 'f') {
|
if (key === 'f') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const activeTab = getActiveTab();
|
const activeTab = getActiveTab();
|
||||||
// Only enable search in session views, not dashboard
|
// Only enable search in session views, not dashboard
|
||||||
|
|
@ -270,14 +273,14 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+O: Open project (placeholder for future implementation)
|
// Cmd+O: Open project (placeholder for future implementation)
|
||||||
if (event.key === 'o') {
|
if (key === 'o') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
logger.debug('Open project shortcut triggered (not yet implemented)');
|
logger.debug('Open project shortcut triggered (not yet implemented)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+R: Refresh current session and sidebar session list
|
// Cmd+R: Refresh current session and sidebar session list
|
||||||
if (event.key === 'r') {
|
if (key === 'r') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedProjectId && selectedSessionId) {
|
if (selectedProjectId && selectedSessionId) {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
|
|
@ -289,7 +292,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+B: Toggle sidebar
|
// Cmd+B: Toggle sidebar
|
||||||
if (event.key === 'b') {
|
if (key === 'b') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,11 @@
|
||||||
--skeleton-base-dim: rgba(26, 28, 40, 0.6);
|
--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 */
|
/* Light theme overrides - Warm neutral palette for eye comfort */
|
||||||
:root.light {
|
:root.light {
|
||||||
--color-surface: #f9f9f7; /* Warm off-white (not pure white) */
|
--color-surface: #f9f9f7; /* Warm off-white (not pure white) */
|
||||||
|
|
@ -580,6 +585,10 @@ body {
|
||||||
animation: shimmer 1.2s ease-in-out infinite;
|
animation: shimmer 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root.light .file-icon-glow {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
:root.light .skeleton-card::after {
|
:root.light .skeleton-card::after {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ export interface EditorSlice {
|
||||||
|
|
||||||
openFile: (filePath: string) => void;
|
openFile: (filePath: string) => void;
|
||||||
closeEditorTab: (tabId: string) => void;
|
closeEditorTab: (tabId: string) => void;
|
||||||
|
closeOtherEditorTabs: (keepTabId: string) => void;
|
||||||
|
closeEditorTabsToLeft: (tabId: string) => void;
|
||||||
|
closeEditorTabsToRight: (tabId: string) => void;
|
||||||
|
closeAllEditorTabs: () => void;
|
||||||
setActiveEditorTab: (tabId: string) => void;
|
setActiveEditorTab: (tabId: string) => void;
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
@ -392,6 +396,33 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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) => {
|
setActiveEditorTab: (tabId: string) => {
|
||||||
set({ editorActiveTabId: tabId });
|
set({ editorActiveTabId: tabId });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,60 @@ export function isMacOS(): boolean {
|
||||||
return navigator.userAgent.toLowerCase().includes('mac');
|
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
|
* Get the primary modifier key name for the current platform
|
||||||
* @returns 'Cmd' on macOS, 'Ctrl' on other platforms
|
* @returns 'Cmd' on macOS, 'Ctrl' on other platforms
|
||||||
|
|
|
||||||
|
|
@ -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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getFileIcon } from '@renderer/components/team/editor/fileIcons';
|
import { getDeviconUrl, getFileIcon } from '@renderer/components/team/editor/fileIcons';
|
||||||
|
|
||||||
describe('getFileIcon', () => {
|
describe('getFileIcon', () => {
|
||||||
it('returns TypeScript icon for .ts files', () => {
|
it('returns TypeScript icon for .ts files', () => {
|
||||||
const info = getFileIcon('index.ts');
|
const info = getFileIcon('index.ts');
|
||||||
expect(info.color).toBe('#3178c6');
|
expect(info.color).toBe('#3178c6');
|
||||||
|
expect(info.deviconSlug).toBe('typescript');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns TypeScript icon for .tsx files', () => {
|
it('returns TypeScript icon for .tsx files', () => {
|
||||||
const info = getFileIcon('App.tsx');
|
const info = getFileIcon('App.tsx');
|
||||||
expect(info.color).toBe('#3178c6');
|
expect(info.color).toBe('#3178c6');
|
||||||
|
expect(info.deviconSlug).toBe('react');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns JavaScript icon for .js files', () => {
|
it('returns JavaScript icon for .js files', () => {
|
||||||
const info = getFileIcon('app.js');
|
const info = getFileIcon('app.js');
|
||||||
expect(info.color).toBe('#f7df1e');
|
expect(info.color).toBe('#f7df1e');
|
||||||
|
expect(info.deviconSlug).toBe('javascript');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns JSON icon for .json files', () => {
|
it('returns JSON icon for .json files', () => {
|
||||||
const info = getFileIcon('package.json');
|
const info = getFileIcon('package.json');
|
||||||
// package.json has special mapping
|
// package.json has special mapping
|
||||||
expect(info.color).toBe('#cb3837');
|
expect(info.color).toBe('#cb3837');
|
||||||
|
expect(info.deviconSlug).toBe('nodejs');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns markdown icon for .md files', () => {
|
it('returns markdown icon for .md files', () => {
|
||||||
const info = getFileIcon('README.md');
|
const info = getFileIcon('README.md');
|
||||||
expect(info.color).toBe('#519aba');
|
expect(info.color).toBe('#519aba');
|
||||||
|
expect(info.deviconSlug).toBe('markdown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Python icon for .py files', () => {
|
it('returns Python icon for .py files', () => {
|
||||||
const info = getFileIcon('main.py');
|
const info = getFileIcon('main.py');
|
||||||
expect(info.color).toBe('#3572a5');
|
expect(info.color).toBe('#3572a5');
|
||||||
|
expect(info.deviconSlug).toBe('python');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns Rust icon for .rs files', () => {
|
it('returns Rust icon for .rs files', () => {
|
||||||
const info = getFileIcon('lib.rs');
|
const info = getFileIcon('lib.rs');
|
||||||
expect(info.color).toBe('#dea584');
|
expect(info.color).toBe('#dea584');
|
||||||
|
expect(info.deviconSlug).toBe('rust');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default icon for unknown extensions', () => {
|
it('returns default icon for unknown extensions', () => {
|
||||||
const info = getFileIcon('file.xyz123');
|
const info = getFileIcon('file.xyz123');
|
||||||
expect(info.color).toBe('#89949f');
|
expect(info.color).toBe('#89949f');
|
||||||
|
expect(info.deviconSlug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns default icon for files without extension', () => {
|
it('returns default icon for files without extension', () => {
|
||||||
const info = getFileIcon('Procfile');
|
const info = getFileIcon('Procfile');
|
||||||
expect(info.color).toBe('#89949f');
|
expect(info.color).toBe('#89949f');
|
||||||
|
expect(info.deviconSlug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches special filenames exactly', () => {
|
it('matches special filenames exactly', () => {
|
||||||
const docker = getFileIcon('Dockerfile');
|
const docker = getFileIcon('Dockerfile');
|
||||||
expect(docker.color).toBe('#2496ed');
|
expect(docker.color).toBe('#2496ed');
|
||||||
|
expect(docker.deviconSlug).toBe('docker');
|
||||||
|
|
||||||
const gitignore = getFileIcon('.gitignore');
|
const gitignore = getFileIcon('.gitignore');
|
||||||
expect(gitignore.color).toBe('#f05032');
|
expect(gitignore.color).toBe('#f05032');
|
||||||
|
expect(gitignore.deviconSlug).toBe('git');
|
||||||
|
|
||||||
const claudeMd = getFileIcon('CLAUDE.md');
|
const claudeMd = getFileIcon('CLAUDE.md');
|
||||||
expect(claudeMd.color).toBe('#d97706');
|
expect(claudeMd.color).toBe('#d97706');
|
||||||
|
expect(claudeMd.deviconSlug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers filename match over extension match', () => {
|
it('prefers filename match over extension match', () => {
|
||||||
// tsconfig.json should match FILENAME_MAP, not generic .json
|
// tsconfig.json should match FILENAME_MAP, not generic .json
|
||||||
const tsconfig = getFileIcon('tsconfig.json');
|
const tsconfig = getFileIcon('tsconfig.json');
|
||||||
expect(tsconfig.color).toBe('#3178c6');
|
expect(tsconfig.color).toBe('#3178c6');
|
||||||
|
expect(tsconfig.deviconSlug).toBe('typescript');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns lock icon for sensitive files', () => {
|
it('returns lock icon for sensitive files', () => {
|
||||||
const env = getFileIcon('.env');
|
const env = getFileIcon('.env');
|
||||||
expect(env.color).toBe('#e5a00d');
|
expect(env.color).toBe('#e5a00d');
|
||||||
|
expect(env.deviconSlug).toBeUndefined();
|
||||||
|
|
||||||
const pnpmLock = getFileIcon('pnpm-lock.yaml');
|
const pnpmLock = getFileIcon('pnpm-lock.yaml');
|
||||||
expect(pnpmLock.color).toBe('#f69220');
|
expect(pnpmLock.color).toBe('#f69220');
|
||||||
|
expect(pnpmLock.deviconSlug).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles image files', () => {
|
it('handles image files', () => {
|
||||||
const png = getFileIcon('logo.png');
|
const png = getFileIcon('logo.png');
|
||||||
expect(png.color).toBe('#a074c4');
|
expect(png.color).toBe('#a074c4');
|
||||||
|
expect(png.deviconSlug).toBeUndefined();
|
||||||
|
|
||||||
const svg = getFileIcon('icon.svg');
|
const svg = getFileIcon('icon.svg');
|
||||||
expect(svg.color).toBe('#ffb13b');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,23 @@ function createMockDeps(overrides: Partial<EditorKeyHandlerDeps> = {}): 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> = {}): KeyboardEvent {
|
function createKeyEvent(key: string, opts: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||||
return new KeyboardEvent('keydown', {
|
return new KeyboardEvent('keydown', {
|
||||||
key,
|
key,
|
||||||
|
code: keyToCode(key),
|
||||||
metaKey: opts.metaKey ?? true,
|
metaKey: opts.metaKey ?? true,
|
||||||
ctrlKey: opts.ctrlKey ?? false,
|
ctrlKey: opts.ctrlKey ?? false,
|
||||||
shiftKey: opts.shiftKey ?? false,
|
shiftKey: opts.shiftKey ?? false,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getModifierKeyName,
|
getModifierKeyName,
|
||||||
getModifierKeySymbol,
|
getModifierKeySymbol,
|
||||||
isMacOS,
|
isMacOS,
|
||||||
|
physicalKey,
|
||||||
} from '../../../src/renderer/utils/keyboardUtils';
|
} from '../../../src/renderer/utils/keyboardUtils';
|
||||||
|
|
||||||
describe('keyboardUtils', () => {
|
describe('keyboardUtils', () => {
|
||||||
|
|
@ -158,4 +159,111 @@ describe('keyboardUtils', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('physicalKey', () => {
|
||||||
|
function makeEvent(
|
||||||
|
key: string,
|
||||||
|
code: string,
|
||||||
|
mods: Partial<KeyboardEventInit> = {}
|
||||||
|
): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue