mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-12 21:29:42 +00:00
Merge branch 'main' into fix/registration-race-condition
This commit is contained in:
@@ -444,10 +444,25 @@ router.get('/remote-status', async (req, res) => {
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured
|
||||
// No upstream branch configured - but check if we have remotes
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
}
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
hasRemote: false,
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
remoteName,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
@@ -462,6 +477,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
|
||||
res.json({
|
||||
hasRemote: true,
|
||||
hasUpstream: true,
|
||||
branch,
|
||||
remoteBranch: trackingBranch,
|
||||
remoteName,
|
||||
@@ -653,6 +669,82 @@ router.post('/push', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Publish branch to remote (set upstream and push)
|
||||
router.post('/publish', async (req, res) => {
|
||||
const { project, branch } = req.body;
|
||||
|
||||
if (!project || !branch) {
|
||||
return res.status(400).json({ error: 'Project name and branch are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
try {
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the branch (set upstream and push)
|
||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Branch published successfully',
|
||||
remoteName,
|
||||
branch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git publish error:', error);
|
||||
|
||||
// Enhanced error handling for common publish scenarios
|
||||
let errorMessage = 'Publish failed';
|
||||
let details = error.message;
|
||||
|
||||
if (error.message.includes('rejected')) {
|
||||
errorMessage = 'Publish rejected';
|
||||
details = 'The remote branch already exists and has different commits. Use push instead.';
|
||||
} else if (error.message.includes('Could not resolve hostname')) {
|
||||
errorMessage = 'Network error';
|
||||
details = 'Unable to connect to remote repository. Check your internet connection.';
|
||||
} else if (error.message.includes('Permission denied')) {
|
||||
errorMessage = 'Authentication failed';
|
||||
details = 'Permission denied. Check your credentials or SSH keys.';
|
||||
} else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
|
||||
errorMessage = 'Remote not configured';
|
||||
details = 'Remote repository not properly configured. Check your remote URL.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: errorMessage,
|
||||
details: details
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Discard changes for a specific file
|
||||
router.post('/discard', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
@@ -692,4 +784,39 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete untracked file
|
||||
router.post('/delete-untracked', async (req, res) => {
|
||||
const { project, file } = req.body;
|
||||
|
||||
if (!project || !file) {
|
||||
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
}
|
||||
|
||||
const status = statusOutput.substring(0, 2);
|
||||
|
||||
if (status !== '??') {
|
||||
return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
|
||||
}
|
||||
|
||||
// Delete the untracked file
|
||||
await fs.unlink(path.join(projectPath, file));
|
||||
|
||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('Git delete untracked error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
10
src/App.jsx
10
src/App.jsx
@@ -64,6 +64,10 @@ function AppContent() {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
@@ -586,6 +590,7 @@ function AppContent() {
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -617,6 +622,11 @@ function AppContent() {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={(value) => {
|
||||
setSendByCtrlEnter(value);
|
||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -118,7 +118,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{message.isToolUse ? (
|
||||
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -423,41 +423,18 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
// Extract filename
|
||||
const filename = input.file_path.split('/').pop();
|
||||
const pathParts = input.file_path.split('/');
|
||||
const directoryPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Simple heuristic to show only relevant path parts
|
||||
// Show the last 2-3 directory parts before the filename
|
||||
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
|
||||
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
|
||||
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span>
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
|
||||
</summary>
|
||||
{showRawParameters && (
|
||||
<div className="mt-3">
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -882,6 +859,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : message.isToolUse && message.toolName === 'Read' ? (
|
||||
// Simple Read tool indicator
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
const filename = input.file_path.split('/').pop();
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read{' '}
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📖 Read file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
|
||||
// Simple TodoWrite tool indicator with tasks
|
||||
(() => {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.todos && Array.isArray(input.todos)) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
<TodoList todos={input.todos} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📝 Update todo list
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : message.isToolUse && message.toolName === 'TodoRead' ? (
|
||||
// Simple TodoRead tool indicator
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
📋 Read todo list
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{message.type === 'assistant' ? (
|
||||
@@ -984,7 +1016,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
@@ -1400,19 +1432,45 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
toolResult: null // Will be updated when result comes in
|
||||
}]);
|
||||
} else if (part.type === 'text' && part.text?.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = part.text;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: part.text,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
}
|
||||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||||
// Check for usage limit message and format it user-friendly
|
||||
let content = messageData.content;
|
||||
if (content.includes('Claude AI usage limit reached|')) {
|
||||
const parts = content.split('|');
|
||||
if (parts.length === 2) {
|
||||
const timestamp = parseInt(parts[1]);
|
||||
if (!isNaN(timestamp)) {
|
||||
const resetTime = new Date(timestamp * 1000);
|
||||
content = `Claude AI usage limit reached. The limit will reset on ${resetTime.toLocaleDateString()} at ${resetTime.toLocaleTimeString()}.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add regular text message
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: messageData.content,
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
@@ -1966,15 +2024,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||||
if (e.key === 'Enter') {
|
||||
// If we're in composition, don't send message
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return; // Let IME handle the Enter key
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
// Ctrl+Enter or Cmd+Enter: Send message
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
// Plain Enter: Also send message (keeping original behavior)
|
||||
// Plain Enter: Send message only if not in IME composition
|
||||
if (!sendByCtrlEnter) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}
|
||||
// Shift+Enter: Allow default behavior (new line)
|
||||
}
|
||||
};
|
||||
@@ -2404,12 +2469,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
|
||||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
|
||||
</div>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
Enter to send • Tab for modes • @ for files
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
|
||||
: "Enter to send • Tab for modes • @ for files"}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
|
||||
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
|
||||
const textareaRef = useRef(null);
|
||||
@@ -266,6 +267,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
branch: currentBranch
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful publish
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Publish failed:', data.error);
|
||||
// TODO: Show user-friendly error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error publishing branch:', error);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const discardChanges = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/discard', {
|
||||
@@ -294,6 +323,34 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUntrackedFile = async (filePath) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/delete-untracked', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
file: filePath
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Remove from selected files and refresh status
|
||||
setSelectedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(filePath);
|
||||
return newSet;
|
||||
});
|
||||
fetchGitStatus();
|
||||
} else {
|
||||
console.error('Delete failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting untracked file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAndExecute = async () => {
|
||||
if (!confirmAction) return;
|
||||
|
||||
@@ -305,6 +362,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'discard':
|
||||
await discardChanges(file);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteUntrackedFile(file);
|
||||
break;
|
||||
case 'commit':
|
||||
await handleCommit();
|
||||
break;
|
||||
@@ -314,6 +374,9 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
case 'push':
|
||||
await handlePush();
|
||||
break;
|
||||
case 'publish':
|
||||
await handlePublish();
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing ${type}:`, error);
|
||||
@@ -578,6 +641,23 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
{isMobile && <span>Discard</span>}
|
||||
</button>
|
||||
)}
|
||||
{status === 'U' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
file: filePath,
|
||||
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
|
||||
});
|
||||
}}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||
title="Delete untracked file"
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||
{isMobile && <span>Delete</span>}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
@@ -716,7 +796,26 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
{/* Remote action buttons - smart logic based on ahead/behind status */}
|
||||
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
|
||||
{remoteStatus?.hasRemote && (
|
||||
<>
|
||||
{/* Publish button - show when branch doesn't exist on remote */}
|
||||
{!remoteStatus?.hasUpstream && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'publish',
|
||||
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPublishing}
|
||||
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPublishing ? 'Publishing...' : 'Publish'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show normal push/pull buttons only if branch has upstream */}
|
||||
{remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
@@ -764,6 +863,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`p-2 rounded-full mr-3 ${
|
||||
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${
|
||||
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||
confirmAction.type === 'delete' ? 'Delete File' :
|
||||
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' :
|
||||
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<button
|
||||
onClick={confirmAndExecute}
|
||||
className={`px-4 py-2 text-sm text-white rounded-md ${
|
||||
confirmAction.type === 'discard'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: confirmAction.type === 'commit'
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: confirmAction.type === 'pull'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: confirmAction.type === 'publish'
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-orange-600 hover:bg-orange-700'
|
||||
} flex items-center space-x-2`}
|
||||
>
|
||||
@@ -1161,6 +1266,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Discard</span>
|
||||
</>
|
||||
) : confirmAction.type === 'delete' ? (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</>
|
||||
) : confirmAction.type === 'commit' ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
@@ -1171,6 +1281,11 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Pull</span>
|
||||
</>
|
||||
) : confirmAction.type === 'publish' ? (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Publish</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -37,7 +37,9 @@ const LoginForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
@@ -39,7 +39,8 @@ function MainContent({
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
|
||||
@@ -285,6 +286,7 @@ function MainContent({
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
|
||||
@@ -2,13 +2,15 @@ import React from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center shadow-sm">
|
||||
<MessageSquare className="w-8 h-8 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Mic,
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText
|
||||
FileText,
|
||||
Languages
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -25,6 +26,8 @@ const QuickSettingsPanel = ({
|
||||
onShowRawParametersChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
@@ -142,6 +145,27 @@ const QuickSettingsPanel = ({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Input Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Send by Ctrl+Enter
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
|
||||
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper Dictation Settings - HIDDEN */}
|
||||
<div className="space-y-2" style={{ display: 'none' }}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
|
||||
|
||||
@@ -374,9 +374,7 @@ function Sidebar({
|
||||
|
||||
try {
|
||||
const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
||||
const response = await fetch(
|
||||
`/api/projects/${project.name}/sessions?limit=5&offset=${currentSessionCount}`
|
||||
);
|
||||
const response = await api.sessions(project.name, 5, currentSessionCount);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user