mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-05 12:25:35 +08:00
Compare commits
2 Commits
fix/file-t
...
fix/chat-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccc1ad268 | ||
|
|
c825d342b3 |
173
server/index.js
173
server/index.js
@@ -1483,133 +1483,74 @@ function permToRwx(perm) {
|
|||||||
return r + w + x;
|
return r + w + x;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directories that are almost never interesting for a project tree but can
|
|
||||||
// contain tens of thousands of files. Skipping them before recursion keeps
|
|
||||||
// traversal time bounded on large monorepos and high-latency filesystems
|
|
||||||
// (NFS / SMB).
|
|
||||||
const IGNORED_DIRS = new Set([
|
|
||||||
// JS / TS toolchains
|
|
||||||
'node_modules', 'dist', 'build', '.next', '.nuxt', '.cache', '.parcel-cache',
|
|
||||||
// VCS
|
|
||||||
'.git', '.svn', '.hg',
|
|
||||||
// Python
|
|
||||||
'__pycache__', '.pytest_cache', '.mypy_cache', '.tox', 'venv', '.venv',
|
|
||||||
// Rust / Go / Java / Ruby
|
|
||||||
'target', 'vendor',
|
|
||||||
// Build output / IDE
|
|
||||||
'.gradle', '.idea', 'coverage', '.nyc_output'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const DEFAULT_FS_CONCURRENCY = 64;
|
|
||||||
const parsedFsConcurrency = Number.parseInt(process.env.FS_CONCURRENCY || '', 10);
|
|
||||||
const FS_CONCURRENCY = Number.isFinite(parsedFsConcurrency) && parsedFsConcurrency > 0
|
|
||||||
? parsedFsConcurrency
|
|
||||||
: DEFAULT_FS_CONCURRENCY;
|
|
||||||
let activeFsOperations = 0;
|
|
||||||
const pendingFsOperations = [];
|
|
||||||
|
|
||||||
async function acquire() {
|
|
||||||
if (activeFsOperations < FS_CONCURRENCY) {
|
|
||||||
activeFsOperations += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
pendingFsOperations.push(resolve);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function release() {
|
|
||||||
const next = pendingFsOperations.shift();
|
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeFsOperations = Math.max(0, activeFsOperations - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||||
// Using fsPromises from import
|
// Using fsPromises from import
|
||||||
let entries;
|
const items = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acquire();
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||||
try {
|
|
||||||
entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
for (const entry of entries) {
|
||||||
} finally {
|
// Debug: log all entries including hidden files
|
||||||
release();
|
|
||||||
|
|
||||||
|
// Skip heavy build directories and VCS directories
|
||||||
|
if (entry.name === 'node_modules' ||
|
||||||
|
entry.name === 'dist' ||
|
||||||
|
entry.name === 'build' ||
|
||||||
|
entry.name === '.git' ||
|
||||||
|
entry.name === '.svn' ||
|
||||||
|
entry.name === '.hg') continue;
|
||||||
|
|
||||||
|
const itemPath = path.join(dirPath, entry.name);
|
||||||
|
const item = {
|
||||||
|
name: entry.name,
|
||||||
|
path: itemPath,
|
||||||
|
type: entry.isDirectory() ? 'directory' : 'file'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get file stats for additional metadata
|
||||||
|
try {
|
||||||
|
const stats = await fsPromises.stat(itemPath);
|
||||||
|
item.size = stats.size;
|
||||||
|
item.modified = stats.mtime.toISOString();
|
||||||
|
|
||||||
|
// Convert permissions to rwx format
|
||||||
|
const mode = stats.mode;
|
||||||
|
const ownerPerm = (mode >> 6) & 7;
|
||||||
|
const groupPerm = (mode >> 3) & 7;
|
||||||
|
const otherPerm = mode & 7;
|
||||||
|
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
||||||
|
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
||||||
|
} catch (statError) {
|
||||||
|
// If stat fails, provide default values
|
||||||
|
item.size = 0;
|
||||||
|
item.modified = null;
|
||||||
|
item.permissions = '000';
|
||||||
|
item.permissionsRwx = '---------';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||||
|
// Recursively get subdirectories but limit depth
|
||||||
|
try {
|
||||||
|
// Check if we can access the directory before trying to read it
|
||||||
|
await fsPromises.access(item.path, fs.constants.R_OK);
|
||||||
|
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||||
|
} catch (e) {
|
||||||
|
// Silently skip directories we can't access (permission denied, etc.)
|
||||||
|
item.children = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only log non-permission errors to avoid spam
|
// Only log non-permission errors to avoid spam
|
||||||
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
||||||
console.error('Error reading directory:', error);
|
console.error('Error reading directory:', error);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = entries.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)));
|
|
||||||
|
|
||||||
// Process every entry in parallel. On high-latency filesystems (NFS/SMB)
|
|
||||||
// serial stat() was the real bottleneck — issuing them concurrently lets
|
|
||||||
// the kernel pipeline the round-trips and the recursive calls overlap too.
|
|
||||||
const items = await Promise.all(filteredEntries.map(async (entry) => {
|
|
||||||
const itemPath = path.join(dirPath, entry.name);
|
|
||||||
const item = {
|
|
||||||
name: entry.name,
|
|
||||||
path: itemPath,
|
|
||||||
type: entry.isDirectory() ? 'directory' : 'file'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get file stats for additional metadata
|
|
||||||
try {
|
|
||||||
await acquire();
|
|
||||||
try {
|
|
||||||
const stats = await fsPromises.lstat(itemPath);
|
|
||||||
item.size = stats.size;
|
|
||||||
item.modified = stats.mtime.toISOString();
|
|
||||||
|
|
||||||
// Mark symlinks so UI can distinguish them
|
|
||||||
if (stats.isSymbolicLink()) {
|
|
||||||
item.isSymlink = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert permissions to rwx format
|
|
||||||
const mode = stats.mode;
|
|
||||||
const ownerPerm = (mode >> 6) & 7;
|
|
||||||
const groupPerm = (mode >> 3) & 7;
|
|
||||||
const otherPerm = mode & 7;
|
|
||||||
item.permissions =
|
|
||||||
((mode >> 6) & 7).toString() +
|
|
||||||
((mode >> 3) & 7).toString() +
|
|
||||||
(mode & 7).toString();
|
|
||||||
item.permissionsRwx =
|
|
||||||
permToRwx(ownerPerm) +
|
|
||||||
permToRwx(groupPerm) +
|
|
||||||
permToRwx(otherPerm);
|
|
||||||
} finally {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
} catch (statError) {
|
|
||||||
// If stat fails, provide default values
|
|
||||||
item.size = 0;
|
|
||||||
item.modified = null;
|
|
||||||
item.permissions = '000';
|
|
||||||
item.permissionsRwx = '---------';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory() && currentDepth < maxDepth) {
|
|
||||||
// Recurse. Let readdir's own EACCES bubble up through the catch in
|
|
||||||
// the recursive call rather than doing a separate access() probe
|
|
||||||
// (which doubled the round-trip count on SMB without adding info).
|
|
||||||
// The recursive call starts with a bounded readdir; holding a permit
|
|
||||||
// for the whole subtree can deadlock when sibling directories are
|
|
||||||
// waiting on their own children.
|
|
||||||
item.children = await getFileTree(itemPath, maxDepth, currentDepth + 1, showHidden);
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
}));
|
|
||||||
|
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
return a.type === 'directory' ? -1 : 1;
|
return a.type === 'directory' ? -1 : 1;
|
||||||
|
|||||||
@@ -31,24 +31,6 @@ export function createWebSocketServer(
|
|||||||
});
|
});
|
||||||
|
|
||||||
wss.on('connection', (ws, request) => {
|
wss.on('connection', (ws, request) => {
|
||||||
// Keep WebSocket alive across reverse-proxy idle timeouts (Cloudflare ~100s,
|
|
||||||
// AWS ALB 60s, nginx 60s, etc.). Without app-level pings these connections
|
|
||||||
// are silently torn down even when the UI is active, causing repeated
|
|
||||||
// reconnect cycles. ws library heartbeat is opt-in.
|
|
||||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
||||||
const heartbeat = setInterval(() => {
|
|
||||||
if (ws.readyState === ws.OPEN) {
|
|
||||||
try {
|
|
||||||
ws.ping();
|
|
||||||
} catch {
|
|
||||||
// socket may have been closed concurrently — interval will be cleared below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_INTERVAL_MS);
|
|
||||||
const stopHeartbeat = () => clearInterval(heartbeat);
|
|
||||||
ws.on('close', stopHeartbeat);
|
|
||||||
ws.on('error', stopHeartbeat);
|
|
||||||
|
|
||||||
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
const incomingRequest = request as AuthenticatedWebSocketRequest;
|
||||||
const url = incomingRequest.url ?? '/';
|
const url = incomingRequest.url ?? '/';
|
||||||
const pathname = new URL(url, 'http://localhost').pathname;
|
const pathname = new URL(url, 'http://localhost').pathname;
|
||||||
|
|||||||
@@ -143,6 +143,21 @@ const createFakeSubmitEvent = () => {
|
|||||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const THINKING_MODE_STORAGE_KEY = 'chat-thinking-mode';
|
||||||
|
|
||||||
|
const getInitialThinkingMode = () => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedMode = safeLocalStorage.getItem(THINKING_MODE_STORAGE_KEY);
|
||||||
|
if (!savedMode) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return thinkingModes.some((mode) => mode.id === savedMode) ? savedMode : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
const getNotificationSessionSummary = (
|
const getNotificationSessionSummary = (
|
||||||
selectedSession: ProjectSession | null,
|
selectedSession: ProjectSession | null,
|
||||||
fallbackInput: string,
|
fallbackInput: string,
|
||||||
@@ -204,7 +219,7 @@ export function useChatComposerState({
|
|||||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||||
const [thinkingMode, setThinkingMode] = useState('none');
|
const [thinkingMode, setThinkingMode] = useState(getInitialThinkingMode);
|
||||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -564,7 +579,7 @@ export function useChatComposerState({
|
|||||||
|
|
||||||
let messageContent = currentInput;
|
let messageContent = currentInput;
|
||||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
if (provider === 'claude' && selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +764,6 @@ export function useChatComposerState({
|
|||||||
setUploadingImages(new Map());
|
setUploadingImages(new Map());
|
||||||
setImageErrors(new Map());
|
setImageErrors(new Map());
|
||||||
setIsTextareaExpanded(false);
|
setIsTextareaExpanded(false);
|
||||||
setThinkingMode('none');
|
|
||||||
|
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
@@ -795,6 +809,10 @@ export function useChatComposerState({
|
|||||||
inputValueRef.current = input;
|
inputValueRef.current = input;
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
safeLocalStorage.setItem(THINKING_MODE_STORAGE_KEY, thinkingMode);
|
||||||
|
}, [thinkingMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -295,7 +295,6 @@ export default function ChatComposer({
|
|||||||
|
|
||||||
<PromptInputTextarea
|
<PromptInputTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
dir="auto"
|
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
onClick={onTextareaClick}
|
onClick={onTextareaClick}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
/* User message bubble on the right */
|
/* User message bubble on the right */
|
||||||
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||||
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
|
||||||
<div dir="auto" className="whitespace-pre-wrap break-words text-sm">
|
<div className="whitespace-pre-wrap break-words text-sm">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
{message.images && message.images.length > 0 && (
|
{message.images && message.images.length > 0 && (
|
||||||
@@ -405,7 +405,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</ReasoningContent>
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
) : (
|
) : (
|
||||||
<div dir="auto" className="text-sm text-gray-700 dark:text-gray-300">
|
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{/* Reasoning accordion */}
|
{/* Reasoning accordion */}
|
||||||
{showThinking && message.reasoning && (
|
{showThinking && message.reasoning && (
|
||||||
<Reasoning className="mb-3" defaultOpen={false}>
|
<Reasoning className="mb-3" defaultOpen={false}>
|
||||||
|
|||||||
@@ -36,12 +36,8 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The cleanup below sets unmountedRef = true. Without this reset, every
|
|
||||||
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
|
|
||||||
// at its unmounted guard and leave the socket permanently disconnected.
|
|
||||||
unmountedRef.current = false;
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unmountedRef.current = true;
|
unmountedRef.current = true;
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/shell': {
|
'/shell': {
|
||||||
target: `ws://${proxyHost}:${serverPort}`,
|
target: `ws://${proxyHost}:${serverPort}`,
|
||||||
ws: true
|
ws: true
|
||||||
},
|
|
||||||
'/plugin-ws': {
|
|
||||||
target: `ws://${proxyHost}:${serverPort}`,
|
|
||||||
ws: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user