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
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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 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'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue