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:
iliya 2026-03-01 14:24:24 +02:00
parent 4c8b50f3dd
commit cb8017b0db
25 changed files with 840 additions and 229 deletions

View file

@ -53,7 +53,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
// Handle Cmd+K to open full command palette
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyK') {
e.preventDefault();
openCommandPalette();
}

View file

@ -330,7 +330,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Handle keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'g' && (e.metaKey || e.ctrlKey)) {
if (e.code === 'KeyG' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setGlobalSearchEnabled((prev) => !prev);
return;

View file

@ -26,6 +26,7 @@ import {
AlertTriangle,
Bell,
CheckCheck,
Code,
Columns3,
FolderOpen,
GitBranch,
@ -805,13 +806,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<span className="max-w-60 truncate font-mono">
{formatProjectPath(data.config.projectPath)}
</span>
<button
onClick={() => setEditorOpen(true)}
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)]"
title="Open in Editor"
>
<Pencil size={10} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
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)]"
>
<Code size={10} className="shrink-0" /> Edit code
</button>
</TooltipTrigger>
<TooltipContent>Open project in built-in editor</TooltipContent>
</Tooltip>
</span>
)}
{leadBranch && (

View file

@ -9,7 +9,7 @@ import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import { ChevronRight } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
// =============================================================================
// Component
@ -33,8 +33,6 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
if (segments.length === 0) return null;
const fileName = segments[segments.length - 1];
const iconInfo = getFileIcon(fileName);
const Icon = iconInfo.icon;
const handleSegmentClick = (segmentIndex: number): void => {
if (!projectPath) return;
@ -53,7 +51,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
{idx > 0 && <ChevronRight className="text-text-muted/50 size-3" />}
{isLast ? (
<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}
</span>
) : (

View file

@ -22,7 +22,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
import { EditorContextMenu } from './EditorContextMenu';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
import { GitStatusBadge } from './GitStatusBadge';
import { NewFileDialog } from './NewFileDialog';
@ -121,9 +121,25 @@ export const EditorFileTree = ({
return map;
}, [flatItems]);
// Compute insertion index for inline new-item input
const newItemInsert = useMemo(() => {
if (!newItemState) return null;
const { parentDir } = newItemState;
const parentIdx = flatItems.findIndex((fi) => fi.node.fullPath === parentDir);
if (parentIdx === -1) {
// parentDir is the project root (not a node in flatItems) — insert at top
return { index: 0, depth: 0 };
}
// Insert right after the parent directory node (top of its children)
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
}, [newItemState, flatItems]);
// Virtual scrolling — increase overscan during drag for more drop targets
const virtualizer = useVirtualizer({
count: flatItems.length,
count: flatItems.length + (newItemInsert ? 1 : 0),
getScrollElement: () => scrollRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: draggedItem ? 20 : 10,
@ -163,14 +179,26 @@ export const EditorFileTree = ({
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
);
// Context menu handlers
const handleNewFile = useCallback((parentDir: string) => {
setNewItemState({ parentDir, type: 'file' });
}, []);
// Context menu handlers — expand parent directory so the input appears inline
const handleNewFile = useCallback(
(parentDir: string) => {
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
void expandDirectory(parentDir);
}
setNewItemState({ parentDir, type: 'file' });
},
[projectPath, expandedDirs, expandDirectory]
);
const handleNewFolder = useCallback((parentDir: string) => {
setNewItemState({ parentDir, type: 'directory' });
}, []);
const handleNewFolder = useCallback(
(parentDir: string) => {
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
void expandDirectory(parentDir);
}
setNewItemState({ parentDir, type: 'directory' });
},
[projectPath, expandedDirs, expandDirectory]
);
const handleDelete = useCallback(
async (path: string) => {
@ -357,7 +385,36 @@ export const EditorFileTree = ({
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = flatItems[virtualItem.index];
const { index } = virtualItem;
// Render inline new-item input at the correct tree position
if (index === newItemInsert?.index) {
return (
<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 (
<DraggableTreeItem
key={item.node.fullPath}
@ -387,14 +444,6 @@ export const EditorFileTree = ({
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
</DragOverlay>
</DndContext>
{newItemState && (
<NewFileDialog
type={newItemState.type}
parentDir={newItemState.parentDir}
onSubmit={handleNewItemSubmit}
onCancel={handleNewItemCancel}
/>
)}
</EditorContextMenu>
);
};
@ -521,9 +570,7 @@ const DraggableTreeItem = React.memo(
if (node.data?.isSensitive) {
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
} else if (node.isFile) {
const fileIcon = getFileIcon(node.name);
const FileIcon = fileIcon.icon;
icon = <FileIcon className="size-3.5 shrink-0" style={{ color: fileIcon.color }} />;
icon = <FileIcon fileName={node.name} className="size-3.5" />;
} else if (isExpanded) {
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
} else {
@ -583,9 +630,7 @@ const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactEleme
let icon: React.ReactNode;
if (node.isFile) {
const fileIcon = getFileIcon(node.name);
const FileIcon = fileIcon.icon;
icon = <FileIcon className="size-3.5" style={{ color: fileIcon.color }} />;
icon = <FileIcon fileName={node.name} className="size-3.5" />;
} else {
icon = <FolderOpen className="size-3.5 text-text-muted" />;
}

View file

@ -1,13 +1,15 @@
/**
* Tab bar for the project editor.
* Shows open files as tabs with dirty indicator (dot) and close button.
* Shows open files as tabs with dirty indicator (dot), close button,
* and right-click context menu (close others, close to left/right, close all).
*/
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { X } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import { EditorTabContextMenu } from './EditorTabContextMenu';
import { FileIcon } from './FileIcon';
import type { EditorFileTab } from '@shared/types/editor';
@ -31,6 +33,10 @@ export const EditorTabBar = ({
const activeTabId = useStore((s) => s.editorActiveTabId);
const modifiedFiles = useStore((s) => s.editorModifiedFiles);
const setActiveEditorTab = useStore((s) => s.setActiveEditorTab);
const closeOtherEditorTabs = useStore((s) => s.closeOtherEditorTabs);
const closeEditorTabsToLeft = useStore((s) => s.closeEditorTabsToLeft);
const closeEditorTabsToRight = useStore((s) => s.closeEditorTabsToRight);
const closeAllEditorTabs = useStore((s) => s.closeAllEditorTabs);
if (tabs.length === 0) return null;
@ -39,14 +45,20 @@ export const EditorTabBar = ({
className="flex h-8 shrink-0 items-center overflow-x-auto border-b border-border bg-surface-sidebar"
role="tablist"
>
{tabs.map((tab) => (
{tabs.map((tab, index) => (
<Tab
key={tab.id}
tab={tab}
tabIndex={index}
totalTabs={tabs.length}
isActive={tab.id === activeTabId}
isModified={!!modifiedFiles[tab.filePath]}
onActivate={() => setActiveEditorTab(tab.id)}
onClose={() => onRequestCloseTab(tab.id)}
onRequestClose={onRequestCloseTab}
onCloseOthers={closeOtherEditorTabs}
onCloseToLeft={closeEditorTabsToLeft}
onCloseToRight={closeEditorTabsToRight}
onCloseAll={closeAllEditorTabs}
/>
))}
</div>
@ -59,67 +71,93 @@ export const EditorTabBar = ({
interface TabProps {
tab: EditorFileTab;
tabIndex: number;
totalTabs: number;
isActive: boolean;
isModified: boolean;
onActivate: () => void;
onClose: () => void;
onRequestClose: (tabId: string) => void;
onCloseOthers: (tabId: string) => void;
onCloseToLeft: (tabId: string) => void;
onCloseToRight: (tabId: string) => void;
onCloseAll: () => void;
}
const Tab = ({ tab, isActive, isModified, onActivate, onClose }: TabProps): React.ReactElement => {
const Tab = ({
tab,
tabIndex,
totalTabs,
isActive,
isModified,
onActivate,
onRequestClose,
onCloseOthers,
onCloseToLeft,
onCloseToRight,
onCloseAll,
}: TabProps): React.ReactElement => {
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
onRequestClose(tab.id);
};
const handleAuxClick = (e: React.MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
onClose();
onRequestClose(tab.id);
}
};
const iconInfo = getFileIcon(tab.fileName);
const FileIcon = iconInfo.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onActivate}
onAuxClick={handleAuxClick}
role="tab"
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
? 'bg-surface text-text'
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
>
{isModified && (
<span
className="size-1.5 shrink-0 rounded-full bg-amber-400"
aria-label="Unsaved changes"
/>
)}
<FileIcon className="size-3.5 shrink-0" style={{ color: iconInfo.color }} />
<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}
<EditorTabContextMenu
tabId={tab.id}
tabIndex={tabIndex}
totalTabs={totalTabs}
onClose={onRequestClose}
onCloseOthers={onCloseOthers}
onCloseToLeft={onCloseToLeft}
onCloseToRight={onCloseToRight}
onCloseAll={onCloseAll}
>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onActivate}
onAuxClick={handleAuxClick}
role="tab"
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
? 'bg-surface text-text'
: 'bg-surface-sidebar text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
>
<X className="size-3" />
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tab.filePath}</TooltipContent>
</Tooltip>
{isModified && (
<span
className="size-1.5 shrink-0 rounded-full bg-amber-400"
aria-label="Unsaved changes"
/>
)}
<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>
);
};

View 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>
);
};

View 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';

View file

@ -49,9 +49,16 @@ export const NewFileDialog = ({
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Track whether focus has been established (prevents premature blur cancel)
const focusedRef = useRef(false);
useEffect(() => {
// Auto-focus on mount
inputRef.current?.focus();
// Defer focus to next frame — ensures Radix context menu has fully closed
const raf = requestAnimationFrame(() => {
inputRef.current?.focus();
focusedRef.current = true;
});
return () => cancelAnimationFrame(raf);
}, []);
const handleSubmit = useCallback(() => {
@ -82,6 +89,13 @@ export const NewFileDialog = ({
setError(null);
}, []);
const handleBlur = useCallback(() => {
// Only cancel if focus was already established (prevents race with RAF focus)
if (focusedRef.current) {
onCancel();
}
}, [onCancel]);
const Icon = type === 'file' ? FilePlus : FolderPlus;
return (
@ -94,7 +108,7 @@ export const NewFileDialog = ({
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={onCancel}
onBlur={handleBlur}
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
aria-label={type === 'file' ? 'New file name' : 'New folder name'}

View file

@ -11,7 +11,7 @@ import { useStore } from '@renderer/store';
import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
import type { QuickOpenFile } from '@shared/types/editor';
@ -97,15 +97,7 @@ export const QuickOpenDialog = ({
[allFiles, onSelectFile, onClose]
);
// Memoize file icon lookups
const fileItems = useMemo(
() =>
allFiles.map((file) => ({
...file,
iconInfo: getFileIcon(file.name),
})),
[allFiles]
);
const fileItems = allFiles;
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-[15vh]">
@ -146,7 +138,6 @@ export const QuickOpenDialog = ({
</Command.Empty>
)}
{fileItems.map((file) => {
const Icon = file.iconInfo.icon;
return (
<Command.Item
key={file.path}
@ -154,7 +145,7 @@ export const QuickOpenDialog = ({
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"
>
<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="ml-auto truncate text-xs text-text-muted">
{file.relativePath}

View file

@ -11,7 +11,7 @@ import { api } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Loader2, Search, X } from 'lucide-react';
import { getFileIcon } from './fileIcons';
import { FileIcon } from './FileIcon';
import type { SearchFileResult, SearchInFilesResult } from '@shared/types/editor';
@ -259,9 +259,6 @@ const SearchFileGroup = ({
const dirPath = relativePath.includes('/')
? relativePath.slice(0, relativePath.lastIndexOf('/'))
: '';
const iconInfo = getFileIcon(fileName);
const Icon = iconInfo.icon;
return (
<div className="border-border/50 border-b">
<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"
>
<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>
{dirPath && <span className="ml-1 truncate text-[10px] text-text-muted">{dirPath}</span>}
<span className="ml-auto shrink-0 text-[10px] text-text-muted">

View file

@ -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 {
@ -7,7 +10,6 @@ import {
Code,
Database,
File,
FileCode,
FileJson,
FileText,
FileType,
@ -26,6 +28,22 @@ import type { LucideIcon } from 'lucide-react';
export interface FileIconInfo {
icon: LucideIcon;
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> = {
// TypeScript / JavaScript
ts: { icon: FileCode, color: '#3178c6' },
tsx: { icon: FileCode, color: '#3178c6' },
js: { icon: FileCode, color: '#f7df1e' },
jsx: { icon: FileCode, color: '#61dafb' },
mjs: { icon: FileCode, color: '#f7df1e' },
cjs: { icon: FileCode, color: '#f7df1e' },
ts: { icon: Code, color: '#3178c6', deviconSlug: 'typescript' },
tsx: { icon: Code, color: '#3178c6', deviconSlug: 'react' },
js: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
jsx: { icon: Code, color: '#61dafb', deviconSlug: 'react' },
mjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
cjs: { icon: Code, color: '#f7df1e', deviconSlug: 'javascript' },
// Web
html: { icon: Code, color: '#e34c26' },
htm: { icon: Code, color: '#e34c26' },
css: { icon: FileCode, color: '#563d7c' },
scss: { icon: FileCode, color: '#c6538c' },
less: { icon: FileCode, color: '#1d365d' },
vue: { icon: FileCode, color: '#42b883' },
svelte: { icon: FileCode, color: '#ff3e00' },
html: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
htm: { icon: Code, color: '#e34c26', deviconSlug: 'html5' },
css: { icon: Code, color: '#1572b6', deviconSlug: 'css3' },
scss: { icon: Code, color: '#c6538c', deviconSlug: 'sass' },
less: { icon: Code, color: '#1d365d', deviconSlug: 'less' },
vue: { icon: Code, color: '#42b883', deviconSlug: 'vuejs' },
svelte: { icon: Code, color: '#ff3e00', deviconSlug: 'svelte' },
// Data / Config
json: { icon: FileJson, color: '#cbcb41' },
// Data / Config (no devicon — lucide fallbacks)
json: { icon: FileJson, color: '#cbcb41', deviconSlug: 'json' },
jsonl: { icon: FileJson, color: '#cbcb41' },
yaml: { icon: Settings, color: '#cb171e' },
yml: { icon: Settings, color: '#cb171e' },
yaml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
yml: { icon: Settings, color: '#cb171e', deviconSlug: 'yaml' },
toml: { icon: Settings, color: '#9c4121' },
xml: { icon: Code, color: '#e37933' },
xml: { icon: Code, color: '#e37933', deviconSlug: 'xml' },
csv: { icon: Database, color: '#4caf50' },
// Markdown / Text
md: { icon: FileText, color: '#519aba' },
mdx: { icon: FileText, color: '#519aba' },
md: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
mdx: { icon: FileText, color: '#519aba', deviconSlug: 'markdown' },
txt: { icon: FileText, color: '#89949f' },
rst: { icon: FileText, color: '#89949f' },
// Python
py: { icon: FileCode, color: '#3572a5' },
pyx: { icon: FileCode, color: '#3572a5' },
pyi: { icon: FileCode, color: '#3572a5' },
py: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
pyx: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
pyi: { icon: Code, color: '#3572a5', deviconSlug: 'python' },
// Rust
rs: { icon: FileCode, color: '#dea584' },
rs: { icon: Code, color: '#dea584', deviconSlug: 'rust' },
// Go
go: { icon: FileCode, color: '#00add8' },
go: { icon: Code, color: '#00add8', deviconSlug: 'go' },
// Ruby
rb: { icon: FileCode, color: '#cc342d' },
gemspec: { icon: FileCode, color: '#cc342d' },
rb: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
gemspec: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
// Java / Kotlin
java: { icon: FileCode, color: '#b07219' },
kt: { icon: FileCode, color: '#a97bff' },
kts: { icon: FileCode, color: '#a97bff' },
java: { icon: Code, color: '#b07219', deviconSlug: 'java' },
kt: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
kts: { icon: Code, color: '#a97bff', deviconSlug: 'kotlin' },
// C / C++
c: { icon: FileCode, color: '#555555' },
h: { icon: FileCode, color: '#555555' },
cpp: { icon: FileCode, color: '#f34b7d' },
hpp: { icon: FileCode, color: '#f34b7d' },
cc: { icon: FileCode, color: '#f34b7d' },
// C / C++ / C#
c: { icon: Code, color: '#555555', deviconSlug: 'c' },
h: { icon: Code, color: '#555555', deviconSlug: 'c' },
cpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
hpp: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
cc: { icon: Code, color: '#f34b7d', deviconSlug: 'cplusplus' },
cs: { icon: Code, color: '#178600', deviconSlug: 'csharp' },
// Shell
sh: { icon: Terminal, color: '#89e051' },
bash: { icon: Terminal, color: '#89e051' },
zsh: { icon: Terminal, color: '#89e051' },
sh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
bash: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
zsh: { icon: Terminal, color: '#89e051', deviconSlug: 'bash' },
fish: { icon: Terminal, color: '#89e051' },
// 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' },
jpg: { icon: Image, color: '#a074c4' },
jpeg: { icon: Image, color: '#a074c4' },
@ -110,46 +129,76 @@ const EXTENSION_MAP: Record<string, FileIconInfo> = {
ico: { icon: Image, color: '#a074c4' },
webp: { icon: Image, color: '#a074c4' },
// Fonts
// Fonts (no devicon — lucide FileType icon)
woff: { icon: FileType, color: '#89949f' },
woff2: { icon: FileType, color: '#89949f' },
ttf: { icon: FileType, color: '#89949f' },
otf: { icon: FileType, color: '#89949f' },
// Config files
// Config files (no devicon — lucide icons)
env: { icon: Lock, color: '#e5a00d' },
ini: { icon: Settings, color: '#89949f' },
conf: { icon: Settings, color: '#89949f' },
cfg: { icon: Settings, color: '#89949f' },
// Other
graphql: { icon: Braces, color: '#e535ab' },
gql: { icon: Braces, color: '#e535ab' },
proto: { icon: Code, color: '#89949f' },
dart: { icon: FileCode, color: '#00b4ab' },
swift: { icon: FileCode, color: '#f05138' },
php: { icon: FileCode, color: '#4f5d95' },
// Other languages
graphql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
gql: { icon: Braces, color: '#e535ab', deviconSlug: 'graphql' },
proto: { icon: Code, color: '#89949f', deviconSlug: 'protobuf' },
dart: { icon: Code, color: '#00b4ab', deviconSlug: 'dart' },
swift: { icon: Code, color: '#f05138', deviconSlug: 'swift' },
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
const FILENAME_MAP: Record<string, FileIconInfo> = {
Dockerfile: { icon: FileCode, color: '#2496ed' },
'docker-compose.yml': { icon: FileCode, color: '#2496ed' },
'docker-compose.yaml': { icon: FileCode, color: '#2496ed' },
Dockerfile: { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
'docker-compose.yml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
'docker-compose.yaml': { icon: Code, color: '#2496ed', deviconSlug: 'docker' },
Makefile: { icon: Terminal, color: '#427819' },
Rakefile: { icon: Terminal, color: '#cc342d' },
Gemfile: { icon: FileCode, color: '#cc342d' },
'.gitignore': { icon: Settings, color: '#f05032' },
'.gitattributes': { icon: Settings, color: '#f05032' },
'.eslintrc': { icon: Settings, color: '#4b32c3' },
Rakefile: { icon: Terminal, color: '#cc342d', deviconSlug: 'ruby' },
Gemfile: { icon: Code, color: '#cc342d', deviconSlug: 'ruby' },
'.gitignore': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
'.gitattributes': { icon: Settings, color: '#f05032', deviconSlug: 'git' },
'.eslintrc': { icon: Settings, color: '#4b32c3', deviconSlug: 'eslint' },
'.prettierrc': { icon: Settings, color: '#56b3b4' },
'tsconfig.json': { icon: Settings, color: '#3178c6' },
'package.json': { icon: FileJson, color: '#cb3837' },
'tsconfig.json': { icon: Settings, color: '#3178c6', deviconSlug: 'typescript' },
'package.json': { icon: FileJson, color: '#cb3837', deviconSlug: 'nodejs' },
'pnpm-lock.yaml': { icon: Lock, color: '#f69220' },
'package-lock.json': { icon: Lock, color: '#cb3837' },
'yarn.lock': { icon: Lock, color: '#2c8ebb' },
'package-lock.json': { icon: Lock, color: '#cb3837', deviconSlug: 'npm' },
'yarn.lock': { icon: Lock, color: '#2c8ebb', deviconSlug: 'yarn' },
LICENSE: { icon: FileText, color: '#d9b611' },
'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' };

View file

@ -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 { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
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 { useStore } from '@renderer/store';
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 = ({
taskId,
onConfirm,
@ -228,11 +264,7 @@ export const KanbanTaskCard = ({
#{task.id}
</Badge>
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{!compact && (
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div>
{task.needsClarification ? (
<span
@ -246,11 +278,7 @@ export const KanbanTaskCard = ({
{task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
</span>
) : null}
{compact && (
<h5 className="mt-1 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
)}
{compact && <TruncatedTitle text={task.subject} className="mt-1" />}
</div>
{hasBlockedBy ? (

View file

@ -379,7 +379,7 @@ export const ChangeReviewDialog = ({
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent): void => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
if ((e.metaKey || e.ctrlKey) && e.code === 'KeyZ' && !e.shiftKey) {
// Don't intercept if focus is inside a CM editor — let CM handle its own undo
if (document.activeElement?.closest('.cm-editor')) return;
// Don't intercept native undo in input/textarea

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@ -11,7 +12,6 @@ import {
Circle,
CircleDot,
Eye,
File,
Folder,
FolderOpen,
X as XIcon,
@ -139,7 +139,7 @@ const TreeItem = ({
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<FileStatusIcon status={status} />
<File className="size-3.5 shrink-0" />
<FileIcon fileName={node.name} className="size-3.5" />
{viewedSet && viewedSet.has(node.data.filePath) && (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -66,7 +66,7 @@ export const EmbeddedTerminal = ({
// Ctrl+C with selection → copy to clipboard (instead of sending SIGINT)
term.attachCustomKeyEventHandler((event) => {
if (event.type === 'keydown' && event.key === 'c' && (event.ctrlKey || event.metaKey)) {
if (event.type === 'keydown' && event.code === 'KeyC' && (event.ctrlKey || event.metaKey)) {
const selection = term.getSelection();
if (selection) {
void navigator.clipboard.writeText(selection);

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
import { getChunks } from '@renderer/components/team/review/CodeMirrorDiffUtils';
import { physicalKey } from '@renderer/utils/keyboardUtils';
import type { EditorView } from '@codemirror/view';
import type { FileChangeSummary } from '@shared/types/review';
@ -276,44 +277,46 @@ export function useDiffNavigation(
}
const isMeta = event.metaKey || event.ctrlKey;
// Layout-independent key (uses event.code for letters/symbols)
const key = physicalKey(event);
// Alt+J -> next hunk (cross-file in continuous mode)
if (event.altKey && event.key.toLowerCase() === 'j') {
if (event.altKey && key === 'j') {
event.preventDefault();
goToNextHunk();
return;
}
// Alt+K -> prev hunk (cross-file in continuous mode)
if (event.altKey && event.key.toLowerCase() === 'k') {
if (event.altKey && key === 'k') {
event.preventDefault();
goToPrevHunk();
return;
}
// Alt+ArrowDown -> next file
if (event.altKey && event.key === 'ArrowDown') {
if (event.altKey && key === 'ArrowDown') {
event.preventDefault();
goToNextFile();
return;
}
// Alt+ArrowUp -> prev file
if (event.altKey && event.key === 'ArrowUp') {
if (event.altKey && key === 'ArrowUp') {
event.preventDefault();
goToPrevFile();
return;
}
// Cmd+Enter -> save file
if (isMeta && event.key === 'Enter') {
if (isMeta && key === 'Enter') {
event.preventDefault();
onSaveFileRef.current?.();
return;
}
// Cmd+Y -> accept chunk + next (cross-file aware)
if (isMeta && event.key.toLowerCase() === 'y') {
if (isMeta && key === 'y') {
event.preventDefault();
const view = getActiveEditorView(editorViewRef, continuousOptionsRef.current);
if (view) {

View file

@ -10,6 +10,7 @@ import { useCallback, useEffect } from 'react';
import { gotoLine, openSearchPanel } from '@codemirror/search';
import { useStore } from '@renderer/store';
import { editorBridge } from '@renderer/utils/editorBridge';
import { physicalKey } from '@renderer/utils/keyboardUtils';
import type { EditorFileTab } from '@shared/types/editor';
@ -52,8 +53,11 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
const isMod = e.metaKey || e.ctrlKey;
if (!isMod) return;
// Layout-independent key (uses event.code for letters/symbols)
const key = physicalKey(e);
// Cmd+P: Quick Open
if (e.key === 'p' && !e.shiftKey) {
if (key === 'p' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleQuickOpen();
@ -61,7 +65,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+Shift+F: Search in files
if (e.key === 'f' && e.shiftKey) {
if (key === 'f' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleSearchPanel();
@ -69,7 +73,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+F: Find in current file (CM6)
if (e.key === 'f' && !e.shiftKey) {
if (key === 'f' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const view = deps.getEditorView();
@ -78,7 +82,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+G: Go to line
if (e.key === 'g' && !e.shiftKey) {
if (key === 'g' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const view = deps.getEditorView();
@ -87,7 +91,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+S: Save current file
if (e.key === 's' && !e.shiftKey) {
if (key === 's' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (deps.activeTabId) void deps.saveFile(deps.activeTabId);
@ -95,7 +99,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+Shift+S: Save all files
if (e.key === 's' && e.shiftKey) {
if (key === 's' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
if (deps.hasUnsavedChanges()) void deps.saveAllFiles();
@ -103,7 +107,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+Shift+W: Toggle line wrap
if (e.key === 'w' && e.shiftKey && !e.altKey) {
if (key === 'w' && e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleLineWrap();
@ -111,7 +115,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+W: Close current editor tab
if (e.key === 'w' && !e.shiftKey && !e.altKey) {
if (key === 'w' && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
if (deps.activeTabId) {
@ -123,7 +127,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+B: Toggle sidebar
if (e.key === 'b') {
if (key === 'b') {
e.preventDefault();
e.stopPropagation();
deps.onToggleSidebar();
@ -131,7 +135,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+Shift+]: Next tab
if (e.key === ']' && e.shiftKey) {
if (key === ']' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
@ -144,7 +148,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Cmd+Shift+[: Previous tab
if (e.key === '[' && e.shiftKey) {
if (key === '[' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);
@ -157,7 +161,7 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
}
// Ctrl+Tab / Ctrl+Shift+Tab: Tab cycling
if (e.ctrlKey && e.key === 'Tab') {
if (e.ctrlKey && key === 'Tab') {
e.preventDefault();
e.stopPropagation();
const idx = deps.openTabs.findIndex((t) => t.id === deps.activeTabId);

View file

@ -8,6 +8,7 @@
import { useEffect } from 'react';
import { physicalKey } from '@renderer/utils/keyboardUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
@ -78,27 +79,29 @@ export function useKeyboardShortcuts(): void {
function handleKeyDown(event: KeyboardEvent): void {
// Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed
const isMod = event.metaKey || event.ctrlKey;
// Layout-independent key (uses event.code for letters/symbols)
const key = physicalKey(event);
// Editor scope guard: when the editor overlay is open, these shortcuts are
// handled by useEditorKeyboardShortcuts — yield control to avoid conflicts.
if (editorOpen) {
const isConflicting =
// Ctrl+Tab — editor tab cycling
(event.ctrlKey && event.key === 'Tab') ||
(event.ctrlKey && key === 'Tab') ||
// Cmd+W — editor close tab
(isMod && event.key === 'w' && !event.altKey && !event.shiftKey) ||
(isMod && key === 'w' && !event.altKey && !event.shiftKey) ||
// Cmd+B — editor sidebar toggle
(isMod && event.key === 'b') ||
(isMod && key === 'b') ||
// Cmd+F — editor find in file (CM6)
(isMod && event.key === 'f') ||
(isMod && key === 'f') ||
// Cmd+Shift+[ / ] — editor tab switching
(isMod && event.shiftKey && (event.key === '[' || event.key === ']'));
(isMod && event.shiftKey && (key === '[' || key === ']'));
if (isConflicting) return;
}
// Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut)
if (event.ctrlKey && event.key === 'Tab') {
if (event.ctrlKey && key === 'Tab') {
event.preventDefault();
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
@ -128,7 +131,7 @@ export function useKeyboardShortcuts(): void {
// Cmd+Option+1-4: Focus pane by index
if (event.altKey && !event.shiftKey) {
const numKey = parseInt(event.key);
const numKey = parseInt(key);
if (numKey >= 1 && numKey <= 4) {
event.preventDefault();
const targetPane = paneLayout.panes[numKey - 1];
@ -139,7 +142,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Option+W: Close current pane
if (event.key === 'w') {
if (key === 'w') {
event.preventDefault();
if (paneLayout.panes.length > 1) {
closePane(paneLayout.focusedPaneId);
@ -149,7 +152,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+\: Split right with current tab
if (event.key === '\\' && !event.altKey && !event.shiftKey) {
if (key === '\\' && !event.altKey && !event.shiftKey) {
event.preventDefault();
if (activeTabId) {
splitPane(paneLayout.focusedPaneId, activeTabId, 'right');
@ -158,21 +161,21 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+T: New tab (Dashboard)
if (event.key === 't') {
if (key === 't') {
event.preventDefault();
openDashboard();
return;
}
// Cmd+Shift+W: Close all tabs
if (event.key === 'w' && event.shiftKey && !event.altKey) {
if (key === 'w' && event.shiftKey && !event.altKey) {
event.preventDefault();
closeAllTabs();
return;
}
// Cmd+W: Close selected tabs (if multi-selected) or active tab
if (event.key === 'w' && !event.altKey) {
if (key === 'w' && !event.altKey) {
event.preventDefault();
if (selectedTabIds.length > 0) {
closeTabs(selectedTabIds);
@ -183,7 +186,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+[1-9]: Switch to tab by index within focused pane
const numKey = parseInt(event.key);
const numKey = parseInt(key);
if (numKey >= 1 && numKey <= 9 && !event.altKey) {
event.preventDefault();
const targetTab = openTabs[numKey - 1];
@ -194,7 +197,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Shift+]: Next tab within focused pane
if (event.key === ']' && event.shiftKey) {
if (key === ']' && event.shiftKey) {
event.preventDefault();
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
if (currentIndex !== -1 && currentIndex < openTabs.length - 1) {
@ -204,7 +207,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Shift+[: Previous tab within focused pane
if (event.key === '[' && event.shiftKey) {
if (key === '[' && event.shiftKey) {
event.preventDefault();
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
if (currentIndex > 0) {
@ -214,7 +217,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Option+Right: Next tab (browser-style) within focused pane
if (event.key === 'ArrowRight' && event.altKey) {
if (key === 'ArrowRight' && event.altKey) {
event.preventDefault();
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
if (currentIndex !== -1 && currentIndex < openTabs.length - 1) {
@ -224,7 +227,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Option+Left: Previous tab (browser-style) within focused pane
if (event.key === 'ArrowLeft' && event.altKey) {
if (key === 'ArrowLeft' && event.altKey) {
event.preventDefault();
const currentIndex = openTabs.findIndex((t) => t.id === activeTabId);
if (currentIndex > 0) {
@ -234,7 +237,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+Shift+K: Cycle to next workspace context
if (event.key === 'k' && event.shiftKey) {
if (key === 'k' && event.shiftKey) {
event.preventDefault();
if (!isContextSwitching && availableContexts.length > 1) {
const currentIndex = availableContexts.findIndex((c) => c.id === activeContextId);
@ -245,21 +248,21 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+K: Open command palette for global search
if (event.key === 'k') {
if (key === 'k') {
event.preventDefault();
openCommandPalette();
return;
}
// Cmd+,: Open settings (standard macOS shortcut)
if (event.key === ',') {
if (key === ',') {
event.preventDefault();
openSettingsTab();
return;
}
// Cmd+F: Find in session
if (event.key === 'f') {
if (key === 'f') {
event.preventDefault();
const activeTab = getActiveTab();
// Only enable search in session views, not dashboard
@ -270,14 +273,14 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+O: Open project (placeholder for future implementation)
if (event.key === 'o') {
if (key === 'o') {
event.preventDefault();
logger.debug('Open project shortcut triggered (not yet implemented)');
return;
}
// Cmd+R: Refresh current session and sidebar session list
if (event.key === 'r') {
if (key === 'r') {
event.preventDefault();
if (selectedProjectId && selectedSessionId) {
void Promise.all([
@ -289,7 +292,7 @@ export function useKeyboardShortcuts(): void {
}
// Cmd+B: Toggle sidebar
if (event.key === 'b') {
if (key === 'b') {
event.preventDefault();
toggleSidebar();
}

View file

@ -199,6 +199,11 @@
--skeleton-base-dim: rgba(26, 28, 40, 0.6);
}
/* File icon glow — halo so dark icons stay visible on dark backgrounds */
.file-icon-glow {
filter: drop-shadow(0 0 6px rgba(255, 255, 255, 0.45));
}
/* Light theme overrides - Warm neutral palette for eye comfort */
:root.light {
--color-surface: #f9f9f7; /* Warm off-white (not pure white) */
@ -580,6 +585,10 @@ body {
animation: shimmer 1.2s ease-in-out infinite;
}
:root.light .file-icon-glow {
filter: none;
}
:root.light .skeleton-card::after {
background: linear-gradient(
90deg,

View file

@ -78,6 +78,10 @@ export interface EditorSlice {
openFile: (filePath: string) => void;
closeEditorTab: (tabId: string) => void;
closeOtherEditorTabs: (keepTabId: string) => void;
closeEditorTabsToLeft: (tabId: string) => void;
closeEditorTabsToRight: (tabId: string) => void;
closeAllEditorTabs: () => void;
setActiveEditorTab: (tabId: string) => void;
// ═══════════════════════════════════════════════════════
@ -392,6 +396,33 @@ export const createEditorSlice: StateCreator<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) => {
set({ editorActiveTabId: tabId });
},

View file

@ -9,6 +9,60 @@ export function isMacOS(): boolean {
return navigator.userAgent.toLowerCase().includes('mac');
}
/**
* Resolve the physical key from a keyboard event, independent of keyboard layout.
*
* Uses `event.code` (physical key position on a QWERTY keyboard) to determine
* the key, so shortcuts work correctly regardless of active layout (Russian,
* Hebrew, Arabic, etc.).
*
* Returns a lowercase single character for letter keys, digit for number keys,
* the symbol for punctuation keys, or falls back to `event.key` for special
* keys (Tab, Enter, Escape, Arrow*, etc.) which are layout-independent.
*/
export function physicalKey(e: KeyboardEvent): string {
const { code, key } = e;
// Letter keys: KeyA → 'a', KeyF → 'f', KeyZ → 'z'
if (code.startsWith('Key') && code.length === 4) {
return code[3].toLowerCase();
}
// Digit keys: Digit0 → '0', Digit9 → '9'
if (code.startsWith('Digit') && code.length === 6) {
return code[5];
}
// Punctuation / symbol keys
switch (code) {
case 'BracketLeft':
return '[';
case 'BracketRight':
return ']';
case 'Backslash':
return '\\';
case 'Comma':
return ',';
case 'Period':
return '.';
case 'Slash':
return '/';
case 'Semicolon':
return ';';
case 'Quote':
return "'";
case 'Minus':
return '-';
case 'Equal':
return '=';
case 'Backquote':
return '`';
default:
// Special keys: Tab, Enter, Escape, ArrowUp, ArrowDown, Space, etc.
return key;
}
}
/**
* Get the primary modifier key name for the current platform
* @returns 'Cmd' on macOS, 'Ctrl' on other platforms

View file

@ -1,88 +1,154 @@
/**
* Tests for fileIcons utility extension-to-icon mapping.
* Tests for fileIcons utility extension-to-icon mapping with Devicon support.
*/
import { describe, expect, it } from 'vitest';
import { getFileIcon } from '@renderer/components/team/editor/fileIcons';
import { getDeviconUrl, getFileIcon } from '@renderer/components/team/editor/fileIcons';
describe('getFileIcon', () => {
it('returns TypeScript icon for .ts files', () => {
const info = getFileIcon('index.ts');
expect(info.color).toBe('#3178c6');
expect(info.deviconSlug).toBe('typescript');
});
it('returns TypeScript icon for .tsx files', () => {
const info = getFileIcon('App.tsx');
expect(info.color).toBe('#3178c6');
expect(info.deviconSlug).toBe('react');
});
it('returns JavaScript icon for .js files', () => {
const info = getFileIcon('app.js');
expect(info.color).toBe('#f7df1e');
expect(info.deviconSlug).toBe('javascript');
});
it('returns JSON icon for .json files', () => {
const info = getFileIcon('package.json');
// package.json has special mapping
expect(info.color).toBe('#cb3837');
expect(info.deviconSlug).toBe('nodejs');
});
it('returns markdown icon for .md files', () => {
const info = getFileIcon('README.md');
expect(info.color).toBe('#519aba');
expect(info.deviconSlug).toBe('markdown');
});
it('returns Python icon for .py files', () => {
const info = getFileIcon('main.py');
expect(info.color).toBe('#3572a5');
expect(info.deviconSlug).toBe('python');
});
it('returns Rust icon for .rs files', () => {
const info = getFileIcon('lib.rs');
expect(info.color).toBe('#dea584');
expect(info.deviconSlug).toBe('rust');
});
it('returns default icon for unknown extensions', () => {
const info = getFileIcon('file.xyz123');
expect(info.color).toBe('#89949f');
expect(info.deviconSlug).toBeUndefined();
});
it('returns default icon for files without extension', () => {
const info = getFileIcon('Procfile');
expect(info.color).toBe('#89949f');
expect(info.deviconSlug).toBeUndefined();
});
it('matches special filenames exactly', () => {
const docker = getFileIcon('Dockerfile');
expect(docker.color).toBe('#2496ed');
expect(docker.deviconSlug).toBe('docker');
const gitignore = getFileIcon('.gitignore');
expect(gitignore.color).toBe('#f05032');
expect(gitignore.deviconSlug).toBe('git');
const claudeMd = getFileIcon('CLAUDE.md');
expect(claudeMd.color).toBe('#d97706');
expect(claudeMd.deviconSlug).toBeUndefined();
});
it('prefers filename match over extension match', () => {
// tsconfig.json should match FILENAME_MAP, not generic .json
const tsconfig = getFileIcon('tsconfig.json');
expect(tsconfig.color).toBe('#3178c6');
expect(tsconfig.deviconSlug).toBe('typescript');
});
it('returns lock icon for sensitive files', () => {
const env = getFileIcon('.env');
expect(env.color).toBe('#e5a00d');
expect(env.deviconSlug).toBeUndefined();
const pnpmLock = getFileIcon('pnpm-lock.yaml');
expect(pnpmLock.color).toBe('#f69220');
expect(pnpmLock.deviconSlug).toBeUndefined();
});
it('handles image files', () => {
const png = getFileIcon('logo.png');
expect(png.color).toBe('#a074c4');
expect(png.deviconSlug).toBeUndefined();
const svg = getFileIcon('icon.svg');
expect(svg.color).toBe('#ffb13b');
expect(svg.deviconSlug).toBeUndefined();
});
it('provides devicon slugs for major languages', () => {
const cases: [string, string][] = [
['app.go', 'go'],
['lib.rb', 'ruby'],
['Main.java', 'java'],
['style.css', 'css3'],
['page.html', 'html5'],
['Component.vue', 'vuejs'],
['App.svelte', 'svelte'],
['main.dart', 'dart'],
['app.swift', 'swift'],
['main.php', 'php'],
['main.kt', 'kotlin'],
['main.scala', 'scala'],
['app.ex', 'elixir'],
['query.graphql', 'graphql'],
];
for (const [fileName, expectedSlug] of cases) {
const info = getFileIcon(fileName);
expect(info.deviconSlug, `Expected ${fileName} to have slug "${expectedSlug}"`).toBe(
expectedSlug
);
}
});
it('provides devicon slugs for special config files', () => {
expect(getFileIcon('vite.config.ts').deviconSlug).toBe('vitejs');
expect(getFileIcon('docker-compose.yml').deviconSlug).toBe('docker');
expect(getFileIcon('.eslintrc').deviconSlug).toBe('eslint');
expect(getFileIcon('Cargo.toml').deviconSlug).toBe('rust');
expect(getFileIcon('go.mod').deviconSlug).toBe('go');
expect(getFileIcon('tailwind.config.js').deviconSlug).toBe('tailwindcss');
});
});
describe('getDeviconUrl', () => {
it('builds correct CDN URL for a slug', () => {
const url = getDeviconUrl('typescript');
expect(url).toBe(
'https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg'
);
});
it('works for multi-word slugs', () => {
const url = getDeviconUrl('cplusplus');
expect(url).toContain('/cplusplus/cplusplus-original.svg');
});
});

View file

@ -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 {
return new KeyboardEvent('keydown', {
key,
code: keyToCode(key),
metaKey: opts.metaKey ?? true,
ctrlKey: opts.ctrlKey ?? false,
shiftKey: opts.shiftKey ?? false,

View file

@ -5,6 +5,7 @@ import {
getModifierKeyName,
getModifierKeySymbol,
isMacOS,
physicalKey,
} from '../../../src/renderer/utils/keyboardUtils';
describe('keyboardUtils', () => {
@ -158,4 +159,111 @@ describe('keyboardUtils', () => {
});
});
});
describe('physicalKey', () => {
function makeEvent(
key: string,
code: string,
mods: Partial<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');
});
});
});
});