mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-03 13:07:43 +00:00
refactor: claude status, command menu, and image viewer
- Refactored ClaudeStatus to TypeScript and moved it to the chat view subcomponents. - Refactored CommandMenu to TypeScript, moved it to the chat view subcomponents - Refactored ImageViewer to TypeScript and moved it to the file-tree view subcomponents. - Moved FileTree to the file-tree view folder.
This commit is contained in:
@@ -1,367 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* CommandMenu - Autocomplete dropdown for slash commands
|
||||
*
|
||||
* @param {Array} commands - Array of command objects to display
|
||||
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
|
||||
* @param {Function} onSelect - Callback when a command is selected
|
||||
* @param {Function} onClose - Callback when menu should close
|
||||
* @param {Object} position - Position object { top, left } for absolute positioning
|
||||
* @param {boolean} isOpen - Whether the menu is open
|
||||
* @param {Array} frequentCommands - Array of frequently used command objects
|
||||
*/
|
||||
const CommandMenu = ({
|
||||
commands = [],
|
||||
selectedIndex = -1,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
isOpen = false,
|
||||
frequentCommands = [],
|
||||
}) => {
|
||||
const menuRef = useRef(null);
|
||||
const selectedItemRef = useRef(null);
|
||||
|
||||
// Calculate responsive menu positioning.
|
||||
// Mobile: dock above chat input. Desktop: clamp to viewport.
|
||||
const getMenuPosition = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, calculate bottom position dynamically to appear above the input.
|
||||
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
|
||||
const inputBottom = position.bottom || 90;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
|
||||
};
|
||||
}
|
||||
|
||||
// On desktop, use provided position but ensure it stays on screen.
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
const menuPosition = getMenuPosition();
|
||||
|
||||
// Close menu when clicking outside.
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Keep selected keyboard item visible while navigating.
|
||||
useEffect(() => {
|
||||
if (selectedItemRef.current && menuRef.current) {
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
||||
|
||||
if (itemRect.bottom > menuRect.bottom) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else if (itemRect.top < menuRect.top) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show a message if no commands are available.
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add frequent commands as a special group if provided.
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
|
||||
const getCommandKey = (command) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
|
||||
// Group commands by namespace for section rendering.
|
||||
// When frequent commands are shown, avoid duplicate rows in other sections.
|
||||
const groupedCommands = commands.reduce((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const namespace = command.namespace || command.type || 'other';
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Add frequent commands as a separate group.
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
// Order: frequent, builtin, project, user, other.
|
||||
const namespaceOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
|
||||
|
||||
const namespaceLabels = {
|
||||
frequent: '\u2B50 Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
|
||||
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
|
||||
const commandIndexByKey = new Map();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: '#6b7280',
|
||||
padding: '8px 12px 4px',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedCommands[namespace].map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => {
|
||||
if (onSelect && commandIndex >= 0) {
|
||||
onSelect(command, commandIndex, true);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (onSelect) {
|
||||
onSelect(command, commandIndex, false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||
transition: 'background-color 100ms ease-in-out',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
// Prevent textarea blur when clicking a menu item.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: command.description ? '4px' : 0,
|
||||
}}
|
||||
>
|
||||
{/* Command icon based on namespace */}
|
||||
<span style={{ fontSize: '16px', flexShrink: 0 }}>
|
||||
{namespace === 'builtin' && '\u26A1'}
|
||||
{namespace === 'project' && '\uD83D\uDCC1'}
|
||||
{namespace === 'user' && '\uD83D\uDC64'}
|
||||
{namespace === 'other' && '\uD83D\uDCDD'}
|
||||
{namespace === 'frequent' && '\u2B50'}
|
||||
</span>
|
||||
|
||||
{/* Command name */}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#111827',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
|
||||
{/* Command metadata badge */}
|
||||
{command.metadata?.type && (
|
||||
<span
|
||||
className="command-metadata-badge"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command description */}
|
||||
{command.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginLeft: '24px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
color: '#3b82f6',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{'\u21B5'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Default light mode styles */}
|
||||
<style>{`
|
||||
.command-menu {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.command-menu {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #374151 !important;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.command-item[aria-selected="true"] {
|
||||
background-color: #1e40af !important;
|
||||
}
|
||||
.command-item span:not(.command-metadata-badge) {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.command-metadata-badge {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.command-item div {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
.command-group > div:first-child {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandMenu;
|
||||
@@ -1,5 +1,5 @@
|
||||
import CommandMenu from '../../../CommandMenu';
|
||||
import ClaudeStatus from '../../../ClaudeStatus';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from '../../../MicButton.jsx';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
@@ -151,7 +151,6 @@ export default function ChatComposer({
|
||||
onTranscript,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const AnyCommandMenu = CommandMenu as any;
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
const commandMenuPosition = {
|
||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||
@@ -266,7 +265,7 @@ export default function ChatComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnyCommandMenu
|
||||
<CommandMenu
|
||||
commands={filteredCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={onCommandSelect}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
|
||||
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
can_interrupt?: boolean;
|
||||
} | null;
|
||||
onAbort?: () => void;
|
||||
isLoading: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const SPINNER_CHARS = ['*', '+', 'x', '.'];
|
||||
|
||||
export default function ClaudeStatus({
|
||||
status,
|
||||
onAbort,
|
||||
isLoading,
|
||||
provider: _provider = 'claude',
|
||||
}: ClaudeStatusProps) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
@@ -15,79 +33,72 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
// Calculate random token rate once (30-50 tokens per second)
|
||||
const tokenRate = 30 + Math.random() * 20;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
// Simulate token count increasing over time
|
||||
setFakeTokens(Math.floor(elapsed * tokenRate));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Animate the status indicator
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
if (!isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setAnimationPhase(prev => (prev + 1) % 4);
|
||||
const timer = window.setInterval(() => {
|
||||
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Don't show if loading is false
|
||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
||||
if (!isLoading) return null;
|
||||
|
||||
// Clever action words that cycle
|
||||
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
|
||||
// Parse status data
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
|
||||
const statusText = status?.text || ACTION_WORDS[actionIndex];
|
||||
const tokens = status?.tokens || fakeTokens;
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
|
||||
// Animation characters
|
||||
const spinners = ['✻', '✹', '✸', '✶'];
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
const currentSpinner = SPINNER_CHARS[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
|
||||
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
|
||||
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
|
||||
)}
|
||||
>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - compact for mobile */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">⚒ {tokens.toLocaleString()}</span>
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">
|
||||
tokens {tokens.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
@@ -103,5 +114,3 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaudeStatus;
|
||||
258
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal file
258
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type CommandMenuCommand = {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
metadata?: { type?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CommandMenuProps = {
|
||||
commands?: CommandMenuCommand[];
|
||||
selectedIndex?: number;
|
||||
onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
|
||||
onClose: () => void;
|
||||
position?: { top: number; left: number; bottom?: number };
|
||||
isOpen?: boolean;
|
||||
frequentCommands?: CommandMenuCommand[];
|
||||
};
|
||||
|
||||
const menuBaseStyle: CSSProperties = {
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
};
|
||||
|
||||
const namespaceLabels: Record<string, string> = {
|
||||
frequent: 'Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
const namespaceIcons: Record<string, string> = {
|
||||
frequent: '[*]',
|
||||
builtin: '[B]',
|
||||
project: '[P]',
|
||||
user: '[U]',
|
||||
other: '[O]',
|
||||
};
|
||||
|
||||
const getCommandKey = (command: CommandMenuCommand) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
|
||||
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
|
||||
|
||||
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { position: 'fixed', top: '16px', left: '16px' };
|
||||
}
|
||||
if (window.innerWidth < 640) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${position.bottom ?? 90}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
export default function CommandMenu({
|
||||
commands = [],
|
||||
selectedIndex = -1,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
isOpen = false,
|
||||
frequentCommands = [],
|
||||
}: CommandMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuPosition = getMenuPosition(position);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!menuRef.current || !(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (!menuRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItemRef.current || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
||||
if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
const namespace = getNamespace(command);
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
const preferredOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||
|
||||
const commandIndexByKey = new Map<string, number>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, opacity: isOpen ? 1 : 0, transform: isOpen ? 'translateY(0)' : 'translateY(-10px)' }}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', color: '#6b7280', padding: '8px 12px 4px', letterSpacing: '0.05em' }}>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(groupedCommands[namespace] || []).map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||
onClick={() => onSelect && onSelect(command, commandIndex, false)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
style={{ display: 'flex', alignItems: 'flex-start', padding: '10px 12px', borderRadius: '6px', cursor: 'pointer', backgroundColor: isSelected ? '#eff6ff' : 'transparent', transition: 'background-color 100ms ease-in-out', marginBottom: '2px' }}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: command.description ? '4px' : 0 }}>
|
||||
<span style={{ fontSize: '12px', flexShrink: 0 }}>{namespaceIcons[namespace] || namespaceIcons.other}</span>
|
||||
<span style={{ fontWeight: 600, fontSize: '14px', color: '#111827', fontFamily: 'monospace' }}>{command.name}</span>
|
||||
{command.metadata?.type && (
|
||||
<span className="command-metadata-badge" style={{ fontSize: '10px', padding: '2px 6px', borderRadius: '4px', backgroundColor: '#f3f4f6', color: '#6b7280', fontWeight: 500 }}>
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div style={{ fontSize: '13px', color: '#6b7280', marginLeft: '24px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span style={{ marginLeft: '8px', color: '#3b82f6', fontSize: '12px', fontWeight: 600 }}>{'<-'}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style>{`
|
||||
.command-menu {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.command-menu {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #374151 !important;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.command-item[aria-selected="true"] {
|
||||
background-color: #1e40af !important;
|
||||
}
|
||||
.command-item span:not(.command-metadata-badge) {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.command-metadata-badge {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.command-item div {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
.command-group > div:first-child {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,19 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
import ImageViewer from '../ImageViewer';
|
||||
import { ICON_SIZE_CLASS, getFileIconData } from './constants/fileIcons';
|
||||
import { useExpandedDirectories } from './hooks/useExpandedDirectories';
|
||||
import { useFileTreeData } from './hooks/useFileTreeData';
|
||||
import { useFileTreeSearch } from './hooks/useFileTreeSearch';
|
||||
import { useFileTreeViewMode } from './hooks/useFileTreeViewMode';
|
||||
import type { FileTreeImageSelection, FileTreeNode } from './types/types';
|
||||
import { formatFileSize, formatRelativeTime, isImageFile } from './utils/fileTreeUtils';
|
||||
import FileTreeBody from './view/FileTreeBody';
|
||||
import FileTreeDetailedColumns from './view/FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './view/FileTreeHeader';
|
||||
import FileTreeLoadingState from './view/FileTreeLoadingState';
|
||||
import { Project } from '../../types/app';
|
||||
|
||||
type ImageViewerProps = {
|
||||
file: FileTreeImageSelection;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ImageViewerComponent = ImageViewer as unknown as (props: ImageViewerProps) => JSX.Element;
|
||||
import { cn } from '../../../lib/utils';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||
import { useFileTreeData } from '../hooks/useFileTreeData';
|
||||
import { useFileTreeSearch } from '../hooks/useFileTreeSearch';
|
||||
import { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';
|
||||
import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||
import FileTreeBody from './FileTreeBody';
|
||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||
import FileTreeHeader from './FileTreeHeader';
|
||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||
import { Project } from '../../../types/app';
|
||||
|
||||
type FileTreeProps = {
|
||||
selectedProject: Project | null;
|
||||
@@ -100,7 +93,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
/>
|
||||
|
||||
{selectedImage && (
|
||||
<ImageViewerComponent
|
||||
<ImageViewer
|
||||
file={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { Button } from '../../ui/button';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { FileTreeImageSelection } from '../types/types';
|
||||
|
||||
function ImageViewer({ file, onClose }) {
|
||||
type ImageViewerProps = {
|
||||
file: FileTreeImageSelection;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ImageViewer({ file, onClose }: ImageViewerProps) {
|
||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const [imageUrl, setImageUrl] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl;
|
||||
let objectUrl: string | null = null;
|
||||
const controller = new AbortController();
|
||||
|
||||
const loadImage = async () => {
|
||||
@@ -20,7 +26,7 @@ function ImageViewer({ file, onClose }) {
|
||||
setImageUrl(null);
|
||||
|
||||
const response = await authenticatedFetch(imagePath, {
|
||||
signal: controller.signal
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -30,11 +36,11 @@ function ImageViewer({ file, onClose }) {
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
} catch (loadError: unknown) {
|
||||
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error loading image:', err);
|
||||
console.error('Error loading image:', loadError);
|
||||
setError('Unable to load image');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -55,15 +61,8 @@ function ImageViewer({ file, onClose }) {
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{file.name}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{file.name}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -71,7 +70,7 @@ function ImageViewer({ file, onClose }) {
|
||||
<div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
|
||||
{loading && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<p>Loading image…</p>
|
||||
<p>Loading image...</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && imageUrl && (
|
||||
@@ -90,13 +89,9 @@ function ImageViewer({ file, onClose }) {
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{file.path}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageViewer;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../file-tree/FileTree';
|
||||
import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import ErrorBoundary from '../../ErrorBoundary';
|
||||
|
||||
Reference in New Issue
Block a user