UX enhancements on gitpanel and Shell to make them more mobile friendly

This commit is contained in:
simos
2025-07-13 05:06:31 +00:00
parent 24282ababe
commit 02cc0257ae
5 changed files with 312 additions and 38 deletions

View File

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

View File

@@ -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
</div>
) : (
<>
{chatMessages.length > 100 && (
{chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
Showing last 100 messages ({chatMessages.length} total)
<button className="ml-1 text-blue-600 hover:text-blue-700 underline">
Showing last {visibleMessageCount} messages ({chatMessages.length} total)
<button
className="ml-1 text-blue-600 hover:text-blue-700 underline"
onClick={loadEarlierMessages}
>
Load earlier messages
</button>
</div>

View File

@@ -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,6 +528,24 @@ function GitPanel({ selectedProject, isMobile }) {
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div>
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
<div className="flex items-center gap-1">
{(status === 'M' || status === 'D') && (
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'discard',
file: filePath,
message: `Discard all changes to "${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="Discard changes"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
{isMobile && <span>Discard</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' :
@@ -489,6 +559,7 @@ function GitPanel({ selectedProject, isMobile }) {
</span>
</div>
</div>
</div>
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff
? 'max-h-[600px] opacity-100 translate-y-0'
@@ -618,7 +689,10 @@ function GitPanel({ selectedProject, isMobile }) {
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
<button
onClick={handlePull}
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
@@ -785,7 +859,10 @@ function GitPanel({ selectedProject, isMobile }) {
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={handleCommit}
onClick={() => setConfirmAction({
type: 'commit',
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
})}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
@@ -986,6 +1063,70 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
</div>
)}
{/* Confirmation Modal */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<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'
}`}>
<AlertTriangle className={`w-5 h-5 ${
confirmAction.type === 'discard' ? '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 === 'commit' ? 'Confirm Commit' : 'Confirm Pull'}
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{confirmAction.message}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setConfirmAction(null)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
>
Cancel
</button>
<button
onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${
confirmAction.type === 'discard'
? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-green-600 hover:bg-green-700'
} flex items-center space-x-2`}
>
{confirmAction.type === 'discard' ? (
<>
<Trash2 className="w-4 h-4" />
<span>Discard</span>
</>
) : confirmAction.type === 'commit' ? (
<>
<Check className="w-4 h-4" />
<span>Commit</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>Pull</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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
// 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
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 (
<div className="h-full flex flex-col bg-gray-900">
<div className="h-full flex flex-col bg-gray-900 w-full">
{/* Header */}
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
@@ -494,7 +576,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
{/* Terminal */}
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full" />
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{/* Loading state */}
{!isInitialized && (

View File

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