mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 10:59:47 +00:00
Compare commits
52 Commits
v1.8.1
...
7d0fd141ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d0fd141ff | ||
|
|
e5a05d9865 | ||
|
|
2d6c3b5755 | ||
|
|
2a5d27ffc0 | ||
|
|
3c9a4cab82 | ||
|
|
ce9ab0cd16 | ||
|
|
66fad9a6a2 | ||
|
|
0fcf906ff0 | ||
|
|
7e1f2940d3 | ||
|
|
533d589132 | ||
|
|
d1f310161f | ||
|
|
b853e8cda1 | ||
|
|
1610de1f22 | ||
|
|
3f743d8210 | ||
|
|
cbb18fb010 | ||
|
|
d8d754274a | ||
|
|
eb12aef641 | ||
|
|
5e574dbdec | ||
|
|
af9e9eec02 | ||
|
|
8c3ee770c3 | ||
|
|
58108c083c | ||
|
|
af0ad6b4b6 | ||
|
|
36d9f47e29 | ||
|
|
1f25f1e79b | ||
|
|
d7ed1de1cb | ||
|
|
9be54233d0 | ||
|
|
c8bcad71e7 | ||
|
|
a3f504aed2 | ||
|
|
f766ac1517 | ||
|
|
680d8f6fb1 | ||
|
|
1820f3bfa8 | ||
|
|
376e055443 | ||
|
|
f4becdc218 | ||
|
|
70b421e5a8 | ||
|
|
d74a99ef24 | ||
|
|
771f7e4d7b | ||
|
|
cab13a1534 | ||
|
|
1eba594418 | ||
|
|
ed5374a1bd | ||
|
|
573a04d2e5 | ||
|
|
133af82935 | ||
|
|
79981693f3 | ||
|
|
fb1117a999 | ||
|
|
40b8737732 | ||
|
|
15b95c4d08 | ||
|
|
3ff1db0331 | ||
|
|
dab0068d8f | ||
|
|
3daf21c3d1 | ||
|
|
f52ca8e702 | ||
|
|
11b2ff588a | ||
|
|
975e4b04a6 | ||
|
|
d82a004224 |
@@ -9,4 +9,7 @@
|
||||
#API server
|
||||
PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
VITE_PORT=5173
|
||||
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
21
.release-it.json
Normal file
21
.release-it.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "Claude Code UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
}
|
||||
}
|
||||
12
README.md
12
README.md
@@ -59,7 +59,17 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
||||
|
||||
### Installation
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
No installation required, direct operation:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
Your default browser will automatically open the Claude Code UI interface.
|
||||
|
||||
### Local Development Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Claude Code UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- iOS Safari PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
||||
|
||||
<!-- iOS Safari Icons -->
|
||||
|
||||
2285
package-lock.json
generated
2285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,23 +1,40 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.8.1",
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.8.10",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
"bin": {
|
||||
"claude-code-ui": "server/index.js"
|
||||
},
|
||||
"files": [
|
||||
"server/",
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/siteboon/claudecodeui/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||
"server": "node server/index.js",
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "npm run build && npm run server"
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "release-it"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude coode",
|
||||
"ai",
|
||||
"code",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"assistant"
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
@@ -63,10 +80,12 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"node-gyp": "^10.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^7.0.4"
|
||||
|
||||
@@ -25,15 +25,6 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
// Build Claude CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
|
||||
// Separate arguments for better cross-platform compatibility
|
||||
// This prevents issues with spaces and quotes on Windows
|
||||
args.push('--print');
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
|
||||
const workingDir = cwd || process.cwd();
|
||||
|
||||
@@ -225,6 +216,17 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
console.log('📝 Skip permissions disabled due to plan mode');
|
||||
}
|
||||
}
|
||||
|
||||
// Add print flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
|
||||
// Separate arguments for better cross-platform compatibility
|
||||
// This prevents issues with spaces and quotes on Windows
|
||||
args.push('--print');
|
||||
// Use `--` so user input is always treated as text, not options
|
||||
args.push('--');
|
||||
args.push(command);
|
||||
}
|
||||
|
||||
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
@@ -235,7 +237,11 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
||||
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
||||
|
||||
const claudeProcess = spawnFunction('claude', args, {
|
||||
// Use Claude CLI from environment variable or default to 'claude'
|
||||
const claudePath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
console.log('🔍 Using Claude CLI path:', claudePath);
|
||||
|
||||
const claudeProcess = spawnFunction(claudePath, args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
// Load environment variables from .env file
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -506,7 +507,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||
}
|
||||
|
||||
const files = await getFileTree(actualPath, 3, 0, true);
|
||||
const files = await getFileTree(actualPath, 10, 0, true);
|
||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
|
||||
@@ -899,22 +899,16 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
|
||||
// Check if project already exists in config or as a folder
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
await fs.access(projectDir);
|
||||
throw new Error(`Project already exists for path: ${absolutePath}`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
}
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
|
||||
85
src/App.jsx
85
src/App.jsx
@@ -33,6 +33,7 @@ import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
|
||||
|
||||
@@ -54,22 +55,10 @@ function AppContent() {
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||
const [autoExpandTools, setAutoExpandTools] = useState(() => {
|
||||
const saved = localStorage.getItem('autoExpandTools');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [showRawParameters, setShowRawParameters] = useState(() => {
|
||||
const saved = localStorage.getItem('showRawParameters');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useState(() => {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useState(() => {
|
||||
const saved = localStorage.getItem('sendByCtrlEnter');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false);
|
||||
const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false);
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
|
||||
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
@@ -77,6 +66,37 @@ function AppContent() {
|
||||
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
|
||||
// Detect if running as PWA
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running in standalone mode (PWA)
|
||||
const checkPWA = () => {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone ||
|
||||
document.referrer.includes('android-app://');
|
||||
setIsPWA(isStandalone);
|
||||
|
||||
// Add class to html and body for CSS targeting
|
||||
if (isStandalone) {
|
||||
document.documentElement.classList.add('pwa-mode');
|
||||
document.body.classList.add('pwa-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('pwa-mode');
|
||||
document.body.classList.remove('pwa-mode');
|
||||
}
|
||||
};
|
||||
|
||||
checkPWA();
|
||||
|
||||
// Listen for changes
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA);
|
||||
|
||||
return () => {
|
||||
window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -460,9 +480,10 @@ function AppContent() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
<button
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowVersionModal(false)}
|
||||
aria-label="Close version upgrade modal"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -561,6 +582,8 @@ function AppContent() {
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,7 +594,7 @@ function AppContent() {
|
||||
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
|
||||
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||
}`}>
|
||||
<div
|
||||
<button
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -582,6 +605,7 @@ function AppContent() {
|
||||
e.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
aria-label="Close sidebar"
|
||||
/>
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
@@ -607,13 +631,15 @@ function AppContent() {
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Flexible */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
@@ -623,6 +649,7 @@ function AppContent() {
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
isMobile={isMobile}
|
||||
isPWA={isPWA}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
@@ -652,25 +679,13 @@ function AppContent() {
|
||||
isOpen={showQuickSettings}
|
||||
onToggle={setShowQuickSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
onAutoExpandChange={(value) => {
|
||||
setAutoExpandTools(value);
|
||||
localStorage.setItem('autoExpandTools', JSON.stringify(value));
|
||||
}}
|
||||
onAutoExpandChange={setAutoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
onShowRawParametersChange={(value) => {
|
||||
setShowRawParameters(value);
|
||||
localStorage.setItem('showRawParameters', JSON.stringify(value));
|
||||
}}
|
||||
onShowRawParametersChange={setShowRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
onAutoScrollChange={(value) => {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
onAutoScrollChange={setAutoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onSendByCtrlEnterChange={(value) => {
|
||||
setSendByCtrlEnter(value);
|
||||
localStorage.setItem('sendByCtrlEnter', JSON.stringify(value));
|
||||
}}
|
||||
onSendByCtrlEnterChange={setSendByCtrlEnter}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -157,9 +157,11 @@ const safeLocalStorage = {
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
prevMessage.type === 'assistant' &&
|
||||
!prevMessage.isToolUse && !message.isToolUse;
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
@@ -3217,7 +3219,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
{/* Input Area - Fixed Bottom */}
|
||||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-2 sm:pb-4 md:pb-6'
|
||||
}`}>
|
||||
|
||||
<div className="flex-1">
|
||||
|
||||
41
src/components/DiffViewer.jsx
Normal file
41
src/components/DiffViewer.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs p-2 ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diff-viewer">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiffViewer;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } from 'lucide-react';
|
||||
import { Input } from './ui/input';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
@@ -14,6 +15,8 @@ function FileTree({ selectedProject }) {
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
@@ -29,6 +32,51 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Filter files based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFiles(files);
|
||||
} else {
|
||||
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||
setFilteredFiles(filtered);
|
||||
|
||||
// Auto-expand directories that contain matches
|
||||
const expandMatches = (items) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||
setExpandedDirs(prev => new Set(prev.add(item.path)));
|
||||
expandMatches(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandMatches(filtered);
|
||||
}
|
||||
}, [files, searchQuery]);
|
||||
|
||||
// Recursively filter files and directories based on search query
|
||||
const filterFiles = (items, query) => {
|
||||
return items.reduce((filtered, item) => {
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
let filteredChildren = [];
|
||||
|
||||
if (item.type === 'directory' && item.children) {
|
||||
filteredChildren = filterFiles(item.children, query);
|
||||
}
|
||||
|
||||
// Include item if:
|
||||
// 1. It matches the search query, or
|
||||
// 2. It's a directory with matching children
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
filtered.push({
|
||||
...item,
|
||||
children: filteredChildren
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -308,42 +356,67 @@ function FileTree({ selectedProject }) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* Header with Search and View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search files and folders..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && files.length > 0 && (
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-5">Name</div>
|
||||
@@ -365,11 +438,21 @@ function FileTree({ selectedProject }) {
|
||||
Check if the project path is accessible
|
||||
</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try a different search term or clear the search
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||
{viewMode === 'simple' && renderFileTree(files)}
|
||||
{viewMode === 'compact' && renderCompactView(files)}
|
||||
{viewMode === 'detailed' && renderDetailedView(files)}
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from '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';
|
||||
import DiffViewer from './DiffViewer.jsx';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
@@ -523,27 +524,6 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
@@ -590,7 +570,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||
{commit.stats}
|
||||
</div>
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -705,8 +685,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{diff && <DiffViewer diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ function MainContent({
|
||||
sendMessage,
|
||||
messages,
|
||||
isMobile,
|
||||
isPWA,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
@@ -152,10 +153,12 @@ function MainContent({
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -188,10 +191,12 @@ function MainContent({
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -224,7 +229,9 @@ function MainContent({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
{isMobile && (
|
||||
@@ -234,7 +241,7 @@ function MainContent({
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95"
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
|
||||
@@ -1992,8 +1992,8 @@ function Settings({ isOpen, onClose, projects = [] }) {
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
|
||||
|
||||
@@ -54,7 +54,9 @@ function Sidebar({
|
||||
updateAvailable,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal
|
||||
onShowVersionModal,
|
||||
isPWA,
|
||||
isMobile
|
||||
}) {
|
||||
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
@@ -562,7 +564,10 @@ function Sidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card md:select-none">
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
@@ -607,7 +612,10 @@ function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div className="md:hidden p-3 border-b border-border">
|
||||
<div
|
||||
className="md:hidden p-3 border-b border-border"
|
||||
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
|
||||
41
src/hooks/useLocalStorage.jsx
Normal file
41
src/hooks/useLocalStorage.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to persist state in localStorage.
|
||||
*
|
||||
* @param {string} key The key to use for localStorage.
|
||||
* @param {any} initialValue The initial value to use if nothing is in localStorage.
|
||||
* @returns {[any, Function]} A tuple containing the stored value and a setter function.
|
||||
*/
|
||||
function useLocalStorage(key, initialValue) {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const valueToStore =
|
||||
value instanceof Function ? value(storedValue) : value;
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
setStoredValue(valueToStore);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
export default useLocalStorage;
|
||||
@@ -43,6 +43,22 @@
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Safe area CSS variables */
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Fallback for older iOS versions */
|
||||
@supports (padding-top: constant(safe-area-inset-top)) {
|
||||
:root {
|
||||
--safe-area-inset-top: constant(safe-area-inset-top);
|
||||
--safe-area-inset-right: constant(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: constant(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: constant(safe-area-inset-left);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -82,12 +98,60 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Root element with safe area padding for PWA */
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Apply safe area padding in standalone mode */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
@media (display-mode: standalone) {
|
||||
#root {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA mode detected by JavaScript - more reliable */
|
||||
html.pwa-mode,
|
||||
body.pwa-mode {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: rgb(255 255 255); /* white - same as bg-white for safe area */
|
||||
}
|
||||
|
||||
body.pwa-mode #root {
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Adjust fixed inset positioning in PWA mode */
|
||||
body.pwa-mode .fixed.inset-0 {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
left: env(safe-area-inset-left);
|
||||
right: env(safe-area-inset-right);
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Dark mode safe area background */
|
||||
html.dark body.pwa-mode {
|
||||
background-color: rgb(31 41 55); /* gray-800 - matches header color */
|
||||
}
|
||||
|
||||
/* Global transition defaults */
|
||||
button,
|
||||
a,
|
||||
@@ -577,6 +641,25 @@
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 12px);
|
||||
}
|
||||
|
||||
/* PWA specific header adjustments for iOS */
|
||||
.pwa-header-safe {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* When PWA mode is detected by JavaScript */
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
/* Reset padding since #root already handles safe area */
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
/* For mobile PWA, ensure proper header spacing */
|
||||
@media screen and (max-width: 768px) {
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
padding-top: 4px !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.chat-input-mobile {
|
||||
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
|
||||
|
||||
Reference in New Issue
Block a user