From 02cc0257ae75bbe957ba4761cafc34a11eb4f775 Mon Sep 17 00:00:00 2001 From: simos Date: Sun, 13 Jul 2025 05:06:31 +0000 Subject: [PATCH] UX enhancements on gitpanel and Shell to make them more mobile friendly --- server/routes/git.js | 39 +++++++ src/components/ChatInterface.jsx | 24 +++-- src/components/GitPanel.jsx | 169 ++++++++++++++++++++++++++++--- src/components/Shell.jsx | 110 +++++++++++++++++--- src/index.css | 8 +- 5 files changed, 312 insertions(+), 38 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index cb0523b..0f27fa9 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -583,4 +583,43 @@ router.post('/pull', async (req, res) => { } }); +// Discard changes for a specific file +router.post('/discard', 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 file status to determine correct discard command + const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + + if (!statusOutput.trim()) { + return res.status(400).json({ error: 'No changes to discard for this file' }); + } + + const status = statusOutput.substring(0, 2); + + if (status === '??') { + // Untracked file - delete it + await fs.unlink(path.join(projectPath, file)); + } else if (status.includes('M') || status.includes('D')) { + // Modified or deleted file - restore from HEAD + await execAsync(`git restore "${file}"`, { cwd: projectPath }); + } else if (status.includes('A')) { + // Added file - unstage it + await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath }); + } + + res.json({ success: true, message: `Changes discarded for ${file}` }); + } catch (error) { + console.error('Git discard error:', error); + res.status(500).json({ error: error.message }); + } +}); + export default router; \ No newline at end of file diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index f5bc868..d367edc 100755 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1027,6 +1027,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); const [slashPosition, setSlashPosition] = useState(-1); + const [visibleMessageCount, setVisibleMessageCount] = useState(100); const [claudeStatus, setClaudeStatus] = useState(null); @@ -1630,14 +1631,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess return () => clearTimeout(timer); }, [input]); - // Show only recent messages for better performance (last 100 messages) + // Show only recent messages for better performance const visibleMessages = useMemo(() => { - const maxMessages = 100; - if (chatMessages.length <= maxMessages) { + if (chatMessages.length <= visibleMessageCount) { return chatMessages; } - return chatMessages.slice(-maxMessages); - }, [chatMessages]); + return chatMessages.slice(-visibleMessageCount); + }, [chatMessages, visibleMessageCount]); // Capture scroll position before render when auto-scroll is disabled useEffect(() => { @@ -1737,6 +1737,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } }, []); + // Load earlier messages by increasing the visible message count + const loadEarlierMessages = useCallback(() => { + setVisibleMessageCount(prevCount => prevCount + 100); + }, []); + // Handle image files from drag & drop or file picker const handleImageFiles = useCallback((files) => { const validFiles = files.filter(file => { @@ -2081,10 +2086,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess ) : ( <> - {chatMessages.length > 100 && ( + {chatMessages.length > visibleMessageCount && (
- Showing last 100 messages ({chatMessages.length} total) • -
diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 645fcec..8dc9118 100755 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download } from 'lucide-react'; +import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle } from 'lucide-react'; import { MicButton } from './MicButton.jsx'; import { authenticatedFetch } from '../utils/api'; @@ -28,6 +28,7 @@ function GitPanel({ selectedProject, isMobile }) { const [isFetching, setIsFetching] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile + const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull', file?: string, message?: string } const textareaRef = useRef(null); const dropdownRef = useRef(null); @@ -237,6 +238,57 @@ function GitPanel({ selectedProject, isMobile }) { } }; + const discardChanges = async (filePath) => { + try { + const response = await authenticatedFetch('/api/git/discard', { + 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('Discard failed:', data.error); + } + } catch (error) { + console.error('Error discarding changes:', error); + } + }; + + const confirmAndExecute = async () => { + if (!confirmAction) return; + + const { type, file, message } = confirmAction; + setConfirmAction(null); + + try { + switch (type) { + case 'discard': + await discardChanges(file); + break; + case 'commit': + await handleCommit(); + break; + case 'pull': + await handlePull(); + break; + } + } catch (error) { + console.error(`Error executing ${type}:`, error); + } + }; + const fetchFileDiff = async (filePath) => { try { const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`); @@ -476,17 +528,36 @@ function GitPanel({ selectedProject, isMobile }) { {filePath} - - {status} - +
+ {(status === 'M' || status === 'D') && ( + + )} + + {status} + +
0 && (
)} + + {/* Confirmation Modal */} + {confirmAction && ( +
+
setConfirmAction(null)} /> +
+
+
+
+ +
+

+ {confirmAction.type === 'discard' ? 'Discard Changes' : + confirmAction.type === 'commit' ? 'Confirm Commit' : 'Confirm Pull'} +

+
+ +

+ {confirmAction.message} +

+ +
+ + +
+
+
+
+ )}
); } diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx index f32a7a2..3033b03 100755 --- a/src/components/Shell.jsx +++ b/src/components/Shell.jsx @@ -5,6 +5,27 @@ import { ClipboardAddon } from '@xterm/addon-clipboard'; import { WebglAddon } from '@xterm/addon-webgl'; import 'xterm/css/xterm.css'; +// CSS to remove xterm focus outline +const xtermStyles = ` + .xterm .xterm-screen { + outline: none !important; + } + .xterm:focus .xterm-screen { + outline: none !important; + } + .xterm-screen:focus { + outline: none !important; + } +`; + +// Inject styles +if (typeof document !== 'undefined') { + const styleSheet = document.createElement('style'); + styleSheet.type = 'text/css'; + styleSheet.innerText = xtermStyles; + document.head.appendChild(styleSheet); +} + // Global store for shell sessions to persist across tab switches const shellSessions = new Map(); @@ -138,6 +159,14 @@ function Shell({ selectedProject, selectedSession, isActive }) { setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); + // Send terminal size to backend after reattaching + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify({ + type: 'resize', + cols: terminal.current.cols, + rows: terminal.current.rows + })); + } } }, 100); @@ -226,6 +255,13 @@ function Shell({ selectedProject, selectedSession, isActive }) { terminal.current.open(terminalRef.current); + // Wait for terminal to be fully rendered, then fit + setTimeout(() => { + if (fitAddon.current) { + fitAddon.current.fit(); + } + }, 50); + // Add keyboard shortcuts for copy/paste terminal.current.attachCustomKeyEventHandler((event) => { // Ctrl+C or Cmd+C for copy (when text is selected) @@ -252,10 +288,18 @@ function Shell({ selectedProject, selectedSession, isActive }) { return true; }); - // Ensure terminal takes full space + // Ensure terminal takes full space and notify backend of size setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); + // Send terminal size to backend after fitting + 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); @@ -276,6 +320,14 @@ function Shell({ selectedProject, selectedSession, isActive }) { if (fitAddon.current && terminal.current) { setTimeout(() => { fitAddon.current.fit(); + // Send updated terminal size to backend after resize + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + ws.current.send(JSON.stringify({ + type: 'resize', + cols: terminal.current.cols, + rows: terminal.current.rows + })); + } }, 50); } }); @@ -309,10 +361,18 @@ function Shell({ selectedProject, selectedSession, isActive }) { useEffect(() => { if (!isActive || !isInitialized) return; - // Fit terminal when tab becomes active + // Fit terminal when tab becomes active and notify backend setTimeout(() => { if (fitAddon.current) { fitAddon.current.fit(); + // Send terminal size to backend after tab activation + 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); }, [isActive, isInitialized]); @@ -363,16 +423,38 @@ function Shell({ selectedProject, selectedSession, isActive }) { setIsConnected(true); setIsConnecting(false); - // Send initial setup with project path and session info - const initPayload = { - type: 'init', - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: selectedSession?.id, - hasSession: !!selectedSession - }; - - - ws.current.send(JSON.stringify(initPayload)); + // Wait for terminal to be ready, then fit and send dimensions + setTimeout(() => { + if (fitAddon.current && terminal.current) { + // Force a fit to ensure proper dimensions + fitAddon.current.fit(); + + // Wait a bit more for fit to complete, then send dimensions + setTimeout(() => { + const initPayload = { + type: 'init', + projectPath: selectedProject.fullPath || selectedProject.path, + sessionId: selectedSession?.id, + hasSession: !!selectedSession, + cols: terminal.current.cols, + rows: terminal.current.rows + }; + + ws.current.send(JSON.stringify(initPayload)); + + // Also send resize message immediately after init + setTimeout(() => { + 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); + }, 50); + } + }, 200); }; ws.current.onmessage = (event) => { @@ -442,7 +524,7 @@ function Shell({ selectedProject, selectedSession, isActive }) { } return ( -
+
{/* Header */}
@@ -494,7 +576,7 @@ function Shell({ selectedProject, selectedSession, isActive }) { {/* Terminal */}
-
+
{/* Loading state */} {!isInitialized && ( diff --git a/src/index.css b/src/index.css index 8dec838..274e2df 100755 --- a/src/index.css +++ b/src/index.css @@ -99,8 +99,12 @@ transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); } - /* Color transitions for theme switching */ - * { + /* Color transitions for theme switching - exclude interactive elements */ + body, div, section, article, aside, header, footer, nav, main, + h1, h2, h3, h4, h5, h6, p, span, blockquote, + ul, ol, li, dl, dt, dd, + table, thead, tbody, tfoot, tr, td, th, + form, fieldset, legend, label { transition: background-color 200ms ease-in-out, border-color 200ms ease-in-out, color 200ms ease-in-out;