mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-13 05:39:41 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d63b18c69 | ||
|
|
9f65756e2c | ||
|
|
db8c086f22 | ||
|
|
111311eac5 | ||
|
|
6934581488 | ||
|
|
c0f30afb23 | ||
|
|
aa1df3c9c8 | ||
|
|
ed920bb73b | ||
|
|
e72be46733 | ||
|
|
a5ddef58df | ||
|
|
02cc0257ae | ||
|
|
24282ababe | ||
|
|
71ac848d60 | ||
|
|
7329f89c96 | ||
|
|
00acc57161 | ||
|
|
9ac604de41 |
14
README.md
14
README.md
@@ -33,10 +33,11 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
|
||||
|
||||
@@ -72,6 +73,7 @@ cp .env.example .env
|
||||
npm run dev
|
||||
|
||||
```
|
||||
The application will start at the port you specified in your .env
|
||||
|
||||
5. **Open your browser:**
|
||||
- Development: `http://localhost:3001`
|
||||
@@ -120,17 +122,21 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
- **Syntax Highlighting** - Support for multiple programming languages
|
||||
- **File Operations** - Create, rename, delete files and directories
|
||||
|
||||
#### Git Explorer
|
||||
|
||||
|
||||
#### Session Management
|
||||
- **Session Persistence** - All conversations automatically saved
|
||||
- **Session Organization** - Group sessions by project and timestamp
|
||||
- **Session Actions** - Rename, delete, and export conversation history
|
||||
- **Cross-device Sync** - Access sessions from any device
|
||||
|
||||
### Mobile Experience
|
||||
### Mobile App
|
||||
- **Responsive Design** - Optimized for all screen sizes
|
||||
- **Touch-friendly Interface** - Swipe gestures and touch navigation
|
||||
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
|
||||
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
|
||||
- **Add shortcut to Home Screen** - Add a shortcut to your home screen and the app will behave like a PWA
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -230,8 +236,10 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
- **Watch** for updates and new releases
|
||||
- **Follow** the project for announcements
|
||||
|
||||
### Sponsors
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Made with care for the Claude Code community</strong>
|
||||
<strong>Made with care for the Claude Code community.</strong>
|
||||
</div>
|
||||
|
||||
151
package-lock.json
generated
151
package-lock.json
generated
@@ -31,10 +31,12 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.515.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -2115,6 +2117,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
@@ -2125,6 +2133,15 @@
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@@ -2366,6 +2383,23 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2677,6 +2711,21 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||
@@ -3195,6 +3244,18 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
@@ -4578,6 +4639,18 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -4588,6 +4661,24 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
|
||||
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -5049,6 +5140,17 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -5171,6 +5273,29 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -5966,6 +6091,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -6407,8 +6540,7 @@
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
@@ -6452,6 +6584,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
@@ -6860,6 +6998,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
|
||||
@@ -130,18 +130,6 @@ async function setupProjectsWatcher() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the first non-localhost IP address
|
||||
function getServerIP() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
@@ -188,9 +176,7 @@ app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// API Routes (protected)
|
||||
app.get('/api/config', authenticateToken, (req, res) => {
|
||||
// Always use the server's actual IP and port for WebSocket connections
|
||||
const serverIP = getServerIP();
|
||||
const host = `${serverIP}:${PORT}`;
|
||||
const host = req.headers.host || `${req.hostname}:${PORT}`;
|
||||
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
||||
|
||||
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
||||
@@ -1006,4 +992,4 @@ async function startServer() {
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
startServer();
|
||||
|
||||
@@ -11,7 +11,6 @@ let cacheTimestamp = Date.now();
|
||||
function clearProjectDirectoryCache() {
|
||||
projectDirectoryCache.clear();
|
||||
cacheTimestamp = Date.now();
|
||||
console.log('🗑️ Project directory cache cleared');
|
||||
}
|
||||
|
||||
// Load project configuration file
|
||||
@@ -73,7 +72,6 @@ async function extractProjectDirectory(projectName) {
|
||||
return projectDirectoryCache.get(projectName);
|
||||
}
|
||||
|
||||
console.log(`🔍 Extracting project directory for: ${projectName}`);
|
||||
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const cwdCounts = new Map();
|
||||
@@ -155,7 +153,6 @@ async function extractProjectDirectory(projectName) {
|
||||
|
||||
// Cache the result
|
||||
projectDirectoryCache.set(projectName, extractedPath);
|
||||
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
|
||||
|
||||
return extractedPath;
|
||||
|
||||
|
||||
@@ -420,4 +420,276 @@ function generateSimpleCommitMessage(files, diff) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote status (ahead/behind commits with smart remote detection)
|
||||
router.get('/remote-status', async (req, res) => {
|
||||
const { project } = req.query;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Check if there's a remote tracking branch (smart detection)
|
||||
let trackingBranch;
|
||||
let remoteName;
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
// No upstream branch configured
|
||||
return res.json({
|
||||
hasRemote: false,
|
||||
branch,
|
||||
message: 'No remote tracking branch configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
const { stdout: countOutput } = await execAsync(
|
||||
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
const [behind, ahead] = countOutput.trim().split('\t').map(Number);
|
||||
|
||||
res.json({
|
||||
hasRemote: true,
|
||||
branch,
|
||||
remoteBranch: trackingBranch,
|
||||
remoteName,
|
||||
ahead: ahead || 0,
|
||||
behind: behind || 0,
|
||||
isUpToDate: ahead === 0 && behind === 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git remote status error:', error);
|
||||
res.json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch from remote (using smart remote detection)
|
||||
router.post('/fetch', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
||||
} catch (error) {
|
||||
// No upstream, try to fetch from origin anyway
|
||||
console.log('No upstream configured, using origin as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
||||
} catch (error) {
|
||||
console.error('Git fetch error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fetch failed',
|
||||
details: error.message.includes('Could not resolve hostname')
|
||||
? 'Unable to connect to remote repository. Check your internet connection.'
|
||||
: error.message.includes('fatal: \'origin\' does not appear to be a git repository')
|
||||
? 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Pull from remote (fetch + merge using smart remote detection)
|
||||
router.post('/pull', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
} catch (error) {
|
||||
// No upstream, use fallback
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git pull error:', error);
|
||||
|
||||
// Enhanced error handling for common pull scenarios
|
||||
let errorMessage = 'Pull failed';
|
||||
let details = error.message;
|
||||
|
||||
if (error.message.includes('CONFLICT')) {
|
||||
errorMessage = 'Merge conflicts detected';
|
||||
details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
|
||||
} else if (error.message.includes('Please commit your changes or stash them')) {
|
||||
errorMessage = 'Uncommitted changes detected';
|
||||
details = 'Please commit or stash your local changes before pulling.';
|
||||
} 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('fatal: \'origin\' does not appear to be a git repository')) {
|
||||
errorMessage = 'Remote not configured';
|
||||
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
||||
} else if (error.message.includes('diverged')) {
|
||||
errorMessage = 'Branches have diverged';
|
||||
details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: errorMessage,
|
||||
details: details
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Push commits to remote repository
|
||||
router.post('/push', async (req, res) => {
|
||||
const { project } = req.body;
|
||||
|
||||
if (!project) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
} catch (error) {
|
||||
// No upstream, use fallback
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git push error:', error);
|
||||
|
||||
// Enhanced error handling for common push scenarios
|
||||
let errorMessage = 'Push failed';
|
||||
let details = error.message;
|
||||
|
||||
if (error.message.includes('rejected')) {
|
||||
errorMessage = 'Push rejected';
|
||||
details = 'The remote has newer commits. Pull first to merge changes before pushing.';
|
||||
} else if (error.message.includes('non-fast-forward')) {
|
||||
errorMessage = 'Non-fast-forward push';
|
||||
details = 'Your branch is behind the remote. Pull the latest changes first.';
|
||||
} 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('fatal: \'origin\' does not appear to be a git repository')) {
|
||||
errorMessage = 'Remote not configured';
|
||||
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
||||
} else if (error.message.includes('Permission denied')) {
|
||||
errorMessage = 'Authentication failed';
|
||||
details = 'Permission denied. Check your credentials or SSH keys.';
|
||||
} else if (error.message.includes('no upstream branch')) {
|
||||
errorMessage = 'No upstream branch';
|
||||
details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
@@ -465,6 +465,32 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for exit_plan_mode tool
|
||||
if (message.toolName === 'exit_plan_mode') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.plan) {
|
||||
// Replace escaped newlines with actual newlines
|
||||
const planContent = input.plan.replace(/\\n/g, '\n');
|
||||
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-2">
|
||||
<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>
|
||||
📋 View implementation plan
|
||||
</summary>
|
||||
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown>{planContent}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Regular tool input display for other tools
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
@@ -551,6 +577,30 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for exit_plan_mode tool results
|
||||
if (message.toolName === 'exit_plan_mode') {
|
||||
try {
|
||||
// The content should be JSON with a "plan" field
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed.plan) {
|
||||
// Replace escaped newlines with actual newlines
|
||||
const planContent = parsed.plan.replace(/\\n/g, '\n');
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium">Implementation Plan</span>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown>{planContent}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for interactive prompts
|
||||
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
|
||||
const lines = content.split('\n');
|
||||
@@ -977,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);
|
||||
|
||||
|
||||
@@ -1580,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(() => {
|
||||
@@ -1687,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 => {
|
||||
@@ -1930,19 +1985,34 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||
|
||||
const newInput = textBeforeAt + '@' + file.path + textAfterQuery;
|
||||
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
||||
|
||||
// Immediately ensure focus is maintained
|
||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
|
||||
// Update input and cursor position
|
||||
setInput(newInput);
|
||||
setCursorPosition(newCursorPos);
|
||||
|
||||
// Hide dropdown
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
|
||||
// Focus back to textarea and set cursor position
|
||||
// Set cursor position synchronously
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length;
|
||||
setTimeout(() => {
|
||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||
setCursorPosition(newCursorPos);
|
||||
}, 0);
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||
// Ensure focus is maintained
|
||||
if (!textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2031,10 +2101,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>
|
||||
@@ -2180,6 +2253,37 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File dropdown - positioned outside dropzone to avoid conflicts */}
|
||||
{showFileDropdown && filteredFiles.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm">
|
||||
{filteredFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
|
||||
index === selectedFileIndex
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent textarea from losing focus on mobile
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectFile(file);
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
<input {...getInputProps()} />
|
||||
<textarea
|
||||
@@ -2297,28 +2401,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* File dropdown */}
|
||||
{showFileDropdown && filteredFiles.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{filteredFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={`px-4 py-2 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
|
||||
index === selectedFileIndex
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
onClick={() => selectFile(file)}
|
||||
>
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{file.path}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Hint text */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
@@ -24,6 +24,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
const [expandedCommits, setExpandedCommits] = useState(new Set());
|
||||
const [commitDiffs, setCommitDiffs] = useState({});
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||
const [remoteStatus, setRemoteStatus] = useState(null);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [isPushing, setIsPushing] = 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);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
@@ -31,6 +37,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
if (selectedProject) {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
fetchRemoteStatus();
|
||||
if (activeView === 'history') {
|
||||
fetchRecentCommits();
|
||||
}
|
||||
@@ -105,6 +112,24 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRemoteStatus = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
setRemoteStatus(data);
|
||||
} else {
|
||||
setRemoteStatus(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching remote status:', error);
|
||||
setRemoteStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
const switchBranch = async (branchName) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/checkout', {
|
||||
@@ -161,6 +186,140 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetch = async () => {
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/fetch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful fetch
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Fetch failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching from remote:', error);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
setIsPulling(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/pull', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful pull
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Pull failed:', data.error);
|
||||
// TODO: Show user-friendly error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error pulling from remote:', error);
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = async () => {
|
||||
setIsPushing(true);
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/git/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Refresh status after successful push
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Push failed:', data.error);
|
||||
// TODO: Show user-friendly error message
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error pushing to remote:', error);
|
||||
} finally {
|
||||
setIsPushing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
case 'push':
|
||||
await handlePush();
|
||||
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)}`);
|
||||
@@ -292,6 +451,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
setCommitMessage('');
|
||||
setSelectedFiles(new Set());
|
||||
fetchGitStatus();
|
||||
fetchRemoteStatus();
|
||||
} else {
|
||||
console.error('Commit failed:', data.error);
|
||||
}
|
||||
@@ -384,39 +544,77 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
return (
|
||||
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div className="flex items-center px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelected(filePath)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mr-2 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"
|
||||
className={`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 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center flex-1 cursor-pointer"
|
||||
onClick={() => toggleFileExpanded(filePath)}
|
||||
>
|
||||
<div className="mr-2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
|
||||
<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' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
title={getStatusLabel(status)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-1 text-sm truncate">{filePath}</span>
|
||||
<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' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
title={getStatusLabel(status)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && diff && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900">
|
||||
{isMobile && (
|
||||
<div className="flex justify-end p-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<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'
|
||||
: 'max-h-0 opacity-0 -translate-y-1'
|
||||
}`}>
|
||||
{/* Operation header */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<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' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -427,13 +625,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
>
|
||||
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -449,14 +646,36 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
|
||||
className="flex items-center space-x-2 px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium">{currentBranch}</span>
|
||||
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
|
||||
{/* Remote status indicators */}
|
||||
{remoteStatus?.hasRemote && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400" title={`${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} ahead`}>
|
||||
↑{remoteStatus.ahead}
|
||||
</span>
|
||||
)}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
|
||||
↓{remoteStatus.behind}
|
||||
</span>
|
||||
)}
|
||||
{remoteStatus.isUpToDate && (
|
||||
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
@@ -495,16 +714,69 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
{/* Remote action buttons - smart logic based on ahead/behind status */}
|
||||
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
|
||||
<>
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<button
|
||||
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}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
fetchRemoteStatus();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||
>
|
||||
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Repository Not Found Message */}
|
||||
@@ -523,8 +795,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tab Navigation - Only show when git is available */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{/* Tab Navigation - Only show when git is available and no files expanded */}
|
||||
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
@@ -556,68 +832,110 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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"
|
||||
{/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
|
||||
<div className={`transition-all duration-300 ease-in-out ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-96 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
{isMobile && isCommitAreaCollapsed ? (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setIsCommitAreaCollapsed(false)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<GitCommit className="w-4 h-4" />
|
||||
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
{/* Mobile collapse button */}
|
||||
{isMobile && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Commit Changes</span>
|
||||
<button
|
||||
onClick={() => setIsCommitAreaCollapsed(true)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
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-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
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"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Selection Controls - Only show in changes view and when git is working */}
|
||||
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
|
||||
{activeView === 'changes' && gitStatus && !gitStatus.error && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
||||
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}>
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
const allFiles = new Set([
|
||||
@@ -628,23 +946,23 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||
>
|
||||
Select All
|
||||
{isMobile ? 'All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||
>
|
||||
Deselect All
|
||||
{isMobile ? 'None' : 'Deselect All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Legend Toggle */}
|
||||
{!gitStatus?.error && (
|
||||
{/* Status Legend Toggle - Hide on mobile by default */}
|
||||
{!gitStatus?.error && !isMobile && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
@@ -793,6 +1111,78 @@ 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' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
|
||||
</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'
|
||||
: confirmAction.type === 'pull'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-orange-600 hover:bg-orange-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>
|
||||
</>
|
||||
) : confirmAction.type === 'pull' ? (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Pull</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>Push</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 && (
|
||||
|
||||
@@ -32,9 +32,31 @@ export const ThemeProvider = ({ children }) => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
|
||||
// Update iOS status bar style and theme color for dark mode
|
||||
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
|
||||
if (statusBarMeta) {
|
||||
statusBarMeta.setAttribute('content', 'black-translucent');
|
||||
}
|
||||
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
|
||||
}
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
|
||||
// Update iOS status bar style and theme color for light mode
|
||||
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
|
||||
if (statusBarMeta) {
|
||||
statusBarMeta.setAttribute('content', 'default');
|
||||
}
|
||||
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta) {
|
||||
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
|
||||
}
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user