diff --git a/server/routes/git.js b/server/routes/git.js index 2f29ec9..b56b3e4 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -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 ' + }); + } + 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 ' + }); + } + + // 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; \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 68e6e21..b9a9d8c 100755 --- a/src/App.jsx +++ b/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} /> @@ -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} /> )} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 2039198..2d898eb 100755 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -118,7 +118,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
- {message.isToolUse ? ( + {message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
@@ -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 ( -
- - - - - - - - {relativePath} - {filename} - - {showRawParameters && ( -
-
- - View raw parameters - -
-                                    {message.toolInput}
-                                  
-
-
- )} -
+
+ Read{' '} + +
); } } catch (e) { @@ -882,6 +859,61 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
+ ) : 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 ( +
+ 📖 Read{' '} + +
+ ); + } + } catch (e) { + return ( +
+ 📖 Read file +
+ ); + } + })() + ) : 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 ( +
+
+ 📝 Update todo list +
+ +
+ ); + } + } catch (e) { + return ( +
+ 📝 Update todo list +
+ ); + } + })() + ) : message.isToolUse && message.toolName === 'TodoRead' ? ( + // Simple TodoRead tool indicator +
+ 📋 Read todo list +
) : (
{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,14 +2024,21 @@ 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) - e.preventDefault(); - handleSubmit(e); + // 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
{/* Hint text */}
- 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"}
- 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"}
diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 207c199..06d6217 100755 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -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 && Discard} )} + {status === 'U' && ( + + )} {/* Remote action buttons - smart logic based on ahead/behind status */} - {remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && ( + {remoteStatus?.hasRemote && ( <> - {/* Pull button - show when behind (primary action) */} - {remoteStatus.behind > 0 && ( + {/* Publish button - show when branch doesn't exist on remote */} + {!remoteStatus?.hasUpstream && ( )} - {/* Push button - show when ahead (primary action when ahead only) */} - {remoteStatus.ahead > 0 && ( - - )} - - {/* Fetch button - show when ahead only or when diverged (secondary action) */} - {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( - + {/* Show normal push/pull buttons only if branch has upstream */} + {remoteStatus?.hasUpstream && !remoteStatus?.isUpToDate && ( + <> + {/* Pull button - show when behind (primary action) */} + {remoteStatus.behind > 0 && ( + + )} + + {/* Push button - show when ahead (primary action when ahead only) */} + {remoteStatus.ahead > 0 && ( + + )} + + {/* Fetch button - show when ahead only or when diverged (secondary action) */} + {(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && ( + + )} + )} )} @@ -1120,16 +1221,18 @@ function GitPanel({ selectedProject, isMobile }) {

{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'}

@@ -1147,12 +1250,14 @@ function GitPanel({ selectedProject, isMobile }) {