mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402)
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CommandMenu - Autocomplete dropdown for slash commands
|
|
||||||
*
|
|
||||||
* @param {Array} commands - Array of command objects to display
|
|
||||||
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
|
|
||||||
* @param {Function} onSelect - Callback when a command is selected
|
|
||||||
* @param {Function} onClose - Callback when menu should close
|
|
||||||
* @param {Object} position - Position object { top, left } for absolute positioning
|
|
||||||
* @param {boolean} isOpen - Whether the menu is open
|
|
||||||
* @param {Array} frequentCommands - Array of frequently used command objects
|
|
||||||
*/
|
|
||||||
const CommandMenu = ({
|
|
||||||
commands = [],
|
|
||||||
selectedIndex = -1,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
position = { top: 0, left: 0 },
|
|
||||||
isOpen = false,
|
|
||||||
frequentCommands = [],
|
|
||||||
}) => {
|
|
||||||
const menuRef = useRef(null);
|
|
||||||
const selectedItemRef = useRef(null);
|
|
||||||
|
|
||||||
// Calculate responsive menu positioning.
|
|
||||||
// Mobile: dock above chat input. Desktop: clamp to viewport.
|
|
||||||
const getMenuPosition = () => {
|
|
||||||
const isMobile = window.innerWidth < 640;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
// On mobile, calculate bottom position dynamically to appear above the input.
|
|
||||||
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
|
|
||||||
const inputBottom = position.bottom || 90;
|
|
||||||
|
|
||||||
return {
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
|
|
||||||
left: '16px',
|
|
||||||
right: '16px',
|
|
||||||
width: 'auto',
|
|
||||||
maxWidth: 'calc(100vw - 32px)',
|
|
||||||
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// On desktop, use provided position but ensure it stays on screen.
|
|
||||||
return {
|
|
||||||
position: 'fixed',
|
|
||||||
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
|
|
||||||
left: `${position.left}px`,
|
|
||||||
width: 'min(400px, calc(100vw - 32px))',
|
|
||||||
maxWidth: 'calc(100vw - 32px)',
|
|
||||||
maxHeight: '300px',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuPosition = getMenuPosition();
|
|
||||||
|
|
||||||
// Close menu when clicking outside.
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
// Keep selected keyboard item visible while navigating.
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedItemRef.current && menuRef.current) {
|
|
||||||
const menuRect = menuRef.current.getBoundingClientRect();
|
|
||||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (itemRect.bottom > menuRect.bottom) {
|
|
||||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
||||||
} else if (itemRect.top < menuRect.top) {
|
|
||||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show a message if no commands are available.
|
|
||||||
if (commands.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className="command-menu command-menu-empty"
|
|
||||||
style={{
|
|
||||||
...menuPosition,
|
|
||||||
maxHeight: '300px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
|
||||||
zIndex: 1000,
|
|
||||||
padding: '20px',
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No commands available
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add frequent commands as a special group if provided.
|
|
||||||
const hasFrequentCommands = frequentCommands.length > 0;
|
|
||||||
|
|
||||||
const getCommandKey = (command) =>
|
|
||||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
|
||||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
|
||||||
|
|
||||||
// Group commands by namespace for section rendering.
|
|
||||||
// When frequent commands are shown, avoid duplicate rows in other sections.
|
|
||||||
const groupedCommands = commands.reduce((groups, command) => {
|
|
||||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
const namespace = command.namespace || command.type || 'other';
|
|
||||||
if (!groups[namespace]) {
|
|
||||||
groups[namespace] = [];
|
|
||||||
}
|
|
||||||
groups[namespace].push(command);
|
|
||||||
return groups;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Add frequent commands as a separate group.
|
|
||||||
if (hasFrequentCommands) {
|
|
||||||
groupedCommands.frequent = frequentCommands;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order: frequent, builtin, project, user, other.
|
|
||||||
const namespaceOrder = hasFrequentCommands
|
|
||||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
|
||||||
: ['builtin', 'project', 'user', 'other'];
|
|
||||||
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
|
|
||||||
|
|
||||||
const namespaceLabels = {
|
|
||||||
frequent: '\u2B50 Frequently Used',
|
|
||||||
builtin: 'Built-in Commands',
|
|
||||||
project: 'Project Commands',
|
|
||||||
user: 'User Commands',
|
|
||||||
other: 'Other Commands',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
|
|
||||||
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
|
|
||||||
const commandIndexByKey = new Map();
|
|
||||||
commands.forEach((command, index) => {
|
|
||||||
const key = getCommandKey(command);
|
|
||||||
if (!commandIndexByKey.has(key)) {
|
|
||||||
commandIndexByKey.set(key, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
role="listbox"
|
|
||||||
aria-label="Available commands"
|
|
||||||
className="command-menu"
|
|
||||||
style={{
|
|
||||||
...menuPosition,
|
|
||||||
maxHeight: '300px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
|
||||||
zIndex: 1000,
|
|
||||||
padding: '8px',
|
|
||||||
opacity: isOpen ? 1 : 0,
|
|
||||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
|
||||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{orderedNamespaces.map((namespace) => (
|
|
||||||
<div key={namespace} className="command-group">
|
|
||||||
{orderedNamespaces.length > 1 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: '#6b7280',
|
|
||||||
padding: '8px 12px 4px',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{namespaceLabels[namespace] || namespace}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groupedCommands[namespace].map((command) => {
|
|
||||||
const commandKey = getCommandKey(command);
|
|
||||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
|
||||||
const isSelected = commandIndex === selectedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
|
||||||
ref={isSelected ? selectedItemRef : null}
|
|
||||||
role="option"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
className="command-item"
|
|
||||||
onMouseEnter={() => {
|
|
||||||
if (onSelect && commandIndex >= 0) {
|
|
||||||
onSelect(command, commandIndex, true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (onSelect) {
|
|
||||||
onSelect(command, commandIndex, false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
|
||||||
transition: 'background-color 100ms ease-in-out',
|
|
||||||
marginBottom: '2px',
|
|
||||||
}}
|
|
||||||
// Prevent textarea blur when clicking a menu item.
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
marginBottom: command.description ? '4px' : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Command icon based on namespace */}
|
|
||||||
<span style={{ fontSize: '16px', flexShrink: 0 }}>
|
|
||||||
{namespace === 'builtin' && '\u26A1'}
|
|
||||||
{namespace === 'project' && '\uD83D\uDCC1'}
|
|
||||||
{namespace === 'user' && '\uD83D\uDC64'}
|
|
||||||
{namespace === 'other' && '\uD83D\uDCDD'}
|
|
||||||
{namespace === 'frequent' && '\u2B50'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Command name */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#111827',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Command metadata badge */}
|
|
||||||
{command.metadata?.type && (
|
|
||||||
<span
|
|
||||||
className="command-metadata-badge"
|
|
||||||
style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: '#f3f4f6',
|
|
||||||
color: '#6b7280',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command.metadata.type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Command description */}
|
|
||||||
{command.description && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '13px',
|
|
||||||
color: '#6b7280',
|
|
||||||
marginLeft: '24px',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{command.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selection indicator */}
|
|
||||||
{isSelected && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
marginLeft: '8px',
|
|
||||||
color: '#3b82f6',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{'\u21B5'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Default light mode styles */}
|
|
||||||
<style>{`
|
|
||||||
.command-menu {
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
.command-menu-empty {
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.command-menu {
|
|
||||||
background-color: #1f2937 !important;
|
|
||||||
border: 1px solid #374151 !important;
|
|
||||||
}
|
|
||||||
.command-menu-empty {
|
|
||||||
color: #9ca3af !important;
|
|
||||||
}
|
|
||||||
.command-item[aria-selected="true"] {
|
|
||||||
background-color: #1e40af !important;
|
|
||||||
}
|
|
||||||
.command-item span:not(.command-metadata-badge) {
|
|
||||||
color: #f3f4f6 !important;
|
|
||||||
}
|
|
||||||
.command-metadata-badge {
|
|
||||||
background-color: #f3f4f6 !important;
|
|
||||||
color: #6b7280 !important;
|
|
||||||
}
|
|
||||||
.command-item div {
|
|
||||||
color: #d1d5db !important;
|
|
||||||
}
|
|
||||||
.command-group > div:first-child {
|
|
||||||
color: #9ca3af !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommandMenu;
|
|
||||||
@@ -1,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;
|
|
||||||
@@ -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;
|
|
||||||
48
src/components/DarkModeToggle.tsx
Normal file
48
src/components/DarkModeToggle.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 = '' }) => {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
224
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal file
224
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
17
src/components/code-editor/constants/settings.ts
Normal file
17
src/components/code-editor/constants/settings.ts
Normal 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';
|
||||||
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal file
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal file
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
21
src/components/code-editor/types/types.ts
Normal file
21
src/components/code-editor/types/types.ts
Normal 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;
|
||||||
|
};
|
||||||
141
src/components/code-editor/utils/editorExtensions.ts
Normal file
141
src/components/code-editor/utils/editorExtensions.ts
Normal 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() {}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
79
src/components/code-editor/utils/editorStyles.ts
Normal file
79
src/components/code-editor/utils/editorStyles.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
};
|
||||||
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal file
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
);
|
||||||
|
|
||||||
|
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)];
|
||||||
|
};
|
||||||
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
234
src/components/code-editor/view/CodeEditor.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/file-tree/constants/constants.ts
Normal file
18
src/components/file-tree/constants/constants.ts
Normal 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',
|
||||||
|
]);
|
||||||
224
src/components/file-tree/constants/fileIcons.ts
Normal file
224
src/components/file-tree/constants/fileIcons.ts
Normal 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' };
|
||||||
|
}
|
||||||
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal file
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal file
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal file
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal file
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
30
src/components/file-tree/types/types.ts
Normal file
30
src/components/file-tree/types/types.ts
Normal 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>;
|
||||||
83
src/components/file-tree/utils/fileTreeUtils.ts
Normal file
83
src/components/file-tree/utils/fileTreeUtils.ts
Normal 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));
|
||||||
|
}
|
||||||
|
|
||||||
103
src/components/file-tree/view/FileTree.tsx
Normal file
103
src/components/file-tree/view/FileTree.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/file-tree/view/FileTreeBody.tsx
Normal file
62
src/components/file-tree/view/FileTreeBody.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
17
src/components/file-tree/view/FileTreeDetailedColumns.tsx
Normal file
17
src/components/file-tree/view/FileTreeDetailedColumns.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
20
src/components/file-tree/view/FileTreeEmptyState.tsx
Normal file
20
src/components/file-tree/view/FileTreeEmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
81
src/components/file-tree/view/FileTreeHeader.tsx
Normal file
81
src/components/file-tree/view/FileTreeHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
src/components/file-tree/view/FileTreeList.tsx
Normal file
42
src/components/file-tree/view/FileTreeList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
12
src/components/file-tree/view/FileTreeLoadingState.tsx
Normal file
12
src/components/file-tree/view/FileTreeLoadingState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
141
src/components/file-tree/view/FileTreeNode.tsx
Normal file
141
src/components/file-tree/view/FileTreeNode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
70
src/components/git-panel/constants/constants.ts
Normal file
70
src/components/git-panel/constants/constants.ts
Normal 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',
|
||||||
|
};
|
||||||
710
src/components/git-panel/hooks/useGitPanelController.ts
Normal file
710
src/components/git-panel/hooks/useGitPanelController.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/components/git-panel/hooks/useSelectedProvider.ts
Normal file
20
src/components/git-panel/hooks/useSelectedProvider.ts
Normal 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;
|
||||||
|
}
|
||||||
135
src/components/git-panel/types/types.ts
Normal file
135
src/components/git-panel/types/types.ts
Normal 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;
|
||||||
|
};
|
||||||
26
src/components/git-panel/utils/gitPanelUtils.ts
Normal file
26
src/components/git-panel/utils/gitPanelUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
150
src/components/git-panel/view/GitPanel.tsx
Normal file
150
src/components/git-panel/view/GitPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/git-panel/view/GitPanelHeader.tsx
Normal file
263
src/components/git-panel/view/GitPanelHeader.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/git-panel/view/GitRepositoryErrorState.tsx
Normal file
27
src/components/git-panel/view/GitRepositoryErrorState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/git-panel/view/GitViewTabs.tsx
Normal file
45
src/components/git-panel/view/GitViewTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
src/components/git-panel/view/changes/ChangesView.tsx
Normal file
213
src/components/git-panel/view/changes/ChangesView.tsx
Normal 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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/components/git-panel/view/changes/CommitComposer.tsx
Normal file
162
src/components/git-panel/view/changes/CommitComposer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/components/git-panel/view/changes/FileChangeItem.tsx
Normal file
138
src/components/git-panel/view/changes/FileChangeItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/git-panel/view/changes/FileChangeList.tsx
Normal file
55
src/components/git-panel/view/changes/FileChangeList.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/git-panel/view/changes/FileStatusLegend.tsx
Normal file
52
src/components/git-panel/view/changes/FileStatusLegend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/git-panel/view/history/CommitHistoryItem.tsx
Normal file
71
src/components/git-panel/view/history/CommitHistoryItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/git-panel/view/history/HistoryView.tsx
Normal file
77
src/components/git-panel/view/history/HistoryView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/components/git-panel/view/modals/ConfirmActionModal.tsx
Normal file
97
src/components/git-panel/view/modals/ConfirmActionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/git-panel/view/modals/NewBranchModal.tsx
Normal file
124
src/components/git-panel/view/modals/NewBranchModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
);
|
);
|
||||||
@@ -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 (
|
||||||
@@ -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 (
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
45
src/components/mic-button/constants/constants.ts
Normal file
45
src/components/mic-button/constants/constants.ts
Normal 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.';
|
||||||
|
|
||||||
52
src/components/mic-button/data/whisper.ts
Normal file
52
src/components/mic-button/data/whisper.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
204
src/components/mic-button/hooks/useMicButtonController.ts
Normal file
204
src/components/mic-button/hooks/useMicButtonController.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
2
src/components/mic-button/types/types.ts
Normal file
2
src/components/mic-button/types/types.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';
|
||||||
|
|
||||||
32
src/components/mic-button/view/MicButton.tsx
Normal file
32
src/components/mic-button/view/MicButton.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
86
src/components/mic-button/view/MicButtonView.tsx
Normal file
86
src/components/mic-button/view/MicButtonView.tsx
Normal 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
Reference in New Issue
Block a user