Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)

This commit is contained in:
Haileyesus
2026-02-25 19:07:07 +03:00
committed by GitHub
parent 23801e9cc1
commit 5e3a7b69d7
149 changed files with 11627 additions and 8453 deletions

13
package-lock.json generated
View File

@@ -52,6 +52,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-error-boundary": "^4.1.2",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
@@ -9834,6 +9835,18 @@
"react": ">= 16.8 || 18.0.0" "react": ">= 16.8 || 18.0.0"
} }
}, },
"node_modules/react-error-boundary": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz",
"integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "16.5.3", "version": "16.5.3",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.3.tgz", "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.3.tgz",

View File

@@ -87,6 +87,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-error-boundary": "^4.1.2",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",

View File

@@ -1,373 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function ApiKeysSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubTokens, setGithubTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newTokenName, setNewTokenName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const githubData = await githubRes.json();
setGithubTokens(githubData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubToken = async () => {
if (!newTokenName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newTokenName,
credentialType: 'github_token',
credentialValue: newGithubToken
})
});
const data = await res.json();
if (data.success) {
setNewTokenName('');
setNewGithubToken('');
setShowNewTokenForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub token:', error);
}
};
const deleteGithubToken = async (tokenId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub token:', error);
}
};
const toggleGithubToken = async (tokenId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub token:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.description')}
</p>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Tokens Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.description')}
</p>
{showNewTokenForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="mb-2"
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="mb-2 pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex gap-2">
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewTokenForm(false);
setNewTokenName('');
setNewGithubToken('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{githubTokens.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={token.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubToken(token.id, token.is_active)}
>
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubToken(token.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Documentation Link */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.documentation.description')}
</p>
<a
href="/EXTERNAL_API.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t('apiKeys.documentation.viewLink')}
</a>
</div>
</div>
);
}
export default ApiKeysSettings;

View File

@@ -1,875 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { StreamLanguage } from '@codemirror/language';
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
import { Eye, Code2 } from 'lucide-react';
// Custom .env file syntax highlighting
const envLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.match(/^#.*/)) return 'comment';
// Key (before =)
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
// Equals sign
if (stream.match(/^=/)) return 'operator';
// Double-quoted string
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
// Single-quoted string
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
// Variable interpolation ${...}
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
// Variable reference $VAR
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
// Numbers
if (stream.match(/^\d+/)) return 'number';
// Skip other characters
stream.next();
return null;
},
});
function MarkdownCodeBlock({ inline, className, children, ...props }) {
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const shouldInline = inline || !looksMultiline;
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
return (
<div className="relative group my-2">
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(raw).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
}
const markdownPreviewComponents = {
code: MarkdownCodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
function MarkdownPreview({ content }) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('codeEditorTheme');
return savedTheme ? savedTheme === 'dark' : true;
});
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(() => {
return localStorage.getItem('codeEditorWordWrap') === 'true';
});
const [minimapEnabled, setMinimapEnabled] = useState(() => {
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
});
const [fontSize, setFontSize] = useState(() => {
return localStorage.getItem('codeEditorFontSize') || '12';
});
const [markdownPreview, setMarkdownPreview] = useState(false);
const editorRef = useRef(null);
// Check if file is markdown
const isMarkdownFile = useMemo(() => {
const ext = file.name.split('.').pop()?.toLowerCase();
return ext === 'md' || ext === 'markdown';
}, [file.name]);
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
const gutters = {};
return [
showMinimap.compute(['doc'], (state) => {
// Get actual chunks from merge view
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
// Clear previous gutters
Object.keys(gutters).forEach(key => delete gutters[key]);
// Mark lines that are part of chunks
chunks.forEach(chunk => {
// Mark the lines in the B side (current document)
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters]
};
})
];
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
// Create extension to scroll to first chunk on mount
const scrollToFirstChunkExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
return [
ViewPlugin.fromClass(class {
constructor(view) {
// Delay to ensure merge view is fully initialized
setTimeout(() => {
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
if (chunks.length > 0) {
const firstChunk = chunks[0];
// Scroll to the first chunk
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
});
}
}, 100);
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
// Whether toolbar has any buttons worth showing
const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
// Create editor toolbar panel - only when there are buttons to show
const editorToolbarPanel = useMemo(() => {
if (!hasToolbarButtons) return [];
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
// Check if we have diff info and it's enabled
const hasDiff = file.diffInfo && showDiff;
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
// Build the toolbar HTML
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
// Left side - diff navigation (if applicable)
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
`;
}
toolbarHTML += '</div>';
// Right side - action buttons
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
}
</svg>
</button>
`;
}
// Pop out button (only in sidebar mode with onPopOut)
if (isSidebar && onPopOut) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</button>
`;
}
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
}
</svg>
</button>
`;
}
toolbarHTML += '</div>';
toolbarHTML += '</div>';
dom.innerHTML = toolbarHTML;
if (hasDiff) {
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
}
if (file.diffInfo) {
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
toggleDiffBtn?.addEventListener('click', () => {
setShowDiff(!showDiff);
});
}
if (isSidebar && onPopOut) {
const popoutBtn = dom.querySelector('.cm-popout-btn');
popoutBtn?.addEventListener('click', () => {
onPopOut();
});
}
if (isSidebar && onToggleExpand) {
const expandBtn = dom.querySelector('.cm-expand-btn');
expandBtn?.addEventListener('click', () => {
onToggleExpand();
});
}
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
const lowerName = filename.toLowerCase();
// Handle dotfiles like .env, .env.local, .env.production, etc.
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
// If we have diffInfo with both old and new content, we can show the diff directly
// This handles both GitPanel (full content) and ChatInterface (full content from API)
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
// Use the new_string as the content to display
// The unifiedMergeView will compare it against old_string
setContent(file.diffInfo.new_string);
setLoading(false);
return;
}
// Otherwise, load from disk
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
console.error('Error loading file:', error);
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file, projectPath]);
const handleSave = async () => {
setSaving(true);
try {
console.log('Saving file:', {
projectName: file.projectName,
path: file.path,
contentLength: content?.length
});
const response = await api.saveFile(file.projectName, file.path, content);
console.log('Save response:', {
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
} else {
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
}
const result = await response.json();
console.log('Save successful:', result);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Save theme preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Save word wrap preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
}, [wordWrap]);
// Listen for settings changes from the Settings modal
useEffect(() => {
const handleStorageChange = () => {
const newTheme = localStorage.getItem('codeEditorTheme');
if (newTheme) {
setIsDarkMode(newTheme === 'dark');
}
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
if (newWordWrap !== null) {
setWordWrap(newWordWrap === 'true');
}
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
if (newShowMinimap !== null) {
setMinimapEnabled(newShowMinimap !== 'false');
}
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
if (newShowLineNumbers !== null) {
setShowLineNumbers(newShowLineNumbers !== 'false');
}
const newFontSize = localStorage.getItem('codeEditorFontSize');
if (newFontSize) {
setFontSize(newFontSize);
}
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Custom event for same-window updates
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
};
}, []);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
if (loading) {
return (
<>
<style>
{`
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
)}
</>
);
}
return (
<>
<style>
{`
/* Light background for full line changes */
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
/* Override linear-gradient underline and use solid darker background for partial changes */
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
/* Minimap gutter styling */
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Editor toolbar panel styling */
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-[9999] ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={isSidebar ?
'bg-background flex flex-col w-full h-full' :
`bg-background shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
{t('header.showingChanges')}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{isMarkdownFile && (
<button
onClick={() => setMarkdownPreview(!markdownPreview)}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={markdownPreview ? t('actions.editMarkdown') : t('actions.previewMarkdown')}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
onClick={() => window.openSettings?.('appearance')}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('toolbar.settings')}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.download')}
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={saveSuccess ? t('actions.saved') : saving ? t('actions.saving') : t('actions.save')}
>
{saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<Save className="w-4 h-4" />
)}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.close')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Editor / Markdown Preview */}
<div className="flex-1 overflow-hidden">
{markdownPreview && isMarkdownFile ? (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
<MarkdownPreview content={content} />
</div>
</div>
) : (
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
// Always show the toolbar
...editorToolbarPanel,
// Only show diff-related extensions when diff is enabled
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>{t('footer.lines')} {content.split('\n').length}</span>
<span>{t('footer.characters')} {content.length}</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t('footer.shortcuts')}
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;

View File

@@ -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;

View File

@@ -1,421 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
import { useVersionCheck } from '../hooks/useVersionCheck';
import { version } from '../../package.json';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CredentialsSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubCredentials, setGithubCredentials] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newGithubName, setNewGithubName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [newGithubDescription, setNewGithubDescription] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
// Version check hook
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub credentials only
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const credentialsData = await credentialsRes.json();
setGithubCredentials(credentialsData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubCredential = async () => {
if (!newGithubName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newGithubName,
credentialType: 'github_token',
credentialValue: newGithubToken,
description: newGithubDescription
})
});
const data = await res.json();
if (data.success) {
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
setShowNewGithubForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub credential:', error);
}
};
const deleteGithubCredential = async (credentialId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub credential:', error);
}
};
const toggleGithubCredential = async (credentialId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub credential:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
{t('apiKeys.description')}
</p>
<a
href="/api-docs.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
{t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Credentials Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.descriptionAlt')}
</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)}
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Input
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewGithubForm(false);
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
{t('apiKeys.github.form.howToCreate')}
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{credential.credential_name}</div>
{credential.description && (
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubCredential(credential.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Version Information */}
<div className="pt-6 border-t border-border/50">
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground transition-colors"
>
v{version}
</a>
{updateAvailable && latestVersion && (
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
>
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
</div>
);
}
export default CredentialsSettings;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
function DarkModeToggle() {
const { isDarkMode, toggleDarkMode } = useTheme();
return (
<button
onClick={toggleDarkMode}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isDarkMode}
aria-label="Toggle dark mode"
>
<span className="sr-only">Toggle dark mode</span>
<span
className={`${
isDarkMode ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isDarkMode ? (
<svg className="w-3.5 h-3.5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -0,0 +1,48 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
type DarkModeToggleProps = {
checked?: boolean;
onToggle?: (nextValue: boolean) => void;
ariaLabel?: string;
};
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
const isEnabled = isControlled ? checked : isDarkMode;
const handleToggle = () => {
if (isControlled) {
onToggle(!isEnabled);
return;
}
toggleDarkMode();
};
return (
<button
onClick={handleToggle}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isEnabled}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${
isEnabled ? 'translate-x-7' : 'translate-x-1'
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isEnabled ? (
<Moon className="h-3.5 w-3.5 text-gray-700" />
) : (
<Sun className="h-3.5 w-3.5 text-yellow-500" />
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -1,73 +1,77 @@
import React from 'react'; import React, { useCallback, useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
class ErrorBoundary extends React.Component { function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
constructor(props) { return (
super(props); <div className="flex flex-col items-center justify-center p-8 text-center">
this.state = { hasError: false, error: null, errorInfo: null }; <div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
} <div className="flex items-center mb-4">
<div className="flex-shrink-0">
static getDerivedStateFromError(error) { <svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
// Update state so the next render will show the fallback UI <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
return { hasError: true }; </svg>
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div> </div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div> </div>
); <div className="text-sm text-red-700">
} <p className="mb-2">An error occurred while loading the chat interface.</p>
{showDetails && error && (
return this.props.children; <details className="mt-4">
} <summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{error.toString()}
{componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={resetErrorBoundary}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
} }
export default ErrorBoundary; function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
const [componentStack, setComponentStack] = useState(null);
const handleError = useCallback((error, errorInfo) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
setComponentStack(errorInfo?.componentStack || null);
}, []);
const handleReset = useCallback(() => {
setComponentStack(null);
onRetry?.();
}, [onRetry]);
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
<ErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
showDetails={showDetails}
componentStack={componentStack}
/>
), [showDetails, componentStack]);
return (
<ReactErrorBoundary
fallbackRender={renderFallback}
onError={handleError}
onReset={handleReset}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
}
export default ErrorBoundary;

View File

@@ -1,729 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Input } from './ui/input';
import {
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
ChevronRight,
FileJson, FileType, FileSpreadsheet, FileArchive,
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
} from 'lucide-react';
import { cn } from '../lib/utils';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
// ─── File Icon Registry ──────────────────────────────────────────────
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
// Uses lucide-react icons mapped semantically to file types.
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP = {
// ── JavaScript / TypeScript ──
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
// ── Python ──
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
// ── Rust ──
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
// ── Go ──
go: { icon: Hexagon, color: 'text-cyan-500' },
// ── Ruby ──
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
// ── PHP ──
php: { icon: Blocks, color: 'text-violet-500' },
// ── Java / Kotlin ──
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
// ── C / C++ ──
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
// ── C# ──
cs: { icon: Hexagon, color: 'text-purple-600' },
// ── Swift ──
swift:{ icon: Flame, color: 'text-orange-500' },
// ── Lua ──
lua: { icon: SquareFunction, color: 'text-blue-500' },
// ── R ──
r: { icon: FlaskConical, color: 'text-blue-600' },
// ── Web ──
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte:{ icon: FileCode2, color: 'text-orange-500' },
// ── Data / Config ──
json: { icon: Braces, color: 'text-yellow-600' },
jsonc:{ icon: Braces, color: 'text-yellow-500' },
json5:{ icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql:{ icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto:{ icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
// ── Documents ──
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
// ── Shell / Scripts ──
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
// ── Images ──
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
// ── Audio ──
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
// ── Video ──
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
// ── Fonts ──
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2:{ icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
// ── Archives ──
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
// ── Lock files ──
lock: { icon: Lock, color: 'text-gray-500' },
// ── Binary / Executable ──
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib:{ icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
// ── Misc config ──
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
// Special full-filename matches (highest priority)
const FILENAME_ICON_MAP = {
'Dockerfile': { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
'Gemfile': { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
'Makefile': { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
function getFileIconData(filename) {
// 1. Exact filename match
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
// 2. Check for .env prefix pattern
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
// 3. Extension-based lookup
const ext = filename.split('.').pop()?.toLowerCase();
if (ext && FILE_ICON_MAP[ext]) {
return FILE_ICON_MAP[ext];
}
// 4. Fallback
return { icon: File, color: 'text-muted-foreground' };
}
// ─── Component ───────────────────────────────────────────────────────
function FileTree({ selectedProject, onFileOpen }) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
setViewMode(savedViewMode);
}
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFiles(files);
} else {
const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered);
const expandMatches = (items) => {
items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) {
setExpandedDirs(prev => new Set(prev.add(item.path)));
expandMatches(item.children);
}
});
};
expandMatches(filtered);
}
}, [files, searchQuery]);
const filterFiles = (items, query) => {
return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query);
let filteredChildren = [];
if (item.type === 'directory' && item.children) {
filteredChildren = filterFiles(item.children, query);
}
if (matchesName || filteredChildren.length > 0) {
filtered.push({
...item,
children: filteredChildren
});
}
return filtered;
}, []);
};
const fetchFiles = async () => {
setLoading(true);
try {
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatRelativeTime = (date) => {
if (!date) return '-';
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return t('fileTree.justNow');
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
return past.toLocaleDateString();
};
const isImageFile = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE, color)} />;
};
// ── Click handler shared across all view modes ──
const handleItemClick = (item) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else if (onFileOpen) {
onFileOpen(item.path);
}
};
// ── Indent guide + folder/file icon rendering ──
const renderIndentGuides = (level) => {
if (level === 0) return null;
return (
<span className="flex items-center flex-shrink-0" aria-hidden="true">
{Array.from({ length: level }).map((_, i) => (
<span
key={i}
className="inline-block w-4 h-full border-l border-border/50"
/>
))}
</span>
);
};
const renderItemIcons = (item) => {
const isDir = item.type === 'directory';
const isOpen = expandedDirs.has(item.path);
if (isDir) {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return (
<span className="flex items-center flex-shrink-0 ml-[18px]">
{getFileIcon(item.name)}
</span>
);
};
// ─── Simple (Tree) View ────────────────────────────────────────────
const renderFileTree = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
'hover:bg-accent/60 transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
{isDir && isOpen && item.children && item.children.length > 0 && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Detailed View ────────────────────────────────────────────────
const renderDetailedView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-sm text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">
{item.permissionsRwx || ''}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderDetailedView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderCompactView(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Loading state ─────────────────────────────────────────────────
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">
{t('fileTree.loading')}
</div>
</div>
);
}
// ─── Main render ───────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
{t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')}
title={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')}
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
)}
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (
<div>
{viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div>
)}
</ScrollArea>
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { GitBranch, Check } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function GitSettings() {
const { t } = useTranslation('settings');
const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState('');
const [gitConfigLoading, setGitConfigLoading] = useState(false);
const [gitConfigSaving, setGitConfigSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
useEffect(() => {
loadGitConfig();
}, []);
const loadGitConfig = async () => {
try {
setGitConfigLoading(true);
const response = await authenticatedFetch('/api/user/git-config');
if (response.ok) {
const data = await response.json();
setGitName(data.gitName || '');
setGitEmail(data.gitEmail || '');
}
} catch (error) {
console.error('Error loading git config:', error);
} finally {
setGitConfigLoading(false);
}
};
const saveGitConfig = async () => {
try {
setGitConfigSaving(true);
const response = await authenticatedFetch('/api/user/git-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitName, gitEmail })
});
if (response.ok) {
setSaveStatus('success');
setTimeout(() => setSaveStatus(null), 3000);
} else {
const data = await response.json();
setSaveStatus('error');
console.error('Failed to save git config:', data.error);
}
} catch (error) {
console.error('Error saving git config:', error);
setSaveStatus('error');
} finally {
setGitConfigSaving(false);
}
};
return (
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('git.description')}
</p>
<div className="p-4 border rounded-lg bg-card space-y-3">
<div>
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
placeholder="John Doe"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.name.help')}
</p>
</div>
<div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
placeholder="john@example.com"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.email.help')}
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={gitConfigSaving || !gitName || !gitEmail}
>
{gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" />
{t('git.status.success')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default GitSettings;

View File

@@ -1,5 +1,5 @@
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell'; import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config'; import { IS_PLATFORM } from '../constants/config';
/** /**

View File

@@ -1,272 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Mic, Loader2, Brain } from 'lucide-react';
import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null);
const streamRef = useRef(null);
const chunksRef = useRef([]);
const lastTapRef = useRef(0);
// Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording
const startRecording = async () => {
try {
console.log('Starting recording...');
setError(null);
chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
console.log('Recording stopped, creating blob...');
const blob = new Blob(chunksRef.current, { type: mimeType });
// Clean up stream
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Start transcribing
setState('transcribing');
// Check if we're in an enhancement mode
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
// Set up a timer to switch to processing state for enhancement modes
let processingTimer;
if (isEnhancementMode) {
processingTimer = setTimeout(() => {
setState('processing');
}, 2000); // Switch to processing after 2 seconds
}
try {
const text = await transcribeWithWhisper(blob);
if (text && onTranscript) {
onTranscript(text);
}
} catch (err) {
console.error('Transcription error:', err);
setError(err.message);
} finally {
if (processingTimer) {
clearTimeout(processingTimer);
}
setState('idle');
}
};
recorder.start();
setState('recording');
console.log('Recording started successfully');
} catch (err) {
console.error('Failed to start recording:', err);
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle');
}
};
// Stop recording
const stopRecording = () => {
console.log('Stopping recording...');
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
// Don't set state here - let the onstop handler do it
} else {
// If recorder isn't in recording state, force cleanup
console.log('Recorder not in recording state, forcing cleanup');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setState('idle');
}
};
// Handle button click
const handleClick = (e) => {
// Prevent double firing on mobile
if (e) {
e.preventDefault();
e.stopPropagation();
}
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue
const now = Date.now();
if (now - lastTapRef.current < 300) {
console.log('Ignoring rapid tap');
return;
}
lastTapRef.current = now;
console.log('Button clicked, current state:', state);
if (state === 'idle') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
// Do nothing if transcribing or processing
};
// Clean up on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
// Button appearance based on state
const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) {
case 'recording':
return {
icon: <Mic className="w-5 h-5 text-white" />,
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
disabled: false
};
case 'transcribing':
return {
icon: <Loader2 className="w-5 h-5 animate-spin" />,
className: 'bg-blue-500 hover:bg-blue-600',
disabled: true
};
case 'processing':
return {
icon: <Brain className="w-5 h-5 animate-pulse" />,
className: 'bg-purple-500 hover:bg-purple-600',
disabled: true
};
default: // idle
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-700 hover:bg-gray-600',
disabled: false
};
}
};
const { icon, className: buttonClass, disabled } = getButtonAppearance();
return (
<div className="relative">
<button
type="button"
style={{
backgroundColor: state === 'recording' ? '#ef4444' :
state === 'transcribing' ? '#3b82f6' :
state === 'processing' ? '#a855f7' :
'#374151'
}}
className={`
flex items-center justify-center
w-12 h-12 rounded-full
text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
dark:ring-offset-gray-800
touch-action-manipulation
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === 'recording' ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={handleClick}
disabled={disabled}
>
{icon}
</button>
{error && (
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
animate-fade-in">
{error}
</div>
)}
{state === 'recording' && (
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
)}
{state === 'processing' && (
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api'; import { api } from '../utils/api';
import Shell from './Shell'; import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail'; import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => { const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react'; import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
import ClaudeLogo from './ClaudeLogo'; import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import CursorLogo from './CursorLogo';
import CodexLogo from './CodexLogo';
import LoginModal from './LoginModal'; import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api'; import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -347,7 +345,7 @@ const Onboarding = ({ onComplete }) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
<ClaudeLogo size={20} /> <SessionProviderLogo provider="claude" className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="font-medium text-foreground flex items-center gap-2"> <div className="font-medium text-foreground flex items-center gap-2">
@@ -380,7 +378,7 @@ const Onboarding = ({ onComplete }) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<CursorLogo size={20} /> <SessionProviderLogo provider="cursor" className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="font-medium text-foreground flex items-center gap-2"> <div className="font-medium text-foreground flex items-center gap-2">
@@ -413,7 +411,7 @@ const Onboarding = ({ onComplete }) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<CodexLogo className="w-5 h-5" /> <SessionProviderLogo provider="codex" className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="font-medium text-foreground flex items-center gap-2"> <div className="font-medium text-foreground flex items-center gap-2">

File diff suppressed because it is too large Load Diff

View File

@@ -1,692 +0,0 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../constants/config';
const xtermStyles = `
.xterm .xterm-screen {
outline: none !important;
}
.xterm:focus .xterm-screen {
outline: none !important;
}
.xterm-screen:focus {
outline: none !important;
}
`;
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = xtermStyles;
document.head.appendChild(styleSheet);
}
function fallbackCopyToClipboard(text) {
if (!text || typeof document === 'undefined') return false;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
}
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
function isCodexLoginCommand(command) {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
const ws = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
const [authUrl, setAuthUrl] = useState('');
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
useEffect(() => {
selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
});
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) return false;
const popup = window.open(url, '_blank', 'noopener,noreferrer');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) return false;
let copied = false;
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
copied = true;
}
} catch {
copied = false;
}
if (!copied) {
copied = fallbackCopyToClipboard(url);
}
return copied;
}, []);
const connectWebSocket = useCallback(async () => {
if (isConnecting || isConnected) return;
try {
let wsUrl;
if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
fitAddon.current.fit();
ws.current.send(JSON.stringify({
type: 'init',
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),
cols: terminal.current.cols,
rows: terminal.current.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current
}));
}
}, 100);
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
let output = data.data;
if (isPlainShellRef.current && onProcessCompleteRef.current) {
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
if (cleanOutput.includes('Process exited with code 0')) {
onProcessCompleteRef.current(0);
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
if (exitCode !== 0) {
onProcessCompleteRef.current(exitCode);
}
}
}
if (terminal.current) {
terminal.current.write(output);
}
} else if (data.type === 'auth_url' && data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
} else if (data.type === 'url_open') {
if (data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}
}
} catch (error) {
console.error('[Shell] Error handling WebSocket message:', error, event.data);
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
connectWebSocket();
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
const disconnectFromShell = useCallback(() => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
setIsConnected(false);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, []);
const sessionDisplayName = useMemo(() => {
if (!selectedSession) return null;
return selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
}, [selectedSession]);
const sessionDisplayNameShort = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 30);
}, [sessionDisplayName]);
const sessionDisplayNameLong = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 50);
}, [sessionDisplayName]);
const restartShell = () => {
setIsRestarting(true);
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
setIsConnected(false);
setIsInitialized(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
disconnectFromShell();
}
setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
return;
}
terminal.current = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
extendedAnsi: [
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
]
}
});
fitAddon.current = new FitAddon();
const webglAddon = new WebglAddon();
const webLinksAddon = new WebLinksAddon();
terminal.current.loadAddon(fitAddon.current);
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
if (!minimal) {
terminal.current.loadAddon(webLinksAddon);
}
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
try {
terminal.current.loadAddon(webglAddon);
} catch (error) {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
terminal.current.open(terminalRef.current);
terminal.current.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'c' &&
terminal.current.hasSelection()
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
return false;
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'v'
) {
// Block native browser/xterm paste so clipboard data is only sent after
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
event.preventDefault();
event.stopPropagation();
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: text
}));
}
}).catch(() => {});
return false;
}
return true;
});
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
setIsInitialized(true);
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 50);
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
ws.current.close();
}
ws.current = null;
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
}
};
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
connectToShell();
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>{t('shell.selectProject.description')}</p>
</div>
</div>
);
}
if (minimal) {
const displayAuthUrl = isCodexLoginCommand(initialCommand)
? CODEX_DEVICE_AUTH_URL
: authUrl;
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="h-full w-full bg-gray-900 relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({sessionDisplayNameShort}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title={t('shell.actions.disconnectTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>{t('shell.actions.disconnect')}</span>
</button>
)}
<button
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title={t('shell.actions.restartTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
</div>
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title={t('shell.actions.connectTitle')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;

View File

@@ -1,105 +0,0 @@
import React, { useState, useCallback } from 'react';
import Shell from './Shell.jsx';
/**
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
* Provides a flexible API for both standalone and session-based usage.
*
* @param {Object} project - Project object with name, fullPath/path, displayName
* @param {Object} session - Session object (optional, for tab usage)
* @param {string} command - Initial command to run (optional)
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
* @param {function} onComplete - Callback when process completes (receives exitCode)
* @param {function} onClose - Callback for close button (optional)
* @param {string} title - Custom header title (optional)
* @param {string} className - Additional CSS classes
* @param {boolean} showHeader - Whether to show custom header (default: true)
* @param {boolean} compact - Use compact layout (default: false)
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
*/
function StandaloneShell({
project,
session = null,
command = null,
isPlainShell = null,
autoConnect = true,
onComplete = null,
onClose = null,
title = null,
className = "",
showHeader = true,
compact = false,
minimal = false
}) {
const [isCompleted, setIsCompleted] = useState(false);
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
const handleProcessComplete = useCallback((exitCode) => {
setIsCompleted(true);
if (onComplete) {
onComplete(exitCode);
}
}, [onComplete]);
if (!project) {
return (
<div className={`h-full flex items-center justify-center ${className}`}>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
<p>A project is required to open a shell</p>
</div>
</div>
);
}
return (
<div className={`h-full w-full flex flex-col ${className}`}>
{/* Optional custom header */}
{!minimal && showHeader && title && (
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
{isCompleted && (
<span className="text-xs text-green-400">(Completed)</span>
)}
</div>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
title="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
)}
{/* Shell component wrapper */}
<div className="flex-1 w-full min-h-0">
<Shell
selectedProject={project}
selectedSession={session}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}
/>
</div>
</div>
);
}
export default StandaloneShell;

View File

@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator'; import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTaskMaster } from '../contexts/TaskMasterContext';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({ const TaskDetail = ({
task, task,
@@ -79,7 +80,7 @@ const TaskDetail = ({
}; };
const copyTaskId = () => { const copyTaskId = () => {
navigator.clipboard.writeText(task.id.toString()); copyTextToClipboard(task.id.toString());
}; };
const getStatusConfig = (status) => { const getStatusConfig = (status) => {

View File

@@ -1,10 +1,10 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react'; import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import TaskCard from './TaskCard'; import TaskCard from './TaskCard';
import CreateTaskModal from './CreateTaskModal'; import CreateTaskModal from './CreateTaskModal';
import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTaskMaster } from '../contexts/TaskMasterContext';
import Shell from './Shell'; import Shell from './shell/view/Shell';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -32,6 +32,7 @@ const TaskList = ({
const [showHelpGuide, setShowHelpGuide] = useState(false); const [showHelpGuide, setShowHelpGuide] = useState(false);
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false); const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
const [showPRDDropdown, setShowPRDDropdown] = useState(false); const [showPRDDropdown, setShowPRDDropdown] = useState(false);
const dropdownRef = useRef(null);
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster(); const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
const { t } = useTranslation('tasks'); const { t } = useTranslation('tasks');
@@ -39,7 +40,11 @@ const TaskList = ({
// Close PRD dropdown when clicking outside // Close PRD dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
if (showPRDDropdown && !event.target.closest('.relative')) { if (
showPRDDropdown &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target)
) {
setShowPRDDropdown(false); setShowPRDDropdown(false);
} }
}; };
@@ -48,6 +53,31 @@ const TaskList = ({
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPRDDropdown]); }, [showPRDDropdown]);
const loadPRDOptions = async (prd, closeDropdown = false) => {
if (!currentProject) {
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
if (closeDropdown) {
setShowPRDDropdown(false);
}
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
};
// Get unique status values from tasks // Get unique status values from tasks
const statuses = useMemo(() => { const statuses = useMemo(() => {
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean)); const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
@@ -309,23 +339,8 @@ const TaskList = ({
{existingPRDs.map((prd) => ( {existingPRDs.map((prd) => (
<button <button
key={prd.name} key={prd.name}
onClick={async () => { onClick={() => {
try { void loadPRDOptions(prd);
// Load the PRD content from the API
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
}} }}
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
> >
@@ -589,7 +604,7 @@ const TaskList = ({
</button> </button>
{/* PRD Management */} {/* PRD Management */}
<div className="relative"> <div ref={dropdownRef} className="relative">
{existingPRDs.length > 0 ? ( {existingPRDs.length > 0 ? (
// Dropdown when PRDs exist // Dropdown when PRDs exist
<div className="relative"> <div className="relative">
@@ -624,21 +639,8 @@ const TaskList = ({
{existingPRDs.map((prd) => ( {existingPRDs.map((prd) => (
<button <button
key={prd.name} key={prd.name}
onClick={async () => { onClick={() => {
try { void loadPRDOptions(prd, true);
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
setShowPRDDropdown(false);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
}} }}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2" className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })} title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
@@ -1050,4 +1052,4 @@ const TaskList = ({
); );
}; };
export default TaskList; export default TaskList;

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react'; import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({ const TaskMasterSetupWizard = ({
isOpen = true, isOpen = true,
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
} }
} }
}`; }`;
navigator.clipboard.writeText(mcpConfig); copyTextToClipboard(mcpConfig);
}; };
const renderStepContent = () => { const renderStepContent = () => {

View File

@@ -1,109 +1,3 @@
import { Zap } from 'lucide-react'; import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
function TasksSettings() { export default TasksSettingsTab;
const { t } = useTranslation('settings');
const {
tasksEnabled,
setTasksEnabled,
isTaskMasterInstalled,
isCheckingInstallation
} = useTasksSettings();
return (
<div className="space-y-8">
{/* Installation Status Check */}
{isCheckingInstallation ? (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</div>
) : (
<>
{/* TaskMaster Not Installed Warning */}
{!isTaskMasterInstalled && (
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
{t('tasks.notInstalled.title')}
</div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
<p>{t('tasks.notInstalled.description')}</p>
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
<code>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
{t('tasks.notInstalled.viewOnGitHub')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<div className="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
{/* TaskMaster Settings */}
{isTaskMasterInstalled && (
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
{t('tasks.settings.enableLabel')}
</div>
<div className="text-sm text-muted-foreground mt-1">
{t('tasks.settings.enableDescription')}
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={tasksEnabled}
onChange={(e) => setTasksEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
export default TasksSettings;

View File

@@ -47,6 +47,7 @@ interface UseChatComposerStateArgs {
sendMessage: (message: unknown) => void; sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean; sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void; onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void; onShowSettings?: () => void;
@@ -98,6 +99,7 @@ export function useChatComposerState({
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
onSessionProcessing,
onInputFocusChange, onInputFocusChange,
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
@@ -569,6 +571,9 @@ export function useChatComposerState({
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
} }
onSessionActive?.(sessionToActivate); onSessionActive?.(sessionToActivate);
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => { const getToolsSettings = () => {
try { try {
@@ -666,6 +671,7 @@ export function useChatComposerState({
executeCommand, executeCommand,
isLoading, isLoading,
onSessionActive, onSessionActive,
onSessionProcessing,
pendingViewSessionRef, pendingViewSessionRef,
permissionMode, permissionMode,
provider, provider,

View File

@@ -956,12 +956,26 @@ export function useChatRealtimeHandlers({
case 'session-status': { case 'session-status': {
const statusSessionId = latestMessage.sessionId; const statusSessionId = latestMessage.sessionId;
if (!statusSessionId) {
break;
}
const isCurrentSession = const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (isCurrentSession && latestMessage.isProcessing) {
setIsLoading(true); if (latestMessage.isProcessing) {
setCanAbortSession(true);
onSessionProcessing?.(statusSessionId); onSessionProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(true);
setCanAbortSession(true);
}
break;
}
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
clearLoadingIndicators();
} }
break; break;
} }

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps { interface OneLineDisplayProps {
toolName: string; toolName: string;
icon?: string; icon?: string;
label?: string; label?: string;
@@ -25,52 +25,6 @@ interface OneLineDisplayProps {
toolId?: string; toolId?: string;
} }
// Fallback for environments where the async Clipboard API is unavailable or blocked.
const copyWithLegacyExecCommand = (text: string): boolean => {
if (typeof document === 'undefined' || !document.body) {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
};
const copyTextToClipboard = async (text: string): Promise<boolean> => {
if (
typeof navigator !== 'undefined' &&
typeof window !== 'undefined' &&
window.isSecureContext &&
navigator.clipboard?.writeText
) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
}
}
return copyWithLegacyExecCommand(text);
};
/** /**
* Unified one-line display for simple tool inputs and results * Unified one-line display for simple tool inputs and results
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc. * Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
@@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
border: 'border-gray-300 dark:border-gray-600', border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400' icon: 'text-gray-500 dark:text-gray-400'
}, },
resultId,
toolResult, toolResult,
toolId toolId
}) => { }) => {

View File

@@ -180,6 +180,7 @@ function ChatInterface({
sendMessage, sendMessage,
sendByCtrlEnter, sendByCtrlEnter,
onSessionActive, onSessionActive,
onSessionProcessing,
onInputFocusChange, onInputFocusChange,
onFileOpen, onFileOpen,
onShowSettings, onShowSettings,
@@ -238,13 +239,6 @@ function ChatInterface({
}; };
}, [canAbortSession, handleAbortSession, isLoading]); }, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
const processingSessionId = selectedSession?.id || currentSessionId;
if (processingSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(processingSessionId);
}
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
useEffect(() => { useEffect(() => {
return () => { return () => {
resetStreamingState(); resetStreamingState();

View File

@@ -1,5 +1,5 @@
import { SessionProvider } from '../../../../types/app'; import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types'; import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = { type AssistantThinkingIndicatorProps = {

View File

@@ -1,6 +1,6 @@
import CommandMenu from '../../../CommandMenu'; import CommandMenu from './CommandMenu';
import ClaudeStatus from '../../../ClaudeStatus'; import ClaudeStatus from './ClaudeStatus';
import { MicButton } from '../../../MicButton.jsx'; import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment'; import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner'; import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls'; import ChatInputControls from './ChatInputControls';
@@ -151,7 +151,6 @@ export default function ChatComposer({
onTranscript, onTranscript,
}: ChatComposerProps) { }: ChatComposerProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const AnyCommandMenu = CommandMenu as any;
const textareaRect = textareaRef.current?.getBoundingClientRect(); const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = { const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0, top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
@@ -266,7 +265,7 @@ export default function ChatComposer({
</div> </div>
)} )}
<AnyCommandMenu <CommandMenu
commands={filteredCommands} commands={filteredCommands}
selectedIndex={selectedCommandIndex} selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect} onSelect={onCommandSelect}

View File

@@ -1,12 +1,30 @@
import React, { useState, useEffect } from 'react'; import { useEffect, useState } from 'react';
import { cn } from '../lib/utils'; 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 [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0); const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0); const [fakeTokens, setFakeTokens] = useState(0);
// Update elapsed time every second
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading) {
setElapsedTime(0); setElapsedTime(0);
@@ -15,79 +33,72 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
} }
const startTime = Date.now(); const startTime = Date.now();
// Calculate random token rate once (30-50 tokens per second)
const tokenRate = 30 + Math.random() * 20; const tokenRate = 30 + Math.random() * 20;
const timer = setInterval(() => { const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000); const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed); setElapsedTime(elapsed);
// Simulate token count increasing over time
setFakeTokens(Math.floor(elapsed * tokenRate)); setFakeTokens(Math.floor(elapsed * tokenRate));
}, 1000); }, 1000);
return () => clearInterval(timer); return () => window.clearInterval(timer);
}, [isLoading]); }, [isLoading]);
// Animate the status indicator
useEffect(() => { useEffect(() => {
if (!isLoading) return; if (!isLoading) {
return;
}
const timer = setInterval(() => { const timer = window.setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4); setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
}, 500); }, 500);
return () => clearInterval(timer); return () => window.clearInterval(timer);
}, [isLoading]); }, [isLoading]);
// Don't show if loading is false if (!isLoading) {
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator return null;
if (!isLoading) return null; }
// Clever action words that cycle const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; const statusText = status?.text || ACTION_WORDS[actionIndex];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const tokens = status?.tokens || fakeTokens; const tokens = status?.tokens || fakeTokens;
const canInterrupt = status?.can_interrupt !== false; const canInterrupt = status?.can_interrupt !== false;
const currentSpinner = SPINNER_CHARS[animationPhase];
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return ( return (
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300"> <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 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-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
{/* Animated spinner */} <span
<span className={cn( className={cn(
"text-base sm:text-xl transition-all duration-500 flex-shrink-0", 'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300" animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
)}> )}
>
{currentSpinner} {currentSpinner}
</span> </span>
{/* Status text - compact for mobile */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2"> <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="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> <span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
{tokens > 0 && ( {tokens > 0 && (
<> <>
<span className="text-gray-500 hidden sm:inline">·</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.toLocaleString()}</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> <span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Interrupt button */}
{canInterrupt && onAbort && ( {canInterrupt && onAbort && (
<button <button
onClick={onAbort} onClick={onAbort}
@@ -103,5 +114,3 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
</div> </div>
); );
} }
export default ClaudeStatus;

View File

@@ -0,0 +1,224 @@
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 border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
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 border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{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 mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
<div className="min-w-0 flex-1">
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
{command.metadata?.type && (
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
{command.description}
</div>
)}
</div>
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
</div>
);
})}
</div>
))}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type MarkdownProps = { type MarkdownProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -31,9 +32,8 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
if (shouldInline) { if (shouldInline) {
return ( return (
<code <code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${ className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''
className || '' }`}
}`}
{...props} {...props}
> >
{children} {children}
@@ -43,43 +43,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text'; const language = match ? match[1] : 'text';
const textToCopy = raw;
const handleCopy = () => {
const doSet = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
});
} else {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
}
} catch {}
};
return ( return (
<div className="relative group my-2"> <div className="relative group my-2">
@@ -89,7 +52,14 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<button <button
type="button" type="button"
onClick={handleCopy} onClick={() =>
copyTextToClipboard(raw).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})
}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600" className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')} aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}

View File

@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react'; import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { import type {
ChatMessage, ChatMessage,
ClaudePermissionSuggestion, ClaudePermissionSuggestion,

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Check, ChevronDown } from 'lucide-react'; import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx'; import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app'; import type { ProjectSession, SessionProvider } from '../../../../types/app';

View File

@@ -0,0 +1,17 @@
export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
fontSize: 'codeEditorFontSize',
} as const;
export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,
fontSize: '12',
} as const;
export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';

View File

@@ -0,0 +1,126 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
projectPath?: string;
};
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
return String(error);
};
export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const fileProjectName = file.projectName ?? projectPath;
const filePath = file.path;
const fileName = file.name;
const fileDiffNewString = file.diffInfo?.new_string;
const fileDiffOldString = file.diffInfo?.old_string;
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
// Diff payload may already include full old/new snapshots, so avoid disk read.
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
setContent(fileDiffNewString);
setLoading(false);
return;
}
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.readFile(fileProjectName, filePath);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error loading file:', error);
setContent(`// Error loading file: ${message}\n// File: ${fileName}\n// Path: ${filePath}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
try {
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.saveFile(fileProjectName, filePath, content);
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
}
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
await response.json();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error saving file:', error);
setSaveError(message);
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [content, file.name]);
return {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
handleSave,
handleDownload,
};
};

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import {
CODE_EDITOR_DEFAULTS,
CODE_EDITOR_SETTINGS_CHANGED_EVENT,
CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings';
const readTheme = () => {
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
if (!savedTheme) {
return CODE_EDITOR_DEFAULTS.isDarkMode;
}
return savedTheme === 'dark';
};
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey);
if (value === null) {
return defaultValue;
}
return value !== falseValue;
};
const readWordWrap = () => {
return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';
};
const readFontSize = () => {
const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
));
const [showLineNumbers, setShowLineNumbers] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
setFontSize(readFontSize());
};
window.addEventListener('storage', refreshFromStorage);
window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
return () => {
window.removeEventListener('storage', refreshFromStorage);
window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
};
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,
setMinimapEnabled,
showLineNumbers,
setShowLineNumbers,
fontSize,
setFontSize,
};
};

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
type UseEditorKeyboardShortcutsParams = {
onSave: () => void;
onClose: () => void;
dependency: string;
};
export const useEditorKeyboardShortcuts = ({
onSave,
onClose,
dependency,
}: UseEditorKeyboardShortcutsParams) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (!(event.ctrlKey || event.metaKey)) {
return;
}
if (event.key.toLowerCase() === 's') {
event.preventDefault();
onSave();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [dependency, onClose, onSave]);
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react'; import type { MouseEvent as ReactMouseEvent } from 'react';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import type { DiffInfo, EditingFile } from '../types/types'; import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
type UseEditorSidebarOptions = { type UseEditorSidebarOptions = {
selectedProject: Project | null; selectedProject: Project | null;
@@ -9,19 +9,20 @@ type UseEditorSidebarOptions = {
initialWidth?: number; initialWidth?: number;
}; };
export function useEditorSidebar({ export const useEditorSidebar = ({
selectedProject, selectedProject,
isMobile, isMobile,
initialWidth = 600, initialWidth = 600,
}: UseEditorSidebarOptions) { }: UseEditorSidebarOptions) => {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null); const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);
const [editorWidth, setEditorWidth] = useState(initialWidth); const [editorWidth, setEditorWidth] = useState(initialWidth);
const [editorExpanded, setEditorExpanded] = useState(false); const [editorExpanded, setEditorExpanded] = useState(false);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [hasManualWidth, setHasManualWidth] = useState(false);
const resizeHandleRef = useRef<HTMLDivElement | null>(null); const resizeHandleRef = useRef<HTMLDivElement | null>(null);
const handleFileOpen = useCallback( const handleFileOpen = useCallback(
(filePath: string, diffInfo: DiffInfo | null = null) => { (filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
const normalizedPath = filePath.replace(/\\/g, '/'); const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop() || filePath; const fileName = normalizedPath.split('/').pop() || filePath;
@@ -41,7 +42,7 @@ export function useEditorSidebar({
}, []); }, []);
const handleToggleEditorExpand = useCallback(() => { const handleToggleEditorExpand = useCallback(() => {
setEditorExpanded((prev) => !prev); setEditorExpanded((previous) => !previous);
}, []); }, []);
const handleResizeStart = useCallback( const handleResizeStart = useCallback(
@@ -50,6 +51,8 @@ export function useEditorSidebar({
return; return;
} }
// After first drag interaction, the editor width is user-controlled.
setHasManualWidth(true);
setIsResizing(true); setIsResizing(true);
event.preventDefault(); event.preventDefault();
}, },
@@ -101,10 +104,11 @@ export function useEditorSidebar({
editingFile, editingFile,
editorWidth, editorWidth,
editorExpanded, editorExpanded,
hasManualWidth,
resizeHandleRef, resizeHandleRef,
handleFileOpen, handleFileOpen,
handleCloseEditor, handleCloseEditor,
handleToggleEditorExpand, handleToggleEditorExpand,
handleResizeStart, handleResizeStart,
}; };
} };

View File

@@ -0,0 +1,21 @@
export type CodeEditorDiffInfo = {
old_string?: string;
new_string?: string;
[key: string]: unknown;
};
export type CodeEditorFile = {
name: string;
path: string;
projectName?: string;
diffInfo?: CodeEditorDiffInfo | null;
[key: string]: unknown;
};
export type CodeEditorSettingsState = {
isDarkMode: boolean;
wordWrap: boolean;
minimapEnabled: boolean;
showLineNumbers: boolean;
fontSize: string;
};

View File

@@ -0,0 +1,141 @@
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { StreamLanguage } from '@codemirror/language';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { getChunks } from '@codemirror/merge';
import { EditorView, ViewPlugin } from '@codemirror/view';
import { showMinimap } from '@replit/codemirror-minimap';
import type { CodeEditorFile } from '../types/types';
// Lightweight lexer for `.env` files (including `.env.*` variants).
const envLanguage = StreamLanguage.define({
token(stream) {
if (stream.match(/^#.*/)) return 'comment';
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
if (stream.match(/^=/)) return 'operator';
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
if (stream.match(/^\d+/)) return 'number';
stream.next();
return null;
},
});
export const getLanguageExtensions = (filename: string) => {
const lowerName = filename.toLowerCase();
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
export const createMinimapExtension = ({
file,
showDiff,
minimapEnabled,
isDarkMode,
}: {
file: CodeEditorFile;
showDiff: boolean;
minimapEnabled: boolean;
isDarkMode: boolean;
}) => {
if (!file.diffInfo || !showDiff || !minimapEnabled) {
return [];
}
const gutters: Record<number, string> = {};
return [
showMinimap.compute(['doc'], (state) => {
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
Object.keys(gutters).forEach((key) => {
delete gutters[Number(key)];
});
chunks.forEach((chunk) => {
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {
gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters],
};
}),
];
};
export const createScrollToFirstChunkExtension = ({
file,
showDiff,
}: {
file: CodeEditorFile;
showDiff: boolean;
}) => {
if (!file.diffInfo || !showDiff) {
return [];
}
return [
ViewPlugin.fromClass(class {
constructor(view: EditorView) {
// Wait for merge decorations so the first chunk location is stable.
setTimeout(() => {
const chunksData = getChunks(view.state);
const firstChunk = chunksData?.chunks?.[0];
if (firstChunk) {
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),
});
}
}, 100);
}
update() {}
destroy() {}
}),
];
};

View File

@@ -0,0 +1,79 @@
export const getEditorLoadingStyles = (isDarkMode: boolean) => {
return `
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`;
};
export const getEditorStyles = (isDarkMode: boolean) => {
return `
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
};

View File

@@ -0,0 +1,212 @@
import { getChunks } from '@codemirror/merge';
import { EditorView, showPanel } from '@codemirror/view';
import type { CodeEditorFile } from '../types/types';
type EditorToolbarLabels = {
changes: string;
previousChange: string;
nextChange: string;
hideDiff: string;
showDiff: string;
collapse: string;
expand: string;
};
type CreateEditorToolbarPanelParams = {
file: CodeEditorFile;
showDiff: boolean;
isSidebar: boolean;
isExpanded: boolean;
onToggleDiff: () => void;
onPopOut: (() => void) | null;
onToggleExpand: (() => void) | null;
labels: EditorToolbarLabels;
};
const getDiffVisibilityIcon = (showDiff: boolean) => {
if (showDiff) {
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
};
const getExpandIcon = (isExpanded: boolean) => {
if (isExpanded) {
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />';
};
const escapeHtml = (value: string): string => (
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
);
export const createEditorToolbarPanelExtension = ({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff,
onPopOut,
onToggleExpand,
labels,
}: CreateEditorToolbarPanelParams) => {
const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
if (!hasToolbarButtons) {
return [];
}
const createPanel = (view: EditorView) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
const hasDiff = Boolean(file.diffInfo && showDiff);
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
const maxChunkIndex = Math.max(0, chunkCount - 1);
currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
const escapedLabels = {
changes: escapeHtml(labels.changes),
previousChange: escapeHtml(labels.previousChange),
nextChange: escapeHtml(labels.nextChange),
hideDiff: escapeHtml(labels.hideDiff),
showDiff: escapeHtml(labels.showDiff),
collapse: escapeHtml(labels.collapse),
expand: escapeHtml(labels.expand),
};
// Icons are static SVG path fragments controlled by this module.
const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
const expandIcon = getExpandIcon(isExpanded);
let toolbarHtml = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHtml += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${escapedLabels.previousChange}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${escapedLabels.nextChange}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 4px;">';
if (file.diffInfo) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${diffVisibilityIcon}
</svg>
</button>
`;
}
if (isSidebar && onPopOut) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</button>
`;
}
if (isSidebar && onToggleExpand) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? escapedLabels.collapse : escapedLabels.expand}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${expandIcon}
</svg>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '</div>';
dom.innerHTML = toolbarHtml;
if (hasDiff) {
const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');
const nextButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-next');
previousButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
nextButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
}
const toggleDiffButton = dom.querySelector<HTMLButtonElement>('.cm-toggle-diff-btn');
toggleDiffButton?.addEventListener('click', onToggleDiff);
const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');
popOutButton?.addEventListener('click', () => {
onPopOut?.();
});
const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');
expandButton?.addEventListener('click', () => {
onToggleExpand?.();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel,
};
};
return [showPanel.of(createPanel)];
};

View File

@@ -0,0 +1,234 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
type CodeEditorProps = {
file: CodeEditorFile;
onClose: () => void;
projectPath?: string;
isSidebar?: boolean;
isExpanded?: boolean;
onToggleExpand?: (() => void) | null;
onPopOut?: (() => void) | null;
};
export default function CodeEditor({
file,
onClose,
projectPath,
isSidebar = false,
isExpanded = false,
onToggleExpand = null,
onPopOut = null,
}: CodeEditorProps) {
const { t } = useTranslation('codeEditor');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
fontSize,
} = useCodeEditorSettings();
const {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
handleSave,
handleDownload,
} = useCodeEditorDocument({
file,
projectPath,
});
const isMarkdownFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'md' || extension === 'markdown';
}, [file.name]);
const minimapExtension = useMemo(
() => (
createMinimapExtension({
file,
showDiff,
minimapEnabled,
isDarkMode,
})
),
[file, isDarkMode, minimapEnabled, showDiff],
);
const scrollToFirstChunkExtension = useMemo(
() => createScrollToFirstChunkExtension({ file, showDiff }),
[file, showDiff],
);
const toolbarPanelExtension = useMemo(
() => (
createEditorToolbarPanelExtension({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff: () => setShowDiff((previous) => !previous),
onPopOut,
onToggleExpand,
labels: {
changes: t('toolbar.changes'),
previousChange: t('toolbar.previousChange'),
nextChange: t('toolbar.nextChange'),
hideDiff: t('toolbar.hideDiff'),
showDiff: t('toolbar.showDiff'),
collapse: t('toolbar.collapse'),
expand: t('toolbar.expand'),
},
})
),
[file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
);
const extensions = useMemo(() => {
const allExtensions: Extension[] = [
...getLanguageExtensions(file.name),
...toolbarPanelExtension,
];
if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
allExtensions.push(
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true,
}),
);
allExtensions.push(...minimapExtension);
allExtensions.push(...scrollToFirstChunkExtension);
}
if (wordWrap) {
allExtensions.push(EditorView.lineWrapping);
}
return allExtensions;
}, [
file.diffInfo,
file.name,
minimapExtension,
scrollToFirstChunkExtension,
showDiff,
toolbarPanelExtension,
wordWrap,
]);
useEditorKeyboardShortcuts({
onSave: handleSave,
onClose,
dependency: content,
});
if (loading) {
return (
<CodeEditorLoadingState
isDarkMode={isDarkMode}
isSidebar={isSidebar}
loadingText={t('loading', { fileName: file.name })}
/>
);
}
const outerContainerClassName = isSidebar
? 'w-full h-full flex flex-col'
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
const innerContainerClassName = isSidebar
? 'bg-background flex flex-col w-full h-full'
: `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
}`;
return (
<>
<style>{getEditorStyles(isDarkMode)}</style>
<div className={outerContainerClassName}>
<div className={innerContainerClassName}>
<CodeEditorHeader
file={file}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenSettings={() => window.openSettings?.('appearance')}
onDownload={handleDownload}
onSave={handleSave}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
onClose={onClose}
labels={{
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),
saving: t('actions.saving'),
saved: t('actions.saved'),
fullscreen: t('actions.fullscreen'),
exitFullscreen: t('actions.exitFullscreen'),
close: t('actions.close'),
}}
/>
{saveError && (
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
{saveError}
</div>
)}
<div className="flex-1 overflow-hidden">
<CodeEditorSurface
content={content}
onChange={setContent}
markdownPreview={markdownPreview}
isMarkdownFile={isMarkdownFile}
isDarkMode={isDarkMode}
fontSize={fontSize}
showLineNumbers={showLineNumbers}
extensions={extensions}
/>
</div>
<CodeEditorFooter
content={content}
linesLabel={t('footer.lines')}
charactersLabel={t('footer.characters')}
shortcutsLabel={t('footer.shortcuts')}
/>
</div>
</div>
</>
);
}

View File

@@ -1,14 +1,28 @@
import { useState } from 'react'; import { useState } from 'react';
import CodeEditor from '../../../CodeEditor'; import type { MouseEvent, MutableRefObject } from 'react';
import type { EditorSidebarProps } from '../../types/types'; import type { CodeEditorFile } from '../types/types';
import CodeEditor from './CodeEditor';
const AnyCodeEditor = CodeEditor as any; type EditorSidebarProps = {
editingFile: CodeEditorFile | null;
isMobile: boolean;
editorExpanded: boolean;
editorWidth: number;
hasManualWidth: boolean;
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
onCloseEditor: () => void;
onToggleEditorExpand: () => void;
projectPath?: string;
fillSpace?: boolean;
};
export default function EditorSidebar({ export default function EditorSidebar({
editingFile, editingFile,
isMobile, isMobile,
editorExpanded, editorExpanded,
editorWidth, editorWidth,
hasManualWidth,
resizeHandleRef, resizeHandleRef,
onResizeStart, onResizeStart,
onCloseEditor, onCloseEditor,
@@ -24,7 +38,7 @@ export default function EditorSidebar({
if (isMobile || poppedOut) { if (isMobile || poppedOut) {
return ( return (
<AnyCodeEditor <CodeEditor
file={editingFile} file={editingFile}
onClose={() => { onClose={() => {
setPoppedOut(false); setPoppedOut(false);
@@ -36,7 +50,8 @@ export default function EditorSidebar({
); );
} }
const useFlex = editorExpanded || fillSpace; // In files tab, fill the remaining width unless user has dragged manually.
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return ( return (
<> <>
@@ -52,10 +67,10 @@ export default function EditorSidebar({
)} )}
<div <div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlex ? 'flex-1' : ''}`} className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
style={useFlex ? undefined : { width: `${editorWidth}px` }} style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
> >
<AnyCodeEditor <CodeEditor
file={editingFile} file={editingFile}
onClose={onCloseEditor} onClose={onCloseEditor}
projectPath={projectPath} projectPath={projectPath}

View File

@@ -0,0 +1,28 @@
type CodeEditorFooterProps = {
content: string;
linesLabel: string;
charactersLabel: string;
shortcutsLabel: string;
};
export default function CodeEditorFooter({
content,
linesLabel,
charactersLabel,
shortcutsLabel,
}: CodeEditorFooterProps) {
return (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>
{linesLabel} {content.split('\n').length}
</span>
<span>
{charactersLabel} {content.length}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
file: CodeEditorFile;
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
labels: {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
settings: string;
download: string;
save: string;
saving: string;
saved: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
export default function CodeEditorHeader({
file,
isSidebar,
isFullscreen,
isMarkdownFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenSettings,
onDownload,
onSave,
onToggleFullscreen,
onClose,
labels,
}: CodeEditorHeaderProps) {
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
{labels.showingChanges}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{isMarkdownFile && (
<button
type="button"
onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onOpenSettings}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.settings}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={onDownload}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.download}
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={onSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
saveSuccess
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={saveTitle}
>
{saveSuccess ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<Save className="w-4 h-4" />
)}
</button>
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onClose}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={labels.close}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { getEditorLoadingStyles } from '../../utils/editorStyles';
type CodeEditorLoadingStateProps = {
isDarkMode: boolean;
isSidebar: boolean;
loadingText: string;
};
export default function CodeEditorLoadingState({
isDarkMode,
isSidebar,
loadingText,
}: CodeEditorLoadingStateProps) {
return (
<>
<style>{getEditorLoadingStyles(isDarkMode)}</style>
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,62 @@
import CodeMirror from '@uiw/react-codemirror';
import { oneDark } from '@codemirror/theme-one-dark';
import type { Extension } from '@codemirror/state';
import MarkdownPreview from './markdown/MarkdownPreview';
type CodeEditorSurfaceProps = {
content: string;
onChange: (value: string) => void;
markdownPreview: boolean;
isMarkdownFile: boolean;
isDarkMode: boolean;
fontSize: number;
showLineNumbers: boolean;
extensions: Extension[];
};
export default function CodeEditorSurface({
content,
onChange,
markdownPreview,
isMarkdownFile,
isDarkMode,
fontSize,
showLineNumbers,
extensions,
}: CodeEditorSurfaceProps) {
if (markdownPreview && isMarkdownFile) {
return (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
<MarkdownPreview content={content} />
</div>
</div>
);
}
return (
<CodeMirror
value={content}
onChange={onChange}
extensions={extensions}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard';
type MarkdownCodeBlockProps = {
inline?: boolean;
node?: unknown;
} & ComponentProps<'code'>;
export default function MarkdownCodeBlock({
inline,
className,
children,
node: _node,
...props
}: MarkdownCodeBlockProps) {
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
const shouldRenderInline = inline || !looksMultiline;
if (shouldRenderInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
{...props}
>
{children}
</code>
);
}
const languageMatch = /language-(\w+)/.exec(className || '');
const language = languageMatch ? languageMatch[1] : 'text';
return (
<div className="relative group my-2">
{language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={() =>
copyTextToClipboard(rawContent).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{rawContent}
</SyntaxHighlighter>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import type { Components } from 'react-markdown';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import MarkdownCodeBlock from './MarkdownCodeBlock';
type MarkdownPreviewProps = {
content: string;
};
const markdownPreviewComponents: Components = {
code: MarkdownCodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,18 @@
import type { FileTreeViewMode } from '../types/types';
export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const IMAGE_FILE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'ico',
'bmp',
]);

View File

@@ -0,0 +1,224 @@
import {
Archive,
Binary,
Blocks,
BookOpen,
Box,
Braces,
Code2,
Cog,
Coffee,
Cpu,
Database,
File,
FileCheck,
FileCode,
FileCode2,
FileSpreadsheet,
FileText,
FileType,
Flame,
FlaskConical,
Gem,
Globe,
Hash,
Hexagon,
Image,
Lock,
Music2,
NotebookPen,
Palette,
Scroll,
Settings,
Shield,
SquareFunction,
Terminal,
Video,
Workflow,
} from 'lucide-react';
import type { FileIconData, FileIconMap } from '../types/types';
export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP: FileIconMap = {
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb: { icon: NotebookPen, color: 'text-orange-500' },
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
go: { icon: Hexagon, color: 'text-cyan-500' },
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
php: { icon: Blocks, color: 'text-violet-500' },
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
cs: { icon: Hexagon, color: 'text-purple-600' },
swift: { icon: Flame, color: 'text-orange-500' },
lua: { icon: SquareFunction, color: 'text-blue-500' },
r: { icon: FlaskConical, color: 'text-blue-600' },
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte: { icon: FileCode2, color: 'text-orange-500' },
json: { icon: Braces, color: 'text-yellow-600' },
jsonc: { icon: Braces, color: 'text-yellow-500' },
json5: { icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql: { icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto: { icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2: { icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
lock: { icon: Lock, color: 'text-gray-500' },
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib: { icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
const FILENAME_ICON_MAP: FileIconMap = {
Dockerfile: { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json': { icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
Gemfile: { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
Makefile: { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js': { icon: Cog, color: 'text-blue-500' },
'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },
'postcss.config.js': { icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
LICENSE: { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
export function getFileIconData(filename: string): FileIconData {
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
const extension = filename.split('.').pop()?.toLowerCase();
if (extension && FILE_ICON_MAP[extension]) {
return FILE_ICON_MAP[extension];
}
return { icon: File, color: 'text-muted-foreground' };
}

View File

@@ -0,0 +1,44 @@
import { useCallback, useState } from 'react';
type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
const toggleDirectory = useCallback((path: string) => {
setExpandedDirs((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandDirectories = useCallback((paths: string[]) => {
if (paths.length === 0) {
return;
}
setExpandedDirs((previous) => {
const next = new Set(previous);
paths.forEach((path) => next.add(path));
return next;
});
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
};
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const projectName = selectedProject?.name;
if (!projectName) {
setFiles([]);
setLoading(false);
return;
}
const abortController = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
const fetchFiles = async () => {
if (isActive) {
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
if (!response.ok) {
const errorText = await response.text();
console.error('File fetch failed:', response.status, errorText);
if (isActive) {
setFiles([]);
}
return;
}
const data = (await response.json()) as FileTreeNode[];
if (isActive) {
setFiles(data);
}
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return;
}
console.error('Error fetching files:', error);
if (isActive) {
setFiles([]);
}
} finally {
if (isActive) {
setLoading(false);
}
}
};
void fetchFiles();
return () => {
isActive = false;
abortController.abort();
};
}, [selectedProject?.name]);
return {
files,
loading,
};
}

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';
import type { FileTreeNode } from '../types/types';
type UseFileTreeSearchArgs = {
files: FileTreeNode[];
expandDirectories: (paths: string[]) => void;
};
type UseFileTreeSearchResult = {
searchQuery: string;
setSearchQuery: (query: string) => void;
filteredFiles: FileTreeNode[];
};
export function useFileTreeSearch({
files,
expandDirectories,
}: UseFileTreeSearchArgs): UseFileTreeSearchResult {
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState<FileTreeNode[]>(files);
useEffect(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
setFilteredFiles(files);
return;
}
const filtered = filterFileTree(files, query);
setFilteredFiles(filtered);
// Keep search results visible by opening every matching ancestor directory once per query update.
expandDirectories(collectExpandedDirectoryPaths(filtered));
}, [files, searchQuery, expandDirectories]);
return {
searchQuery,
setSearchQuery,
filteredFiles,
};
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
import {
FILE_TREE_DEFAULT_VIEW_MODE,
FILE_TREE_VIEW_MODES,
FILE_TREE_VIEW_MODE_STORAGE_KEY,
} from '../constants/constants';
import type { FileTreeViewMode } from '../types/types';
type UseFileTreeViewModeResult = {
viewMode: FileTreeViewMode;
changeViewMode: (mode: FileTreeViewMode) => void;
};
export function useFileTreeViewMode(): UseFileTreeViewModeResult {
const [viewMode, setViewMode] = useState<FileTreeViewMode>(FILE_TREE_DEFAULT_VIEW_MODE);
useEffect(() => {
try {
const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);
if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {
setViewMode(savedViewMode as FileTreeViewMode);
}
} catch {
// Keep default view mode when storage is unavailable.
}
}, []);
const changeViewMode = useCallback((mode: FileTreeViewMode) => {
setViewMode(mode);
try {
localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Keep runtime state even when persistence fails.
}
}, []);
return {
viewMode,
changeViewMode,
};
}

View File

@@ -0,0 +1,30 @@
import type { LucideIcon } from 'lucide-react';
export type FileTreeViewMode = 'simple' | 'compact' | 'detailed';
export type FileTreeItemType = 'file' | 'directory';
export interface FileTreeNode {
name: string;
type: FileTreeItemType;
path: string;
size?: number;
modified?: string;
permissionsRwx?: string;
children?: FileTreeNode[];
[key: string]: unknown;
}
export interface FileTreeImageSelection {
name: string;
path: string;
projectPath?: string;
projectName: string;
}
export interface FileIconData {
icon: LucideIcon;
color: string;
}
export type FileIconMap = Record<string, FileIconData>;

View File

@@ -0,0 +1,83 @@
import type { TFunction } from 'i18next';
import { IMAGE_FILE_EXTENSIONS } from '../constants/constants';
import type { FileTreeNode } from '../types/types';
export function filterFileTree(items: FileTreeNode[], query: string): FileTreeNode[] {
return items.reduce<FileTreeNode[]>((filteredItems, item) => {
const matchesName = item.name.toLowerCase().includes(query);
const filteredChildren =
item.type === 'directory' && item.children ? filterFileTree(item.children, query) : [];
if (matchesName || filteredChildren.length > 0) {
filteredItems.push({
...item,
children: filteredChildren,
});
}
return filteredItems;
}, []);
}
// During search we auto-expand every directory present in the filtered subtree.
export function collectExpandedDirectoryPaths(items: FileTreeNode[]): string[] {
const paths: string[] = [];
const visit = (nodes: FileTreeNode[]) => {
nodes.forEach((node) => {
if (node.type === 'directory' && node.children && node.children.length > 0) {
paths.push(node.path);
visit(node.children);
}
});
};
visit(items);
return paths;
}
export function formatFileSize(bytes?: number): string {
if (!bytes || bytes === 0) {
return '0 B';
}
const base = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const index = Math.floor(Math.log(bytes) / Math.log(base));
return `${(bytes / Math.pow(base, index)).toFixed(1).replace(/\.0$/, '')} ${sizes[index]}`;
}
export function formatRelativeTime(date: string | undefined, t: TFunction): string {
if (!date) {
return '-';
}
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
if (diffInSeconds < 60) {
return t('fileTree.justNow');
}
if (diffInSeconds < 3600) {
return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
}
if (diffInSeconds < 86400) {
return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
}
if (diffInSeconds < 2592000) {
return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
}
return past.toLocaleDateString();
}
export function isImageFile(filename: string): boolean {
const extension = filename.split('.').pop()?.toLowerCase();
return Boolean(extension && IMAGE_FILE_EXTENSIONS.has(extension));
}

View File

@@ -0,0 +1,103 @@
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 './FileTreeBody';
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
import FileTreeHeader from './FileTreeHeader';
import FileTreeLoadingState from './FileTreeLoadingState';
import { Project } from '../../../types/app';
type FileTreeProps = {
selectedProject: Project | null;
onFileOpen?: (filePath: string) => void;
}
export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {
const { t } = useTranslation();
const [selectedImage, setSelectedImage] = useState<FileTreeImageSelection | null>(null);
const { files, loading } = useFileTreeData(selectedProject);
const { viewMode, changeViewMode } = useFileTreeViewMode();
const { expandedDirs, toggleDirectory, expandDirectories } = useExpandedDirectories();
const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({
files,
expandDirectories,
});
const renderFileIcon = useCallback((filename: string) => {
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE_CLASS, color)} />;
}, []);
// Centralized click behavior keeps file actions identical across all presentation modes.
const handleItemClick = useCallback(
(item: FileTreeNode) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
return;
}
if (isImageFile(item.name) && selectedProject) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name,
});
return;
}
onFileOpen?.(item.path);
},
[onFileOpen, selectedProject, toggleDirectory],
);
const formatRelativeTimeLabel = useCallback(
(date?: string) => formatRelativeTime(date, t),
[t],
);
if (loading) {
return <FileTreeLoadingState />;
}
return (
<div className="h-full flex flex-col bg-background">
<FileTreeHeader
viewMode={viewMode}
onViewModeChange={changeViewMode}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
/>
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
<FileTreeBody
files={files}
filteredFiles={filteredFiles}
searchQuery={searchQuery}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={handleItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTimeLabel}
/>
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import type { ReactNode } from 'react';
import { Folder, Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from '../../ui/scroll-area';
import type { FileTreeNode, FileTreeViewMode } from '../types/types';
import FileTreeEmptyState from './FileTreeEmptyState';
import FileTreeList from './FileTreeList';
type FileTreeBodyProps = {
files: FileTreeNode[];
filteredFiles: FileTreeNode[];
searchQuery: string;
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNode) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
export default function FileTreeBody({
files,
filteredFiles,
searchQuery,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeBodyProps) {
const { t } = useTranslation();
return (
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<FileTreeEmptyState
icon={Folder}
title={t('fileTree.noFilesFound')}
description={t('fileTree.checkProjectPath')}
/>
) : filteredFiles.length === 0 && searchQuery ? (
<FileTreeEmptyState
icon={Search}
title={t('fileTree.noMatchesFound')}
description={t('fileTree.tryDifferentSearch')}
/>
) : (
<FileTreeList
items={filteredFiles}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
)}
</ScrollArea>
);
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
export default function FileTreeDetailedColumns() {
const { t } = useTranslation();
return (
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { LucideIcon } from 'lucide-react';
type FileTreeEmptyStateProps = {
icon: LucideIcon;
title: string;
description: string;
};
export default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) {
return (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Icon className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{title}</h4>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { Eye, List, Search, TableProperties, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import type { FileTreeViewMode } from '../types/types';
type FileTreeHeaderProps = {
viewMode: FileTreeViewMode;
onViewModeChange: (mode: FileTreeViewMode) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
};
export default function FileTreeHeader({
viewMode,
onViewModeChange,
searchQuery,
onSearchQueryChange,
}: FileTreeHeaderProps) {
const { t } = useTranslation();
return (
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => onViewModeChange('detailed')}
title={t('fileTree.detailedView')}
>
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => onSearchQueryChange('')}
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from 'react';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
import FileTreeNode from './FileTreeNode';
type FileTreeListProps = {
items: FileTreeNodeType[];
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNodeType) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
export default function FileTreeList({
items,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeListProps) {
return (
<div>
{items.map((item) => (
<FileTreeNode
key={item.path}
item={item}
level={0}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { useTranslation } from 'react-i18next';
export default function FileTreeLoadingState() {
const { t } = useTranslation();
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">{t('fileTree.loading')}</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import type { ReactNode } from 'react';
import { ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '../../../lib/utils';
import type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';
type FileTreeNodeProps = {
item: FileTreeNodeType;
level: number;
viewMode: FileTreeViewMode;
expandedDirs: Set<string>;
onItemClick: (item: FileTreeNodeType) => void;
renderFileIcon: (filename: string) => ReactNode;
formatFileSize: (bytes?: number) => string;
formatRelativeTime: (date?: string) => string;
};
type TreeItemIconProps = {
item: FileTreeNodeType;
isOpen: boolean;
renderFileIcon: (filename: string) => ReactNode;
};
function TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {
if (item.type === 'directory') {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90',
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return <span className="flex items-center flex-shrink-0 ml-[18px]">{renderFileIcon(item.name)}</span>;
}
export default function FileTreeNode({
item,
level,
viewMode,
expandedDirs,
onItemClick,
renderFileIcon,
formatFileSize,
formatRelativeTime,
}: FileTreeNodeProps) {
const isDirectory = item.type === 'directory';
const isOpen = isDirectory && expandedDirs.has(item.path);
const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);
const nameClassName = cn(
'text-[13px] leading-tight truncate',
isDirectory ? 'font-medium text-foreground' : 'text-foreground/90',
);
// View mode only changes the row layout; selection, expansion, and recursion stay shared.
const rowClassName = cn(
viewMode === 'detailed'
? 'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100'
: viewMode === 'compact'
? 'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100'
: 'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm hover:bg-accent/60 transition-colors duration-100',
isDirectory && isOpen && 'border-l-2 border-primary/30',
(isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',
);
return (
<div className="select-none">
<div
className={rowClassName}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => onItemClick(item)}
>
{viewMode === 'detailed' ? (
<>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-sm text-muted-foreground">{formatRelativeTime(item.modified)}</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">{item.permissionsRwx || ''}</div>
</>
) : viewMode === 'compact' ? (
<>
<div className="flex items-center gap-1.5 min-w-0">
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</>
) : (
<>
<TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />
<span className={nameClassName}>{item.name}</span>
</>
)}
</div>
{isDirectory && isOpen && hasChildren && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{item.children?.map((child) => (
<FileTreeNode
key={child.path}
item={child}
level={level + 1}
viewMode={viewMode}
expandedDirs={expandedDirs}
onItemClick={onItemClick}
renderFileIcon={renderFileIcon}
formatFileSize={formatFileSize}
formatRelativeTime={formatRelativeTime}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -1,16 +1,22 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import { X } from 'lucide-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 imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
const [imageUrl, setImageUrl] = useState(null); const [imageUrl, setImageUrl] = useState<string | null>(null);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
let objectUrl; let objectUrl: string | null = null;
const controller = new AbortController(); const controller = new AbortController();
const loadImage = async () => { const loadImage = async () => {
@@ -20,7 +26,7 @@ function ImageViewer({ file, onClose }) {
setImageUrl(null); setImageUrl(null);
const response = await authenticatedFetch(imagePath, { const response = await authenticatedFetch(imagePath, {
signal: controller.signal signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -30,11 +36,11 @@ function ImageViewer({ file, onClose }) {
const blob = await response.blob(); const blob = await response.blob();
objectUrl = URL.createObjectURL(blob); objectUrl = URL.createObjectURL(blob);
setImageUrl(objectUrl); setImageUrl(objectUrl);
} catch (err) { } catch (loadError: unknown) {
if (err.name === 'AbortError') { if (loadError instanceof Error && loadError.name === 'AbortError') {
return; return;
} }
console.error('Error loading image:', err); console.error('Error loading image:', loadError);
setError('Unable to load image'); setError('Unable to load image');
} finally { } finally {
setLoading(false); 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="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="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"> <div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{file.name}</h3>
{file.name} <Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
</h3>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </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]"> <div className="p-4 flex justify-center items-center bg-gray-50 dark:bg-gray-900 min-h-[400px]">
{loading && ( {loading && (
<div className="text-center text-gray-500 dark:text-gray-400"> <div className="text-center text-gray-500 dark:text-gray-400">
<p>Loading image</p> <p>Loading image...</p>
</div> </div>
)} )}
{!loading && imageUrl && ( {!loading && imageUrl && (
@@ -90,13 +89,9 @@ function ImageViewer({ file, onClose }) {
</div> </div>
<div className="p-4 border-t bg-gray-50 dark:bg-gray-800"> <div className="p-4 border-t bg-gray-50 dark:bg-gray-800">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">{file.path}</p>
{file.path}
</p>
</div> </div>
</div> </div>
</div> </div>
); );
} }
export default ImageViewer;

View File

@@ -0,0 +1,70 @@
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
export const DEFAULT_BRANCH = 'main';
export const RECENT_COMMITS_LIMIT = 10;
export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
{ key: 'modified', status: 'M' },
{ key: 'added', status: 'A' },
{ key: 'deleted', status: 'D' },
{ key: 'untracked', status: 'U' },
];
export const FILE_STATUS_LABELS: Record<FileStatusCode, string> = {
M: 'Modified',
A: 'Added',
D: 'Deleted',
U: 'Untracked',
};
export const FILE_STATUS_BADGE_CLASSES: Record<FileStatusCode, string> = {
M: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50',
A: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50',
D: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50',
U: 'bg-muted text-muted-foreground border-border',
};
export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
discard: 'Discard Changes',
delete: 'Delete File',
commit: 'Confirm Commit',
pull: 'Confirm Pull',
push: 'Confirm Push',
publish: 'Publish Branch',
};
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
discard: 'Discard',
delete: 'Delete',
commit: 'Commit',
pull: 'Pull',
push: 'Push',
publish: 'Publish',
};
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
discard: 'bg-red-600 hover:bg-red-700',
delete: 'bg-red-600 hover:bg-red-700',
commit: 'bg-primary hover:bg-primary/90',
pull: 'bg-green-600 hover:bg-green-700',
push: 'bg-orange-600 hover:bg-orange-700',
publish: 'bg-purple-600 hover:bg-purple-700',
};
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
discard: 'bg-red-100 dark:bg-red-900/30',
delete: 'bg-red-100 dark:bg-red-900/30',
commit: 'bg-yellow-100 dark:bg-yellow-900/30',
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
push: 'bg-yellow-100 dark:bg-yellow-900/30',
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
};
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
discard: 'text-red-600 dark:text-red-400',
delete: 'text-red-600 dark:text-red-400',
commit: 'text-yellow-600 dark:text-yellow-400',
pull: 'text-yellow-600 dark:text-yellow-400',
push: 'text-yellow-600 dark:text-yellow-400',
publish: 'text-yellow-600 dark:text-yellow-400',
};

View File

@@ -0,0 +1,710 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
import type {
GitApiErrorResponse,
GitBranchesResponse,
GitCommitSummary,
GitCommitsResponse,
GitDiffMap,
GitDiffResponse,
GitFileWithDiffResponse,
GitGenerateMessageResponse,
GitOperationResponse,
GitPanelController,
GitRemoteStatus,
GitStatusResponse,
UseGitPanelControllerOptions,
} from '../types/types';
import { getAllChangedFiles } from '../utils/gitPanelUtils';
import { useSelectedProvider } from './useSelectedProvider';
// ! use authenticatedFetch directly. fetchWithAuth is redundant
const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>;
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
async function readJson<T>(response: Response, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
const data = (await response.json()) as T;
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
return data;
}
export function useGitPanelController({
selectedProject,
activeView,
onFileOpen,
}: UseGitPanelControllerOptions): GitPanelController {
const [gitStatus, setGitStatus] = useState<GitStatusResponse | null>(null);
const [gitDiff, setGitDiff] = useState<GitDiffMap>({});
const [isLoading, setIsLoading] = useState(false);
const [currentBranch, setCurrentBranch] = useState('');
const [branches, setBranches] = useState<string[]>([]);
const [recentCommits, setRecentCommits] = useState<GitCommitSummary[]>([]);
const [commitDiffs, setCommitDiffs] = useState<GitDiffMap>({});
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null);
const [isCreatingBranch, setIsCreatingBranch] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null);
useEffect(() => {
selectedProjectNameRef.current = selectedProject?.name ?? null;
}, [selectedProject]);
const provider = useSelectedProvider();
const fetchFileDiff = useCallback(
async (filePath: string, signal?: AbortSignal) => {
if (!selectedProject) {
return;
}
const projectName = selectedProject.name;
try {
const response = await fetchWithAuth(
`/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`,
{ signal },
);
const data = await readJson<GitDiffResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName
) {
return;
}
if (!data.error && data.diff) {
setGitDiff((previous) => ({
...previous,
[filePath]: data.diff as string,
}));
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
console.error('Error fetching file diff:', error);
}
},
[selectedProject],
);
const fetchGitStatus = useCallback(async (signal?: AbortSignal) => {
if (!selectedProject) {
return;
}
const projectName = selectedProject.name;
setIsLoading(true);
try {
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal });
const data = await readJson<GitStatusResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName
) {
return;
}
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus({ error: data.error, details: data.details });
setCurrentBranch('');
return;
}
setGitStatus(data);
setCurrentBranch(data.branch || DEFAULT_BRANCH);
const changedFiles = getAllChangedFiles(data);
changedFiles.forEach((filePath) => {
void fetchFileDiff(filePath, signal);
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
if (
selectedProjectNameRef.current !== projectName
) {
return;
}
console.error('Error fetching git status:', error);
setGitStatus({ error: 'Git operation failed', details: String(error) });
setCurrentBranch('');
} finally {
if (
signal?.aborted ||
selectedProjectNameRef.current !== projectName
) {
return;
}
setIsLoading(false);
}
}, [fetchFileDiff, selectedProject]);
const fetchBranches = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
const data = await readJson<GitBranchesResponse>(response);
if (!data.error && data.branches) {
setBranches(data.branches);
return;
}
setBranches([]);
} catch (error) {
console.error('Error fetching branches:', error);
setBranches([]);
}
}, [selectedProject]);
const fetchRemoteStatus = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);
if (!data.error) {
setRemoteStatus(data as GitRemoteStatus);
return;
}
setRemoteStatus(null);
} catch (error) {
console.error('Error fetching remote status:', error);
setRemoteStatus(null);
}
}, [selectedProject]);
const switchBranch = useCallback(
async (branchName: string) => {
if (!selectedProject) {
return false;
}
try {
const response = await fetchWithAuth('/api/git/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: branchName,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Failed to switch branch:', data.error);
return false;
}
setCurrentBranch(branchName);
void fetchGitStatus();
return true;
} catch (error) {
console.error('Error switching branch:', error);
return false;
}
},
[fetchGitStatus, selectedProject],
);
const createBranch = useCallback(
async (branchName: string) => {
const trimmedBranchName = branchName.trim();
if (!selectedProject || !trimmedBranchName) {
return false;
}
setIsCreatingBranch(true);
try {
const response = await fetchWithAuth('/api/git/create-branch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: trimmedBranchName,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Failed to create branch:', data.error);
return false;
}
setCurrentBranch(trimmedBranchName);
void fetchBranches();
void fetchGitStatus();
return true;
} catch (error) {
console.error('Error creating branch:', error);
return false;
} finally {
setIsCreatingBranch(false);
}
},
[fetchBranches, fetchGitStatus, selectedProject],
);
const handleFetch = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsFetching(true);
try {
const response = await fetchWithAuth('/api/git/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
console.error('Fetch failed:', data.error);
} catch (error) {
console.error('Error fetching from remote:', error);
} finally {
setIsFetching(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePull = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPulling(true);
try {
const response = await fetchWithAuth('/api/git/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
console.error('Pull failed:', data.error);
} catch (error) {
console.error('Error pulling from remote:', error);
} finally {
setIsPulling(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePush = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPushing(true);
try {
const response = await fetchWithAuth('/api/git/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
console.error('Push failed:', data.error);
} catch (error) {
console.error('Error pushing to remote:', error);
} finally {
setIsPushing(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePublish = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPublishing(true);
try {
const response = await fetchWithAuth('/api/git/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
branch: currentBranch,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
console.error('Publish failed:', data.error);
} catch (error) {
console.error('Error publishing branch:', error);
} finally {
setIsPublishing(false);
}
}, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);
const discardChanges = useCallback(
async (filePath: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth('/api/git/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
file: filePath,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
return;
}
console.error('Discard failed:', data.error);
} catch (error) {
console.error('Error discarding changes:', error);
}
},
[fetchGitStatus, selectedProject],
);
const deleteUntrackedFile = useCallback(
async (filePath: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth('/api/git/delete-untracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
file: filePath,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
return;
}
console.error('Delete failed:', data.error);
} catch (error) {
console.error('Error deleting untracked file:', error);
}
},
[fetchGitStatus, selectedProject],
);
const fetchRecentCommits = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(
`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,
);
const data = await readJson<GitCommitsResponse>(response);
if (!data.error && data.commits) {
setRecentCommits(data.commits);
}
} catch (error) {
console.error('Error fetching commits:', error);
}
}, [selectedProject]);
const fetchCommitDiff = useCallback(
async (commitHash: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(
`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,
);
const data = await readJson<GitDiffResponse>(response);
if (!data.error && data.diff) {
setCommitDiffs((previous) => ({
...previous,
[commitHash]: data.diff as string,
}));
}
} catch (error) {
console.error('Error fetching commit diff:', error);
}
},
[selectedProject],
);
const generateCommitMessage = useCallback(
async (files: string[]) => {
if (!selectedProject || files.length === 0) {
return null;
}
try {
const response = await authenticatedFetch('/api/git/generate-commit-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
files,
provider,
}),
});
const data = await readJson<GitGenerateMessageResponse>(response);
if (data.message) {
return data.message;
}
console.error('Failed to generate commit message:', data.error);
return null;
} catch (error) {
console.error('Error generating commit message:', error);
return null;
}
},
[provider, selectedProject],
);
const commitChanges = useCallback(
async (message: string, files: string[]) => {
if (!selectedProject || !message.trim() || files.length === 0) {
return false;
}
try {
const response = await fetchWithAuth('/api/git/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
message,
files,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return true;
}
console.error('Commit failed:', data.error);
return false;
} catch (error) {
console.error('Error committing changes:', error);
return false;
}
},
[fetchGitStatus, fetchRemoteStatus, selectedProject],
);
const createInitialCommit = useCallback(async () => {
if (!selectedProject) {
throw new Error('No project selected');
}
setIsCreatingInitialCommit(true);
try {
const response = await fetchWithAuth('/api/git/initial-commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.name,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return true;
}
throw new Error(data.error || 'Failed to create initial commit');
} catch (error) {
console.error('Error creating initial commit:', error);
throw error;
} finally {
setIsCreatingInitialCommit(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const openFile = useCallback(
async (filePath: string) => {
if (!onFileOpen) {
return;
}
if (!selectedProject) {
onFileOpen(filePath);
return;
}
try {
const response = await fetchWithAuth(
`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
);
const data = await readJson<GitFileWithDiffResponse>(response);
if (data.error) {
console.error('Error fetching file with diff:', data.error);
onFileOpen(filePath);
return;
}
onFileOpen(filePath, {
old_string: data.oldContent || '',
new_string: data.currentContent || '',
});
} catch (error) {
console.error('Error opening file:', error);
onFileOpen(filePath);
}
},
[onFileOpen, selectedProject],
);
const refreshAll = useCallback(() => {
void fetchGitStatus();
void fetchBranches();
void fetchRemoteStatus();
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
useEffect(() => {
const controller = new AbortController();
// Reset repository-scoped state when project changes to avoid stale UI.
setCurrentBranch('');
setBranches([]);
setGitStatus(null);
setRemoteStatus(null);
setGitDiff({});
setRecentCommits([]);
setCommitDiffs({});
setIsLoading(false);
if (!selectedProject) {
return () => {
controller.abort();
};
}
void fetchGitStatus(controller.signal);
void fetchBranches();
void fetchRemoteStatus();
return () => {
controller.abort();
};
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
useEffect(() => {
if (!selectedProject || activeView !== 'history') {
return;
}
void fetchRecentCommits();
}, [activeView, fetchRecentCommits, selectedProject]);
return {
gitStatus,
gitDiff,
isLoading,
currentBranch,
branches,
recentCommits,
commitDiffs,
remoteStatus,
isCreatingBranch,
isFetching,
isPulling,
isPushing,
isPublishing,
isCreatingInitialCommit,
refreshAll,
switchBranch,
createBranch,
handleFetch,
handlePull,
handlePush,
handlePublish,
discardChanges,
deleteUntrackedFile,
fetchCommitDiff,
generateCommitMessage,
commitChanges,
createInitialCommit,
openFile,
};
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
export function useSelectedProvider() {
const [provider, setProvider] = useState(() => {
return localStorage.getItem('selected-provider') || 'claude';
});
useEffect(() => {
// Keep provider in sync when another tab changes the selected provider.
const handleStorageChange = () => {
const nextProvider = localStorage.getItem('selected-provider') || 'claude';
setProvider(nextProvider);
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
return provider;
}

View File

@@ -0,0 +1,135 @@
import type { Project } from '../../../types/app';
export type GitPanelView = 'changes' | 'history';
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
export type FileDiffInfo = {
old_string: string;
new_string: string;
};
export type FileOpenHandler = (filePath: string, diffInfo?: FileDiffInfo) => void;
export type GitPanelProps = {
selectedProject: Project | null;
isMobile?: boolean;
onFileOpen?: FileOpenHandler;
};
export type GitStatusResponse = {
branch?: string;
hasCommits?: boolean;
modified?: string[];
added?: string[];
deleted?: string[];
untracked?: string[];
error?: string;
details?: string;
};
export type GitRemoteStatus = {
hasRemote?: boolean;
hasUpstream?: boolean;
branch?: string;
remoteBranch?: string;
remoteName?: string | null;
ahead?: number;
behind?: number;
isUpToDate?: boolean;
message?: string;
error?: string;
};
export type GitCommitSummary = {
hash: string;
author: string;
email?: string;
date: string;
message: string;
stats?: string;
};
export type GitDiffMap = Record<string, string>;
export type GitStatusGroupEntry = {
key: GitStatusFileGroup;
status: FileStatusCode;
};
export type ConfirmationRequest = {
type: ConfirmActionType;
message: string;
onConfirm: () => Promise<void> | void;
};
export type UseGitPanelControllerOptions = {
selectedProject: Project | null;
activeView: GitPanelView;
onFileOpen?: FileOpenHandler;
};
export type GitPanelController = {
gitStatus: GitStatusResponse | null;
gitDiff: GitDiffMap;
isLoading: boolean;
currentBranch: string;
branches: string[];
recentCommits: GitCommitSummary[];
commitDiffs: GitDiffMap;
remoteStatus: GitRemoteStatus | null;
isCreatingBranch: boolean;
isFetching: boolean;
isPulling: boolean;
isPushing: boolean;
isPublishing: boolean;
isCreatingInitialCommit: boolean;
refreshAll: () => void;
switchBranch: (branchName: string) => Promise<boolean>;
createBranch: (branchName: string) => Promise<boolean>;
handleFetch: () => Promise<void>;
handlePull: () => Promise<void>;
handlePush: () => Promise<void>;
handlePublish: () => Promise<void>;
discardChanges: (filePath: string) => Promise<void>;
deleteUntrackedFile: (filePath: string) => Promise<void>;
fetchCommitDiff: (commitHash: string) => Promise<void>;
generateCommitMessage: (files: string[]) => Promise<string | null>;
commitChanges: (message: string, files: string[]) => Promise<boolean>;
createInitialCommit: () => Promise<boolean>;
openFile: (filePath: string) => Promise<void>;
};
export type GitApiErrorResponse = {
error?: string;
details?: string;
};
export type GitDiffResponse = GitApiErrorResponse & {
diff?: string;
};
export type GitBranchesResponse = GitApiErrorResponse & {
branches?: string[];
};
export type GitCommitsResponse = GitApiErrorResponse & {
commits?: GitCommitSummary[];
};
export type GitOperationResponse = GitApiErrorResponse & {
success?: boolean;
output?: string;
};
export type GitGenerateMessageResponse = GitApiErrorResponse & {
message?: string;
};
export type GitFileWithDiffResponse = GitApiErrorResponse & {
oldContent?: string;
currentContent?: string;
isDeleted?: boolean;
isUntracked?: boolean;
};

View File

@@ -0,0 +1,26 @@
import { FILE_STATUS_BADGE_CLASSES, FILE_STATUS_GROUPS, FILE_STATUS_LABELS } from '../constants/constants';
import type { FileStatusCode, GitStatusResponse } from '../types/types';
export function getAllChangedFiles(gitStatus: GitStatusResponse | null): string[] {
if (!gitStatus) {
return [];
}
return FILE_STATUS_GROUPS.flatMap(({ key }) => gitStatus[key] || []);
}
export function getChangedFileCount(gitStatus: GitStatusResponse | null): number {
return getAllChangedFiles(gitStatus).length;
}
export function hasChangedFiles(gitStatus: GitStatusResponse | null): boolean {
return getChangedFileCount(gitStatus) > 0;
}
export function getStatusLabel(status: FileStatusCode): string {
return FILE_STATUS_LABELS[status] || status;
}
export function getStatusBadgeClass(status: FileStatusCode): string {
return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;
}

View File

@@ -0,0 +1,150 @@
import { useCallback, useState } from 'react';
import { useGitPanelController } from '../hooks/useGitPanelController';
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
import ChangesView from '../view/changes/ChangesView';
import HistoryView from '../view/history/HistoryView';
import GitPanelHeader from '../view/GitPanelHeader';
import GitRepositoryErrorState from '../view/GitRepositoryErrorState';
import GitViewTabs from '../view/GitViewTabs';
import ConfirmActionModal from '../view/modals/ConfirmActionModal';
export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }: GitPanelProps) {
const [activeView, setActiveView] = useState<GitPanelView>('changes');
const [wrapText, setWrapText] = useState(true);
const [hasExpandedFiles, setHasExpandedFiles] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmationRequest | null>(null);
const {
gitStatus,
gitDiff,
isLoading,
currentBranch,
branches,
recentCommits,
commitDiffs,
remoteStatus,
isCreatingBranch,
isFetching,
isPulling,
isPushing,
isPublishing,
isCreatingInitialCommit,
refreshAll,
switchBranch,
createBranch,
handleFetch,
handlePull,
handlePush,
handlePublish,
discardChanges,
deleteUntrackedFile,
fetchCommitDiff,
generateCommitMessage,
commitChanges,
createInitialCommit,
openFile,
} = useGitPanelController({
selectedProject,
activeView,
onFileOpen,
});
const executeConfirmedAction = useCallback(async () => {
if (!confirmAction) {
return;
}
const actionToExecute = confirmAction;
setConfirmAction(null);
try {
await actionToExecute.onConfirm();
} catch (error) {
console.error('Error executing confirmation action:', error);
}
}, [confirmAction]);
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a project to view source control</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<GitPanelHeader
isMobile={isMobile}
currentBranch={currentBranch}
branches={branches}
remoteStatus={remoteStatus}
isLoading={isLoading}
isCreatingBranch={isCreatingBranch}
isFetching={isFetching}
isPulling={isPulling}
isPushing={isPushing}
isPublishing={isPublishing}
onRefresh={refreshAll}
onSwitchBranch={switchBranch}
onCreateBranch={createBranch}
onFetch={handleFetch}
onPull={handlePull}
onPush={handlePush}
onPublish={handlePublish}
onRequestConfirmation={setConfirmAction}
/>
{gitStatus?.error ? (
<GitRepositoryErrorState error={gitStatus.error} details={gitStatus.details} />
) : (
<>
<GitViewTabs
activeView={activeView}
isHidden={hasExpandedFiles}
onChange={setActiveView}
/>
{activeView === 'changes' && (
<ChangesView
isMobile={isMobile}
gitStatus={gitStatus}
gitDiff={gitDiff}
isLoading={isLoading}
wrapText={wrapText}
isCreatingInitialCommit={isCreatingInitialCommit}
onWrapTextChange={setWrapText}
onCreateInitialCommit={createInitialCommit}
onOpenFile={openFile}
onDiscardFile={discardChanges}
onDeleteFile={deleteUntrackedFile}
onCommitChanges={commitChanges}
onGenerateCommitMessage={generateCommitMessage}
onRequestConfirmation={setConfirmAction}
onExpandedFilesChange={setHasExpandedFiles}
/>
)}
{activeView === 'history' && (
<HistoryView
isMobile={isMobile}
isLoading={isLoading}
recentCommits={recentCommits}
commitDiffs={commitDiffs}
wrapText={wrapText}
onFetchCommitDiff={fetchCommitDiff}
/>
)}
</>
)}
<ConfirmActionModal
action={confirmAction}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
void executeConfirmedAction();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
import NewBranchModal from './modals/NewBranchModal';
type GitPanelHeaderProps = {
isMobile: boolean;
currentBranch: string;
branches: string[];
remoteStatus: GitRemoteStatus | null;
isLoading: boolean;
isCreatingBranch: boolean;
isFetching: boolean;
isPulling: boolean;
isPushing: boolean;
isPublishing: boolean;
onRefresh: () => void;
onSwitchBranch: (branchName: string) => Promise<boolean>;
onCreateBranch: (branchName: string) => Promise<boolean>;
onFetch: () => Promise<void>;
onPull: () => Promise<void>;
onPush: () => Promise<void>;
onPublish: () => Promise<void>;
onRequestConfirmation: (request: ConfirmationRequest) => void;
};
export default function GitPanelHeader({
isMobile,
currentBranch,
branches,
remoteStatus,
isLoading,
isCreatingBranch,
isFetching,
isPulling,
isPushing,
isPublishing,
onRefresh,
onSwitchBranch,
onCreateBranch,
onFetch,
onPull,
onPush,
onPublish,
onRequestConfirmation,
}: GitPanelHeaderProps) {
const [showBranchDropdown, setShowBranchDropdown] = useState(false);
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowBranchDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const aheadCount = remoteStatus?.ahead || 0;
const behindCount = remoteStatus?.behind || 0;
const remoteName = remoteStatus?.remoteName || 'remote';
const shouldShowFetchButton = aheadCount > 0 && behindCount > 0;
const requestPullConfirmation = () => {
onRequestConfirmation({
type: 'pull',
message: `Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}?`,
onConfirm: onPull,
});
};
const requestPushConfirmation = () => {
onRequestConfirmation({
type: 'push',
message: `Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}?`,
onConfirm: onPush,
});
};
const requestPublishConfirmation = () => {
onRequestConfirmation({
type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteName}?`,
onConfirm: onPublish,
});
};
const handleSwitchBranch = async (branchName: string) => {
try {
const success = await onSwitchBranch(branchName);
if (success) {
setShowBranchDropdown(false);
}
} catch (error) {
console.error('[GitPanelHeader] Failed to switch branch:', error);
}
};
const handleFetch = async () => {
try {
await onFetch();
} catch (error) {
console.error('[GitPanelHeader] Failed to fetch remote changes:', error);
}
};
return (
<>
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowBranchDropdown((previous) => !previous)}
className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
>
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<span className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{remoteStatus?.hasRemote && (
<span className="flex items-center gap-1 text-xs">
{aheadCount > 0 && (
<span
className="text-green-600 dark:text-green-400"
title={`${aheadCount} commit${aheadCount !== 1 ? 's' : ''} ahead`}
>
{'\u2191'}
{aheadCount}
</span>
)}
{behindCount > 0 && (
<span
className="text-primary"
title={`${behindCount} commit${behindCount !== 1 ? 's' : ''} behind`}
>
{'\u2193'}
{behindCount}
</span>
)}
{remoteStatus.isUpToDate && (
<span className="text-muted-foreground" title="Up to date with remote">
{'\u2713'}
</span>
)}
</span>
)}
</span>
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button>
{showBranchDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
<div className="py-1 max-h-64 overflow-y-auto">
{branches.map((branch) => (
<button
key={branch}
onClick={() => void handleSwitchBranch(branch)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
}`}
>
<span className="flex items-center space-x-2">
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
</span>
</button>
))}
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setShowNewBranchModal(true);
setShowBranchDropdown(false);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
>
<Plus className="w-3 h-3" />
<span>Create new branch</span>
</button>
</div>
</div>
)}
</div>
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
{remoteStatus?.hasRemote && (
<>
{!remoteStatus.hasUpstream && (
<button
onClick={requestPublishConfirmation}
disabled={isPublishing}
className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Publish branch "${currentBranch}" to ${remoteName}`}
>
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
</button>
)}
{remoteStatus.hasUpstream && !remoteStatus.isUpToDate && (
<>
{behindCount > 0 && (
<button
onClick={requestPullConfirmation}
disabled={isPulling}
className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${behindCount}`}</span>
</button>
)}
{aheadCount > 0 && (
<button
onClick={requestPushConfirmation}
disabled={isPushing}
className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${aheadCount}`}</span>
</button>
)}
{shouldShowFetchButton && (
<button
onClick={() => void handleFetch()}
disabled={isFetching}
className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Fetch from ${remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
</button>
)}
</>
)}
</>
)}
<button
onClick={onRefresh}
disabled={isLoading}
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
title="Refresh git status"
>
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
</button>
</div>
</div>
<NewBranchModal
isOpen={showNewBranchModal}
currentBranch={currentBranch}
isCreatingBranch={isCreatingBranch}
onClose={() => setShowNewBranchModal(false)}
onCreateBranch={onCreateBranch}
/>
</>
);
}

View File

@@ -0,0 +1,27 @@
import { GitBranch } from 'lucide-react';
type GitRepositoryErrorStateProps = {
error: string;
details?: string;
};
export default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
<GitBranch className="w-8 h-8 opacity-40" />
</div>
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{error}</h3>
{details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{details}</p>
)}
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
<p className="text-sm text-primary text-center">
<strong>Tip:</strong> Run{' '}
<code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code>{' '}
in your project directory to initialize git source control.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { FileText, History } from 'lucide-react';
import type { GitPanelView } from '../types/types';
type GitViewTabsProps = {
activeView: GitPanelView;
isHidden: boolean;
onChange: (view: GitPanelView) => void;
};
export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) {
return (
<div
className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'
}`}
>
<button
onClick={() => onChange('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
</span>
</button>
<button
onClick={() => onChange('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';
import CommitComposer from './CommitComposer';
import FileChangeList from './FileChangeList';
import FileSelectionControls from './FileSelectionControls';
import FileStatusLegend from './FileStatusLegend';
type ChangesViewProps = {
isMobile: boolean;
gitStatus: GitStatusResponse | null;
gitDiff: GitDiffMap;
isLoading: boolean;
wrapText: boolean;
isCreatingInitialCommit: boolean;
onWrapTextChange: (wrapText: boolean) => void;
onCreateInitialCommit: () => Promise<boolean>;
onOpenFile: (filePath: string) => Promise<void>;
onDiscardFile: (filePath: string) => Promise<void>;
onDeleteFile: (filePath: string) => Promise<void>;
onCommitChanges: (message: string, files: string[]) => Promise<boolean>;
onGenerateCommitMessage: (files: string[]) => Promise<string | null>;
onRequestConfirmation: (request: ConfirmationRequest) => void;
onExpandedFilesChange: (hasExpandedFiles: boolean) => void;
};
export default function ChangesView({
isMobile,
gitStatus,
gitDiff,
isLoading,
wrapText,
isCreatingInitialCommit,
onWrapTextChange,
onCreateInitialCommit,
onOpenFile,
onDiscardFile,
onDeleteFile,
onCommitChanges,
onGenerateCommitMessage,
onRequestConfirmation,
onExpandedFilesChange,
}: ChangesViewProps) {
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);
const hasExpandedFiles = expandedFiles.size > 0;
useEffect(() => {
if (!gitStatus || gitStatus.error) {
setSelectedFiles(new Set());
return;
}
// Preserve previous behavior: every fresh status snapshot reselects changed files.
setSelectedFiles(new Set(getAllChangedFiles(gitStatus)));
}, [gitStatus]);
useEffect(() => {
onExpandedFilesChange(hasExpandedFiles);
}, [hasExpandedFiles, onExpandedFilesChange]);
useEffect(() => {
return () => {
onExpandedFilesChange(false);
};
}, [onExpandedFilesChange]);
const toggleFileExpanded = useCallback((filePath: string) => {
setExpandedFiles((previous) => {
const next = new Set(previous);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const toggleFileSelected = useCallback((filePath: string) => {
setSelectedFiles((previous) => {
const next = new Set(previous);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const requestFileAction = useCallback(
(filePath: string, status: FileStatusCode) => {
if (status === 'U') {
onRequestConfirmation({
type: 'delete',
message: `Delete untracked file "${filePath}"? This action cannot be undone.`,
onConfirm: async () => {
await onDeleteFile(filePath);
},
});
return;
}
onRequestConfirmation({
type: 'discard',
message: `Discard all changes to "${filePath}"? This action cannot be undone.`,
onConfirm: async () => {
await onDiscardFile(filePath);
},
});
},
[onDeleteFile, onDiscardFile, onRequestConfirmation],
);
const commitSelectedFiles = useCallback(
(message: string) => {
return onCommitChanges(message, Array.from(selectedFiles));
},
[onCommitChanges, selectedFiles],
);
const generateMessageForSelection = useCallback(() => {
return onGenerateCommitMessage(Array.from(selectedFiles));
}, [onGenerateCommitMessage, selectedFiles]);
return (
<>
<CommitComposer
isMobile={isMobile}
selectedFileCount={selectedFiles.size}
isHidden={hasExpandedFiles}
onCommit={commitSelectedFiles}
onGenerateMessage={generateMessageForSelection}
onRequestConfirmation={onRequestConfirmation}
/>
{gitStatus && !gitStatus.error && (
<FileSelectionControls
isMobile={isMobile}
selectedCount={selectedFiles.size}
totalCount={changedFiles.length}
isHidden={hasExpandedFiles}
onSelectAll={() => setSelectedFiles(new Set(changedFiles))}
onDeselectAll={() => setSelectedFiles(new Set())}
/>
)}
{!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : gitStatus?.hasCommits === false ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<GitBranch className="w-7 h-7 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes.
</p>
<button
onClick={() => void onCreateInitialCommit()}
disabled={isCreatingInitialCommit}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{isCreatingInitialCommit ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Creating Initial Commit...</span>
</>
) : (
<>
<GitCommit className="w-4 h-4" />
<span>Create Initial Commit</span>
</>
)}
</button>
</div>
) : !gitStatus || !hasChangedFiles(gitStatus) ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<GitCommit className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No changes detected</p>
</div>
) : (
<div className={isMobile ? 'pb-4' : ''}>
<FileChangeList
gitStatus={gitStatus}
gitDiff={gitDiff}
expandedFiles={expandedFiles}
selectedFiles={selectedFiles}
isMobile={isMobile}
wrapText={wrapText}
onToggleSelected={toggleFileSelected}
onToggleExpanded={toggleFileExpanded}
onOpenFile={(filePath) => {
void onOpenFile(filePath);
}}
onToggleWrapText={() => onWrapTextChange(!wrapText)}
onRequestFileAction={requestFileAction}
/>
</div>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,162 @@
import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';
import { useState } from 'react';
import MicButton from '../../../mic-button/view/MicButton';
import type { ConfirmationRequest } from '../../types/types';
type CommitComposerProps = {
isMobile: boolean;
selectedFileCount: number;
isHidden: boolean;
onCommit: (message: string) => Promise<boolean>;
onGenerateMessage: () => Promise<string | null>;
onRequestConfirmation: (request: ConfirmationRequest) => void;
};
export default function CommitComposer({
isMobile,
selectedFileCount,
isHidden,
onCommit,
onGenerateMessage,
onRequestConfirmation,
}: CommitComposerProps) {
const [commitMessage, setCommitMessage] = useState('');
const [isCommitting, setIsCommitting] = useState(false);
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(isMobile);
const handleCommit = async (message = commitMessage) => {
const trimmedMessage = message.trim();
if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
return false;
}
setIsCommitting(true);
try {
const success = await onCommit(trimmedMessage);
if (success) {
setCommitMessage('');
}
return success;
} finally {
setIsCommitting(false);
}
};
const handleGenerateMessage = async () => {
if (selectedFileCount === 0 || isGeneratingMessage) {
return;
}
setIsGeneratingMessage(true);
try {
const generatedMessage = await onGenerateMessage();
if (generatedMessage) {
setCommitMessage(generatedMessage);
}
} finally {
setIsGeneratingMessage(false);
}
};
const requestCommitConfirmation = () => {
const trimmedMessage = commitMessage.trim();
if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {
return;
}
onRequestConfirmation({
type: 'commit',
message: `Commit ${selectedFileCount} file${selectedFileCount !== 1 ? 's' : ''} with message: "${trimmedMessage}"?`,
onConfirm: async () => {
await handleCommit(trimmedMessage);
},
});
};
return (
<div
className={`transition-all duration-300 ease-in-out ${
isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-96 opacity-100 translate-y-0'
}`}
>
{isMobile && isCollapsed ? (
<div className="px-4 py-2 border-b border-border/60">
<button
onClick={() => setIsCollapsed(false)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<GitCommit className="w-4 h-4" />
<span>Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}</span>
<ChevronDown className="w-3 h-3" />
</button>
</div>
) : (
<div className="px-4 py-3 border-b border-border/60">
{isMobile && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-foreground">Commit Changes</span>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 hover:bg-accent rounded-lg transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
)}
<div className="relative">
<textarea
value={commitMessage}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
rows={3}
onKeyDown={(event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
void handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={() => void handleGenerateMessage()}
disabled={selectedFileCount === 0 || isGeneratingMessage}
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-sm text-muted-foreground">
{selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected
</span>
<button
onClick={requestCommitConfirmation}
disabled={!commitMessage.trim() || selectedFileCount === 0 || isCommitting}
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { ChevronRight, Trash2 } from 'lucide-react';
import DiffViewer from '../../../DiffViewer.jsx';
import type { FileStatusCode } from '../../types/types';
import { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils';
type DiffViewerProps = {
diff: string;
fileName: string;
isMobile: boolean;
wrapText: boolean;
};
const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
type FileChangeItemProps = {
filePath: string;
status: FileStatusCode;
isMobile: boolean;
isExpanded: boolean;
isSelected: boolean;
diff?: string;
wrapText: boolean;
onToggleSelected: (filePath: string) => void;
onToggleExpanded: (filePath: string) => void;
onOpenFile: (filePath: string) => void;
onToggleWrapText: () => void;
onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
};
export default function FileChangeItem({
filePath,
status,
isMobile,
isExpanded,
isSelected,
diff,
wrapText,
onToggleSelected,
onToggleExpanded,
onOpenFile,
onToggleWrapText,
onRequestFileAction,
}: FileChangeItemProps) {
const statusLabel = getStatusLabel(status);
const badgeClass = getStatusBadgeClass(status);
return (
<div className="border-b border-border last:border-0">
<div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelected(filePath)}
onClick={(event) => event.stopPropagation()}
className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
/>
<div className="flex items-center flex-1 min-w-0">
<button
onClick={(event) => {
event.stopPropagation();
onToggleExpanded(filePath);
}}
className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
title={isExpanded ? 'Collapse diff' : 'Expand diff'}
>
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</button>
<span
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
onClick={(event) => {
event.stopPropagation();
onOpenFile(filePath);
}}
title="Click to open file"
>
{filePath}
</span>
<span className="flex items-center gap-1">
{(status === 'M' || status === 'D' || status === 'U') && (
<button
onClick={(event) => {
event.stopPropagation();
onRequestFileAction(filePath, status);
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
title={status === 'U' ? 'Delete untracked file' : 'Discard changes'}
>
<Trash2 className="w-3 h-3" />
{isMobile && <span>{status === 'U' ? 'Delete' : 'Discard'}</span>}
</button>
)}
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}
title={statusLabel}
>
{status}
</span>
</span>
</div>
</div>
<div
className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff ? 'max-h-[600px] opacity-100 translate-y-0' : 'max-h-0 opacity-0 -translate-y-1'
}`}
>
<div className="flex items-center justify-between p-2 border-b border-border">
<span className="flex items-center gap-2">
<span className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${badgeClass}`}>
{status}
</span>
<span className="text-sm font-medium text-foreground">{statusLabel}</span>
</span>
{isMobile && (
<button
onClick={(event) => {
event.stopPropagation();
onToggleWrapText();
}}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
title={wrapText ? 'Switch to horizontal scroll' : 'Switch to text wrap'}
>
{wrapText ? 'Scroll' : 'Wrap'}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{diff && <DiffViewerComponent diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { FILE_STATUS_GROUPS } from '../../constants/constants';
import type { FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';
import FileChangeItem from './FileChangeItem';
type FileChangeListProps = {
gitStatus: GitStatusResponse;
gitDiff: GitDiffMap;
expandedFiles: Set<string>;
selectedFiles: Set<string>;
isMobile: boolean;
wrapText: boolean;
onToggleSelected: (filePath: string) => void;
onToggleExpanded: (filePath: string) => void;
onOpenFile: (filePath: string) => void;
onToggleWrapText: () => void;
onRequestFileAction: (filePath: string, status: FileStatusCode) => void;
};
export default function FileChangeList({
gitStatus,
gitDiff,
expandedFiles,
selectedFiles,
isMobile,
wrapText,
onToggleSelected,
onToggleExpanded,
onOpenFile,
onToggleWrapText,
onRequestFileAction,
}: FileChangeListProps) {
return (
<>
{FILE_STATUS_GROUPS.map(({ key, status }) =>
(gitStatus[key] || []).map((filePath) => (
<FileChangeItem
key={filePath}
filePath={filePath}
status={status}
isMobile={isMobile}
isExpanded={expandedFiles.has(filePath)}
isSelected={selectedFiles.has(filePath)}
diff={gitDiff[filePath]}
wrapText={wrapText}
onToggleSelected={onToggleSelected}
onToggleExpanded={onToggleExpanded}
onOpenFile={onOpenFile}
onToggleWrapText={onToggleWrapText}
onRequestFileAction={onRequestFileAction}
/>
)),
)}
</>
);
}

View File

@@ -0,0 +1,44 @@
type FileSelectionControlsProps = {
isMobile: boolean;
selectedCount: number;
totalCount: number;
isHidden: boolean;
onSelectAll: () => void;
onDeselectAll: () => void;
};
export default function FileSelectionControls({
isMobile,
selectedCount,
totalCount,
isHidden,
onSelectAll,
onDeselectAll,
}: FileSelectionControlsProps) {
return (
<div
className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${
isMobile ? 'px-3 py-1.5' : 'px-4 py-2'
} ${isHidden ? 'max-h-0 opacity-0 -translate-y-2 overflow-hidden' : 'max-h-16 opacity-100 translate-y-0'}`}
>
<span className="text-sm text-muted-foreground">
{selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected
</span>
<span className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
<button
onClick={onSelectAll}
className="text-sm text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'All' : 'Select All'}
</button>
<span className="text-border">|</span>
<button
onClick={onDeselectAll}
className="text-sm text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'None' : 'Deselect All'}
</button>
</span>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
import { useState } from 'react';
import { getStatusBadgeClass } from '../../utils/gitPanelUtils';
type FileStatusLegendProps = {
isMobile: boolean;
};
const LEGEND_ITEMS = [
{ status: 'M', label: 'Modified' },
{ status: 'A', label: 'Added' },
{ status: 'D', label: 'Deleted' },
{ status: 'U', label: 'Untracked' },
] as const;
export default function FileStatusLegend({ isMobile }: FileStatusLegendProps) {
const [isOpen, setIsOpen] = useState(false);
if (isMobile) {
return null;
}
return (
<div className="border-b border-border/60">
<button
onClick={() => setIsOpen((previous) => !previous)}
className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{isOpen && (
<div className="px-4 py-3 bg-muted/30 text-sm">
<div className="flex justify-center gap-6">
{LEGEND_ITEMS.map((item) => (
<span key={item.status} className="flex items-center gap-2">
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded border font-bold text-[10px] ${getStatusBadgeClass(item.status)}`}
>
{item.status}
</span>
<span className="text-muted-foreground italic">{item.label}</span>
</span>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { ChevronDown, ChevronRight } from 'lucide-react';
import DiffViewer from '../../../DiffViewer.jsx';
import type { GitCommitSummary } from '../../types/types';
type DiffViewerProps = {
diff: string;
fileName: string;
isMobile: boolean;
wrapText: boolean;
};
const DiffViewerComponent = DiffViewer as unknown as (props: DiffViewerProps) => JSX.Element;
type CommitHistoryItemProps = {
commit: GitCommitSummary;
isExpanded: boolean;
diff?: string;
isMobile: boolean;
wrapText: boolean;
onToggle: () => void;
};
export default function CommitHistoryItem({
commit,
isExpanded,
diff,
isMobile,
wrapText,
onToggle,
}: CommitHistoryItemProps) {
return (
<div className="border-b border-border last:border-0">
<button
type="button"
aria-expanded={isExpanded}
className="w-full flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors text-left bg-transparent border-0"
onClick={onToggle}
>
<span className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{commit.message}</p>
<p className="text-sm text-muted-foreground mt-1">
{commit.author}
{' \u2022 '}
{commit.date}
</p>
</div>
<span className="text-sm font-mono text-muted-foreground/60 flex-shrink-0">
{commit.hash.substring(0, 7)}
</span>
</div>
</div>
</button>
{isExpanded && diff && (
<div className="bg-muted/50">
<div className="max-h-96 overflow-y-auto p-2">
<div className="text-sm font-mono text-muted-foreground mb-2">
{commit.stats}
</div>
<DiffViewerComponent diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { History, RefreshCw } from 'lucide-react';
import { useCallback, useState } from 'react';
import type { GitDiffMap, GitCommitSummary } from '../../types/types';
import CommitHistoryItem from './CommitHistoryItem';
type HistoryViewProps = {
isMobile: boolean;
isLoading: boolean;
recentCommits: GitCommitSummary[];
commitDiffs: GitDiffMap;
wrapText: boolean;
onFetchCommitDiff: (commitHash: string) => Promise<void>;
};
export default function HistoryView({
isMobile,
isLoading,
recentCommits,
commitDiffs,
wrapText,
onFetchCommitDiff,
}: HistoryViewProps) {
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
const toggleCommitExpanded = useCallback(
(commitHash: string) => {
const isExpanding = !expandedCommits.has(commitHash);
setExpandedCommits((previous) => {
const next = new Set(previous);
if (next.has(commitHash)) {
next.delete(commitHash);
} else {
next.add(commitHash);
}
return next;
});
// Load commit diff lazily only the first time a commit is expanded.
if (isExpanding && !commitDiffs[commitHash]) {
onFetchCommitDiff(commitHash).catch((err) => {
console.error('Failed to fetch commit diff:', err);
});
}
},
[commitDiffs, expandedCommits, onFetchCommitDiff, setExpandedCommits],
);
return (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : recentCommits.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<History className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No commits found</p>
</div>
) : (
<div className={isMobile ? 'pb-4' : ''}>
{recentCommits.map((commit) => (
<CommitHistoryItem
key={commit.hash}
commit={commit}
isExpanded={expandedCommits.has(commit.hash)}
diff={commitDiffs[commit.hash]}
isMobile={isMobile}
wrapText={wrapText}
onToggle={() => toggleCommitExpanded(commit.hash)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { Check, Download, Trash2, Upload } from 'lucide-react';
import {
CONFIRMATION_ACTION_LABELS,
CONFIRMATION_BUTTON_CLASSES,
CONFIRMATION_ICON_CONTAINER_CLASSES,
CONFIRMATION_TITLES,
} from '../../constants/constants';
import type { ConfirmationRequest } from '../../types/types';
type ConfirmActionModalProps = {
action: ConfirmationRequest | null;
onCancel: () => void;
onConfirm: () => void;
};
function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
if (actionType === 'discard' || actionType === 'delete') {
return <Trash2 className="w-4 h-4" />;
}
if (actionType === 'commit') {
return <Check className="w-4 h-4" />;
}
if (actionType === 'pull') {
return <Download className="w-4 h-4" />;
}
return <Upload className="w-4 h-4" />;
}
export default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {
const titleId = action ? `confirmation-title-${action.type}` : undefined;
useEffect(() => {
if (!action) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [action, onCancel]);
if (!action) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${CONFIRMATION_ICON_CONTAINER_CLASSES[action.type]}`}>
{renderConfirmActionIcon(action.type)}
</div>
<h3 id={titleId} className="text-lg font-semibold text-foreground">
{CONFIRMATION_TITLES[action.type]}
</h3>
</div>
<p className="text-sm text-muted-foreground mb-6">{action.message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm text-white rounded-lg transition-colors flex items-center space-x-2 ${CONFIRMATION_BUTTON_CLASSES[action.type]}`}
>
{renderConfirmActionIcon(action.type)}
<span>{CONFIRMATION_ACTION_LABELS[action.type]}</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Plus, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
type NewBranchModalProps = {
isOpen: boolean;
currentBranch: string;
isCreatingBranch: boolean;
onClose: () => void;
onCreateBranch: (branchName: string) => Promise<boolean>;
};
export default function NewBranchModal({
isOpen,
currentBranch,
isCreatingBranch,
onClose,
onCreateBranch,
}: NewBranchModalProps) {
const [newBranchName, setNewBranchName] = useState('');
useEffect(() => {
if (!isOpen) {
setNewBranchName('');
}
}, [isOpen]);
const handleCreateBranch = async (): Promise<boolean> => {
const branchName = newBranchName.trim();
if (!branchName) {
return false;
}
try {
const success = await onCreateBranch(branchName);
if (success) {
setNewBranchName('');
onClose();
}
return success;
} catch (error) {
console.error('Failed to create branch:', error);
return false;
}
};
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="new-branch-title"
>
<div className="p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
<div className="mb-4">
<label htmlFor="git-new-branch-name" className="block text-sm font-medium text-foreground/80 mb-2">
Branch Name
</label>
<input
id="git-new-branch-name"
type="text"
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !isCreatingBranch) {
event.preventDefault();
event.stopPropagation();
void handleCreateBranch();
return;
}
if (event.key === 'Escape' && !isCreatingBranch) {
event.preventDefault();
event.stopPropagation();
onClose();
}
}}
placeholder="feature/new-feature"
className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
autoFocus
/>
</div>
<p className="text-sm text-muted-foreground mb-4">
This will create a new branch from the current branch ({currentBranch})
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={() => void handleCreateBranch()}
disabled={!newBranchName.trim() || isCreatingBranch}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
>
{isCreatingBranch ? (
<>
<RefreshCw className="w-3 h-3 animate-spin" />
<span>Creating...</span>
</>
) : (
<>
<Plus className="w-3 h-3" />
<span>Create Branch</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
const ClaudeLogo = ({className = 'w-5 h-5'}) => { type ClaudeLogoProps = {
className?: string;
};
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {
return ( return (
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} /> <img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
); );

View File

@@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
const CodexLogo = ({ className = 'w-5 h-5' }) => { type CodexLogoProps = {
className?: string;
};
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
return ( return (

View File

@@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
const CursorLogo = ({ className = 'w-5 h-5' }) => { type CursorLogoProps = {
className?: string;
};
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
return ( return (

View File

@@ -1,4 +1,4 @@
import type { SessionProvider } from '../types/app'; import type { SessionProvider } from '../../types/app';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo'; import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo'; import CursorLogo from './CursorLogo';

View File

@@ -1,23 +1,9 @@
import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app'; import type { AppTab, Project, ProjectSession } from '../../../types/app';
export type SessionLifecycleHandler = (sessionId?: string | null) => void; export type SessionLifecycleHandler = (sessionId?: string | null) => void;
export interface DiffInfo { export type TaskMasterTask = {
old_string?: string;
new_string?: string;
[key: string]: unknown;
}
export interface EditingFile {
name: string;
path: string;
projectName?: string;
diffInfo?: DiffInfo | null;
[key: string]: unknown;
}
export interface TaskMasterTask {
id: string | number; id: string | number;
title?: string; title?: string;
description?: string; description?: string;
@@ -29,24 +15,24 @@ export interface TaskMasterTask {
dependencies?: Array<string | number>; dependencies?: Array<string | number>;
subtasks?: TaskMasterTask[]; subtasks?: TaskMasterTask[];
[key: string]: unknown; [key: string]: unknown;
} };
export interface TaskReference { export type TaskReference = {
id: string | number; id: string | number;
title?: string; title?: string;
[key: string]: unknown; [key: string]: unknown;
} };
export type TaskSelection = TaskMasterTask | TaskReference; export type TaskSelection = TaskMasterTask | TaskReference;
export interface PrdFile { export type PrdFile = {
name: string; name: string;
content?: string; content?: string;
isExisting?: boolean; isExisting?: boolean;
[key: string]: unknown; [key: string]: unknown;
} };
export interface MainContentProps { export type MainContentProps = {
selectedProject: Project | null; selectedProject: Project | null;
selectedSession: ProjectSession | null; selectedSession: ProjectSession | null;
activeTab: AppTab; activeTab: AppTab;
@@ -67,9 +53,9 @@ export interface MainContentProps {
onNavigateToSession: (targetSessionId: string) => void; onNavigateToSession: (targetSessionId: string) => void;
onShowSettings: () => void; onShowSettings: () => void;
externalMessageUpdate: number; externalMessageUpdate: number;
} };
export interface MainContentHeaderProps { export type MainContentHeaderProps = {
activeTab: AppTab; activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>; setActiveTab: Dispatch<SetStateAction<AppTab>>;
selectedProject: Project; selectedProject: Project;
@@ -77,32 +63,19 @@ export interface MainContentHeaderProps {
shouldShowTasksTab: boolean; shouldShowTasksTab: boolean;
isMobile: boolean; isMobile: boolean;
onMenuClick: () => void; onMenuClick: () => void;
} };
export interface MainContentStateViewProps { export type MainContentStateViewProps = {
mode: 'loading' | 'empty'; mode: 'loading' | 'empty';
isMobile: boolean; isMobile: boolean;
onMenuClick: () => void; onMenuClick: () => void;
} };
export interface MobileMenuButtonProps { export type MobileMenuButtonProps = {
onMenuClick: () => void; onMenuClick: () => void;
compact?: boolean; compact?: boolean;
} };
export interface EditorSidebarProps { export type TaskMasterPanelProps = {
editingFile: EditingFile | null;
isMobile: boolean;
editorExpanded: boolean;
editorWidth: number;
resizeHandleRef: RefObject<HTMLDivElement>;
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
onCloseEditor: () => void;
onToggleEditorExpand: () => void;
projectPath?: string;
fillSpace?: boolean;
}
export interface TaskMasterPanelProps {
isVisible: boolean; isVisible: boolean;
} };

View File

@@ -1,26 +1,23 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface'; import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../FileTree'; import FileTree from '../../file-tree/view/FileTree';
import StandaloneShell from '../../StandaloneShell'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
import GitPanel from '../../GitPanel'; import GitPanel from '../../git-panel/view/GitPanel';
import ErrorBoundary from '../../ErrorBoundary'; import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView'; import MainContentStateView from './subcomponents/MainContentStateView';
import EditorSidebar from './subcomponents/EditorSidebar';
import TaskMasterPanel from './subcomponents/TaskMasterPanel'; import TaskMasterPanel from './subcomponents/TaskMasterPanel';
import type { MainContentProps } from '../types/types'; import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext'; import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences'; import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useEditorSidebar } from '../hooks/useEditorSidebar'; import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
import EditorSidebar from '../../code-editor/view/EditorSidebar';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
const AnyStandaloneShell = StandaloneShell as any;
const AnyGitPanel = GitPanel as any;
type TaskMasterContextValue = { type TaskMasterContextValue = {
currentProject?: Project | null; currentProject?: Project | null;
setCurrentProject?: ((project: Project) => void) | null; setCurrentProject?: ((project: Project) => void) | null;
@@ -66,6 +63,7 @@ function MainContent({
editingFile, editingFile,
editorWidth, editorWidth,
editorExpanded, editorExpanded,
hasManualWidth,
resizeHandleRef, resizeHandleRef,
handleFileOpen, handleFileOpen,
handleCloseEditor, handleCloseEditor,
@@ -109,7 +107,7 @@ function MainContent({
/> />
<div className="flex-1 flex min-h-0 overflow-hidden"> <div className="flex-1 flex min-h-0 overflow-hidden">
<div className={`flex flex-col min-h-0 overflow-hidden ${editorExpanded ? 'hidden' : ''} ${activeTab === 'files' && editingFile ? 'w-[280px] flex-shrink-0' : 'flex-1'}`}> <div className={`flex flex-col min-h-0 min-w-0 overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}> <div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails> <ErrorBoundary showDetails>
<ChatInterface <ChatInterface
@@ -147,13 +145,13 @@ function MainContent({
{activeTab === 'shell' && ( {activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<AnyStandaloneShell project={selectedProject} session={selectedSession} showHeader={false} /> <StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
</div> </div>
)} )}
{activeTab === 'git' && ( {activeTab === 'git' && (
<div className="h-full overflow-hidden"> <div className="h-full overflow-hidden">
<AnyGitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} /> <GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div> </div>
)} )}
@@ -167,6 +165,7 @@ function MainContent({
isMobile={isMobile} isMobile={isMobile}
editorExpanded={editorExpanded} editorExpanded={editorExpanded}
editorWidth={editorWidth} editorWidth={editorWidth}
hasManualWidth={hasManualWidth}
resizeHandleRef={resizeHandleRef} resizeHandleRef={resizeHandleRef}
onResizeStart={handleResizeStart} onResizeStart={handleResizeStart}
onCloseEditor={handleCloseEditor} onCloseEditor={handleCloseEditor}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app'; import type { AppTab, Project, ProjectSession } from '../../../../types/app';
type MainContentTitleProps = { type MainContentTitleProps = {

View File

@@ -0,0 +1,45 @@
import type { MicButtonState } from '../types/types';
export const MIC_BUTTON_STATES = {
IDLE: 'idle',
RECORDING: 'recording',
TRANSCRIBING: 'transcribing',
PROCESSING: 'processing',
} as const;
export const MIC_TAP_DEBOUNCE_MS = 300;
export const PROCESSING_STATE_DELAY_MS = 2000;
export const DEFAULT_WHISPER_MODE = 'default';
// Modes that use post-transcription enhancement on the backend.
export const ENHANCEMENT_WHISPER_MODES = new Set([
'prompt',
'vibe',
'instructions',
'architect',
]);
export const BUTTON_BACKGROUND_BY_STATE: Record<MicButtonState, string> = {
idle: '#374151',
recording: '#ef4444',
transcribing: '#3b82f6',
processing: '#a855f7',
};
export const MIC_ERROR_BY_NAME = {
NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',
NotFoundError: 'No microphone found. Please check your audio devices.',
NotSupportedError: 'Microphone not supported by this browser.',
NotReadableError: 'Microphone is being used by another application.',
} as const;
export const MIC_NOT_AVAILABLE_ERROR =
'Microphone access not available. Please use HTTPS or a supported browser.';
export const MIC_NOT_SUPPORTED_ERROR =
'Microphone not supported. Please use HTTPS or a modern browser.';
export const MIC_SECURE_CONTEXT_ERROR =
'Microphone requires HTTPS. Please use a secure connection.';

View File

@@ -0,0 +1,52 @@
import { api } from '../../../utils/api';
type WhisperStatus = 'transcribing';
type WhisperResponse = {
text?: string;
error?: string;
};
export async function transcribeWithWhisper(
audioBlob: Blob,
onStatusChange?: (status: WhisperStatus) => void,
): Promise<string> {
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
const file = new File([audioBlob], fileName, { type: audioBlob.type });
formData.append('audio', file);
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
formData.append('mode', whisperMode);
try {
// Keep existing status callback behavior.
if (onStatusChange) {
onStatusChange('transcribing');
}
const response = (await api.transcribe(formData)) as Response;
if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;
throw new Error(
errorData.error ||
`Transcription error: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as WhisperResponse;
return data.text || '';
} catch (error) {
if (
error instanceof Error
&& error.name === 'TypeError'
&& error.message.includes('fetch')
) {
throw new Error('Cannot connect to server. Please ensure the backend is running.');
}
throw error;
}
}

View File

@@ -0,0 +1,204 @@
import { useEffect, useRef, useState } from 'react';
import type { MouseEvent } from 'react';
import { transcribeWithWhisper } from '../data/whisper';
import {
DEFAULT_WHISPER_MODE,
ENHANCEMENT_WHISPER_MODES,
MIC_BUTTON_STATES,
MIC_ERROR_BY_NAME,
MIC_NOT_AVAILABLE_ERROR,
MIC_NOT_SUPPORTED_ERROR,
MIC_SECURE_CONTEXT_ERROR,
MIC_TAP_DEBOUNCE_MS,
PROCESSING_STATE_DELAY_MS,
} from '../constants/constants';
import type { MicButtonState } from '../types/types';
type UseMicButtonControllerArgs = {
onTranscript?: (transcript: string) => void;
};
type UseMicButtonControllerResult = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
handleButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getRecordingErrorMessage = (error: unknown): string => {
if (error instanceof Error && error.message.includes('HTTPS')) {
return error.message;
}
if (error instanceof DOMException) {
return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';
}
return 'Microphone access failed';
};
const getRecorderMimeType = (): string => (
MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
);
export function useMicButtonController({
onTranscript,
}: UseMicButtonControllerArgs): UseMicButtonControllerResult {
const [state, setState] = useState<MicButtonState>(MIC_BUTTON_STATES.IDLE);
const [error, setError] = useState<string | null>(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const lastTapRef = useRef(0);
const processingTimerRef = useRef<number | null>(null);
const clearProcessingTimer = (): void => {
if (processingTimerRef.current !== null) {
window.clearTimeout(processingTimerRef.current);
processingTimerRef.current = null;
}
};
const stopStreamTracks = (): void => {
if (!streamRef.current) {
return;
}
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
};
const handleStopRecording = async (mimeType: string): Promise<void> => {
const audioBlob = new Blob(chunksRef.current, { type: mimeType });
// Release the microphone immediately once recording ends.
stopStreamTracks();
setState(MIC_BUTTON_STATES.TRANSCRIBING);
const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;
const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);
if (shouldShowProcessingState) {
processingTimerRef.current = window.setTimeout(() => {
setState(MIC_BUTTON_STATES.PROCESSING);
}, PROCESSING_STATE_DELAY_MS);
}
try {
const transcript = await transcribeWithWhisper(audioBlob);
if (transcript && onTranscript) {
onTranscript(transcript);
}
} catch (transcriptionError) {
const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';
setError(message);
} finally {
clearProcessingTimer();
setState(MIC_BUTTON_STATES.IDLE);
}
};
const startRecording = async (): Promise<void> => {
try {
setError(null);
chunksRef.current = [];
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(MIC_NOT_AVAILABLE_ERROR);
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = getRecorderMimeType();
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
void handleStopRecording(mimeType);
};
recorder.start();
setState(MIC_BUTTON_STATES.RECORDING);
} catch (recordingError) {
stopStreamTracks();
setError(getRecordingErrorMessage(recordingError));
setState(MIC_BUTTON_STATES.IDLE);
}
};
const stopRecording = (): void => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
return;
}
stopStreamTracks();
setState(MIC_BUTTON_STATES.IDLE);
};
const handleButtonClick = (event?: MouseEvent<HTMLButtonElement>): void => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!isSupported) {
return;
}
// Mobile tap handling can trigger duplicate click events in quick succession.
const now = Date.now();
if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {
return;
}
lastTapRef.current = now;
if (state === MIC_BUTTON_STATES.IDLE) {
void startRecording();
return;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
stopRecording();
}
};
useEffect(() => {
// getUserMedia needs both browser support and a secure context.
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError(MIC_NOT_SUPPORTED_ERROR);
return;
}
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError(MIC_SECURE_CONTEXT_ERROR);
return;
}
setIsSupported(true);
setError(null);
}, []);
useEffect(() => () => {
clearProcessingTimer();
stopStreamTracks();
}, []);
return {
state,
error,
isSupported,
handleButtonClick,
};
}

View File

@@ -0,0 +1,2 @@
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';

View File

@@ -0,0 +1,32 @@
import { useMicButtonController } from '../hooks/useMicButtonController';
import MicButtonView from './MicButtonView';
type MicButtonProps = {
onTranscript?: (transcript: string) => void;
className?: string;
mode?: string;
};
export default function MicButton({
onTranscript,
className = '',
mode: _mode,
}: MicButtonProps) {
const { state, error, isSupported, handleButtonClick } = useMicButtonController({
onTranscript,
});
// Keep `mode` in the public props for backwards compatibility.
void _mode;
return (
<MicButtonView
state={state}
error={error}
isSupported={isSupported}
className={className}
onButtonClick={handleButtonClick}
/>
);
}

View File

@@ -0,0 +1,86 @@
import { Brain, Loader2, Mic } from 'lucide-react';
import type { MouseEvent, ReactElement } from 'react';
import { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';
import type { MicButtonState } from '../types/types';
type MicButtonViewProps = {
state: MicButtonState;
error: string | null;
isSupported: boolean;
className: string;
onButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;
};
const getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {
if (!isSupported) {
return <Mic className="w-5 h-5" />;
}
if (state === MIC_BUTTON_STATES.TRANSCRIBING) {
return <Loader2 className="w-5 h-5 animate-spin" />;
}
if (state === MIC_BUTTON_STATES.PROCESSING) {
return <Brain className="w-5 h-5 animate-pulse" />;
}
if (state === MIC_BUTTON_STATES.RECORDING) {
return <Mic className="w-5 h-5 text-white" />;
}
return <Mic className="w-5 h-5" />;
};
export default function MicButtonView({
state,
error,
isSupported,
className,
onButtonClick,
}: MicButtonViewProps) {
const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;
const icon = getButtonIcon(state, isSupported);
return (
<div className="relative">
<button
type="button"
style={{ backgroundColor: BUTTON_BACKGROUND_BY_STATE[state] }}
className={`
flex items-center justify-center
w-12 h-12 rounded-full
text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
dark:ring-offset-gray-800
touch-action-manipulation
${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === MIC_BUTTON_STATES.RECORDING ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={onButtonClick}
disabled={isDisabled}
>
{icon}
</button>
{error && (
<div
className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
animate-fade-in"
>
{error}
</div>
)}
{state === MIC_BUTTON_STATES.RECORDING && (
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
)}
{state === MIC_BUTTON_STATES.PROCESSING && (
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More