删除无用内容
This commit is contained in:
5394
package-lock.json
generated
5394
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claudia",
|
"name": "claudia",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -718,7 +718,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claudia"
|
name = "claudia"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "claudia"
|
name = "claudia"
|
||||||
version = "0.1.0"
|
version = "1.1.0"
|
||||||
description = "GUI app and Toolkit for Claude Code"
|
description = "GUI app and Toolkit for Claude Code"
|
||||||
authors = ["mufeedvh", "123vviekr"]
|
authors = ["mufeedvh", "123vviekr"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Claudia",
|
"productName": "Claudia",
|
||||||
"version": "0.1.0",
|
"version": "1.1.0",
|
||||||
"identifier": "claudia.asterisk.so",
|
"identifier": "claudia.asterisk.so",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
@@ -1,170 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { OutputCacheProvider } from "@/lib/outputCache";
|
|
||||||
import { TabProvider } from "@/contexts/TabContext";
|
|
||||||
import { NFOCredits } from "@/components/NFOCredits";
|
|
||||||
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
|
||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
|
||||||
import { TabManager } from "@/components/TabManager";
|
|
||||||
import { TabContent } from "@/components/TabContent";
|
|
||||||
import { AgentsModal } from "@/components/AgentsModal";
|
|
||||||
import { useTabState } from "@/hooks/useTabState";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AppContent component - Contains the main app logic, wrapped by providers
|
|
||||||
*/
|
|
||||||
function AppContent() {
|
|
||||||
const { } = useTabState();
|
|
||||||
const [showNFO, setShowNFO] = useState(false);
|
|
||||||
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
|
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
|
|
||||||
const [showAgentsModal, setShowAgentsModal] = useState(false);
|
|
||||||
const [, setClaudeExecutableExists] = useState(true);
|
|
||||||
|
|
||||||
// Keyboard shortcuts for tab navigation
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
||||||
const modKey = isMac ? e.metaKey : e.ctrlKey;
|
|
||||||
|
|
||||||
if (modKey) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 't':
|
|
||||||
e.preventDefault();
|
|
||||||
window.dispatchEvent(new CustomEvent('create-chat-tab'));
|
|
||||||
break;
|
|
||||||
case 'w':
|
|
||||||
e.preventDefault();
|
|
||||||
window.dispatchEvent(new CustomEvent('close-current-tab'));
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
window.dispatchEvent(new CustomEvent('switch-to-previous-tab'));
|
|
||||||
} else {
|
|
||||||
window.dispatchEvent(new CustomEvent('switch-to-next-tab'));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Handle number keys 1-9
|
|
||||||
const num = parseInt(e.key);
|
|
||||||
if (!isNaN(num) && num >= 1 && num <= 9) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: num - 1 }));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check if Claude executable exists on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const checkClaudeExecutable = async () => {
|
|
||||||
try {
|
|
||||||
// Check if claude executable exists - method not available in API
|
|
||||||
const exists = true; // Default to true for now
|
|
||||||
if (!exists) {
|
|
||||||
setShowClaudeBinaryDialog(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking Claude executable:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkClaudeExecutable();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Custom event handlers
|
|
||||||
useEffect(() => {
|
|
||||||
const handleCreateProjectTab = () => {
|
|
||||||
window.dispatchEvent(new CustomEvent('create-project-tab'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowNFO = () => setShowNFO(true);
|
|
||||||
const handleShowAgents = () => setShowAgentsModal(true);
|
|
||||||
|
|
||||||
const projectButton = document.getElementById('create-project-tab-btn');
|
|
||||||
if (projectButton) {
|
|
||||||
projectButton.addEventListener('click', handleCreateProjectTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for custom events to show modals
|
|
||||||
window.addEventListener('show-nfo', handleShowNFO);
|
|
||||||
window.addEventListener('show-agents-modal', handleShowAgents);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (projectButton) {
|
|
||||||
projectButton.removeEventListener('click', handleCreateProjectTab);
|
|
||||||
}
|
|
||||||
window.removeEventListener('show-nfo', handleShowNFO);
|
|
||||||
window.removeEventListener('show-agents-modal', handleShowAgents);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="min-h-screen bg-background flex flex-col"
|
|
||||||
>
|
|
||||||
{/* Tab-based interface */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<TabManager />
|
|
||||||
<TabContent />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Global Modals */}
|
|
||||||
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
|
|
||||||
|
|
||||||
<ClaudeBinaryDialog
|
|
||||||
open={showClaudeBinaryDialog}
|
|
||||||
onOpenChange={setShowClaudeBinaryDialog}
|
|
||||||
onSuccess={() => {
|
|
||||||
setClaudeExecutableExists(true);
|
|
||||||
setToast({ message: "Claude binary path set successfully", type: "success" });
|
|
||||||
}}
|
|
||||||
onError={(message) => {
|
|
||||||
setToast({ message, type: "error" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AgentsModal
|
|
||||||
open={showAgentsModal}
|
|
||||||
onOpenChange={setShowAgentsModal}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Toast Container */}
|
|
||||||
{toast && (
|
|
||||||
<ToastContainer>
|
|
||||||
<Toast
|
|
||||||
message={toast.message}
|
|
||||||
type={toast.type}
|
|
||||||
onDismiss={() => setToast(null)}
|
|
||||||
/>
|
|
||||||
</ToastContainer>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App component - Main entry point with providers
|
|
||||||
*/
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<OutputCacheProvider>
|
|
||||||
<TabProvider>
|
|
||||||
<AppContent />
|
|
||||||
</TabProvider>
|
|
||||||
</OutputCacheProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@@ -1,401 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { api, type Session } from "@/lib/api";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { FloatingPromptInput, type FloatingPromptInputRef } from "./FloatingPromptInput";
|
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
|
||||||
import { TimelineNavigator } from "./TimelineNavigator";
|
|
||||||
import { CheckpointSettings } from "./CheckpointSettings";
|
|
||||||
import { SlashCommandsManager } from "./SlashCommandsManager";
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import { SplitPane } from "@/components/ui/split-pane";
|
|
||||||
import { WebviewPreview } from "./WebviewPreview";
|
|
||||||
|
|
||||||
// Import refactored components and hooks
|
|
||||||
import { useClaudeMessages } from "./claude-code-session/useClaudeMessages";
|
|
||||||
import { useCheckpoints } from "./claude-code-session/useCheckpoints";
|
|
||||||
import { SessionHeader } from "./claude-code-session/SessionHeader";
|
|
||||||
import { MessageList } from "./claude-code-session/MessageList";
|
|
||||||
import { PromptQueue } from "./claude-code-session/PromptQueue";
|
|
||||||
|
|
||||||
interface ClaudeCodeSessionProps {
|
|
||||||
session?: Session;
|
|
||||||
initialProjectPath?: string;
|
|
||||||
onBack: () => void;
|
|
||||||
onProjectSettings?: (projectPath: string) => void;
|
|
||||||
className?: string;
|
|
||||||
onStreamingChange?: (isStreaming: boolean, sessionId: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|
||||||
session,
|
|
||||||
initialProjectPath = "",
|
|
||||||
onBack,
|
|
||||||
onProjectSettings,
|
|
||||||
className,
|
|
||||||
onStreamingChange,
|
|
||||||
}) => {
|
|
||||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
|
||||||
const [isFirstPrompt, setIsFirstPrompt] = useState(!session);
|
|
||||||
const [totalTokens, setTotalTokens] = useState(0);
|
|
||||||
const [claudeSessionId, setClaudeSessionId] = useState<string | null>(null);
|
|
||||||
const [showTimeline, setShowTimeline] = useState(false);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [showForkDialog, setShowForkDialog] = useState(false);
|
|
||||||
const [showSlashCommandsSettings, setShowSlashCommandsSettings] = useState(false);
|
|
||||||
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
|
|
||||||
const [forkSessionName, setForkSessionName] = useState("");
|
|
||||||
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
|
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
|
|
||||||
const promptInputRef = useRef<FloatingPromptInputRef>(null);
|
|
||||||
const processQueueTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Use custom hooks
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
rawJsonlOutput,
|
|
||||||
isStreaming,
|
|
||||||
currentSessionId: _currentSessionId,
|
|
||||||
clearMessages,
|
|
||||||
loadMessages
|
|
||||||
} = useClaudeMessages({
|
|
||||||
onSessionInfo: (info) => {
|
|
||||||
setClaudeSessionId(info.sessionId);
|
|
||||||
},
|
|
||||||
onTokenUpdate: setTotalTokens,
|
|
||||||
onStreamingChange
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
checkpoints: _checkpoints,
|
|
||||||
timelineVersion,
|
|
||||||
loadCheckpoints,
|
|
||||||
createCheckpoint: _createCheckpoint,
|
|
||||||
restoreCheckpoint,
|
|
||||||
forkCheckpoint
|
|
||||||
} = useCheckpoints({
|
|
||||||
sessionId: claudeSessionId,
|
|
||||||
projectId: session?.project_id || '',
|
|
||||||
projectPath: projectPath,
|
|
||||||
onToast: (message: string, type: 'success' | 'error') => {
|
|
||||||
console.log(`Toast: ${type} - ${message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle path selection
|
|
||||||
const handleSelectPath = async () => {
|
|
||||||
const selected = await open({
|
|
||||||
directory: true,
|
|
||||||
multiple: false,
|
|
||||||
title: "Select Project Directory"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selected && typeof selected === 'string') {
|
|
||||||
setProjectPath(selected);
|
|
||||||
setError(null);
|
|
||||||
setIsFirstPrompt(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle sending prompts
|
|
||||||
const handleSendPrompt = useCallback(async (prompt: string, model: "sonnet" | "opus") => {
|
|
||||||
if (!projectPath || !prompt.trim()) return;
|
|
||||||
|
|
||||||
// Add to queue if streaming
|
|
||||||
if (isStreaming) {
|
|
||||||
const id = Date.now().toString();
|
|
||||||
setQueuedPrompts(prev => [...prev, { id, prompt, model }]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (isFirstPrompt) {
|
|
||||||
await api.executeClaudeCode(projectPath, prompt, model);
|
|
||||||
setIsFirstPrompt(false);
|
|
||||||
} else if (claudeSessionId) {
|
|
||||||
await api.continueClaudeCode(projectPath, prompt, model);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send prompt:", error);
|
|
||||||
setError(error instanceof Error ? error.message : "Failed to send prompt");
|
|
||||||
}
|
|
||||||
}, [projectPath, isStreaming, isFirstPrompt, claudeSessionId]);
|
|
||||||
|
|
||||||
// Process queued prompts
|
|
||||||
const processQueuedPrompts = useCallback(async () => {
|
|
||||||
if (queuedPrompts.length === 0 || isStreaming) return;
|
|
||||||
|
|
||||||
const nextPrompt = queuedPrompts[0];
|
|
||||||
setQueuedPrompts(prev => prev.slice(1));
|
|
||||||
|
|
||||||
await handleSendPrompt(nextPrompt.prompt, nextPrompt.model);
|
|
||||||
}, [queuedPrompts, isStreaming, handleSendPrompt]);
|
|
||||||
|
|
||||||
// Effect to process queue when streaming stops
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isStreaming && queuedPrompts.length > 0) {
|
|
||||||
processQueueTimeoutRef.current = setTimeout(processQueuedPrompts, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (processQueueTimeoutRef.current) {
|
|
||||||
clearTimeout(processQueueTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isStreaming, queuedPrompts.length, processQueuedPrompts]);
|
|
||||||
|
|
||||||
// Copy handlers
|
|
||||||
const handleCopyAsJsonl = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(rawJsonlOutput.join('\n'));
|
|
||||||
setCopyPopoverOpen(false);
|
|
||||||
console.log("Session output copied as JSONL");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to copy:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyAsMarkdown = async () => {
|
|
||||||
try {
|
|
||||||
const markdown = messages
|
|
||||||
.filter(msg => msg.type === 'user' || msg.type === 'assistant')
|
|
||||||
.map(msg => {
|
|
||||||
if (msg.type === 'user') {
|
|
||||||
return `## User\n\n${msg.message || ''}`;
|
|
||||||
} else if (msg.type === 'assistant' && msg.message?.content) {
|
|
||||||
const content = Array.isArray(msg.message.content)
|
|
||||||
? msg.message.content.map((item: any) => {
|
|
||||||
if (typeof item === 'string') return item;
|
|
||||||
if (item.type === 'text') return item.text;
|
|
||||||
return '';
|
|
||||||
}).filter(Boolean).join('')
|
|
||||||
: msg.message.content;
|
|
||||||
return `## Assistant\n\n${content}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n\n---\n\n');
|
|
||||||
|
|
||||||
await navigator.clipboard.writeText(markdown);
|
|
||||||
setCopyPopoverOpen(false);
|
|
||||||
console.log("Session output copied as Markdown");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to copy:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fork dialog handlers
|
|
||||||
const handleFork = (checkpointId: string) => {
|
|
||||||
setForkCheckpointId(checkpointId);
|
|
||||||
setForkSessionName("");
|
|
||||||
setShowForkDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmFork = async () => {
|
|
||||||
if (!forkCheckpointId || !forkSessionName.trim()) return;
|
|
||||||
|
|
||||||
const forkedSession = await forkCheckpoint(forkCheckpointId, forkSessionName);
|
|
||||||
if (forkedSession) {
|
|
||||||
setShowForkDialog(false);
|
|
||||||
// Navigate to forked session
|
|
||||||
window.location.reload(); // Or use proper navigation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Link detection handler
|
|
||||||
const handleLinkDetected = (url: string) => {
|
|
||||||
setPreviewUrl(url);
|
|
||||||
if (!showPreview) {
|
|
||||||
setShowPreview(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load session if provided
|
|
||||||
useEffect(() => {
|
|
||||||
if (session) {
|
|
||||||
setProjectPath(session.project_path);
|
|
||||||
setClaudeSessionId(session.id);
|
|
||||||
loadMessages(session.id);
|
|
||||||
loadCheckpoints();
|
|
||||||
}
|
|
||||||
}, [session, loadMessages, loadCheckpoints]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<div className={cn("flex flex-col h-screen bg-background", className)}>
|
|
||||||
{/* Header */}
|
|
||||||
<SessionHeader
|
|
||||||
projectPath={projectPath}
|
|
||||||
claudeSessionId={claudeSessionId}
|
|
||||||
totalTokens={totalTokens}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
hasMessages={messages.length > 0}
|
|
||||||
showTimeline={showTimeline}
|
|
||||||
copyPopoverOpen={copyPopoverOpen}
|
|
||||||
onBack={onBack}
|
|
||||||
onSelectPath={handleSelectPath}
|
|
||||||
onCopyAsJsonl={handleCopyAsJsonl}
|
|
||||||
onCopyAsMarkdown={handleCopyAsMarkdown}
|
|
||||||
onToggleTimeline={() => setShowTimeline(!showTimeline)}
|
|
||||||
onProjectSettings={onProjectSettings ? () => onProjectSettings(projectPath) : undefined}
|
|
||||||
onSlashCommandsSettings={() => setShowSlashCommandsSettings(true)}
|
|
||||||
setCopyPopoverOpen={setCopyPopoverOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<div className="flex-1 flex">
|
|
||||||
{showPreview ? (
|
|
||||||
<SplitPane
|
|
||||||
left={
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<MessageList
|
|
||||||
messages={messages}
|
|
||||||
projectPath={projectPath}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
onLinkDetected={handleLinkDetected}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<PromptQueue
|
|
||||||
queuedPrompts={queuedPrompts}
|
|
||||||
onRemove={(id) => setQueuedPrompts(prev => prev.filter(p => p.id !== id))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
<WebviewPreview
|
|
||||||
initialUrl={previewUrl || ""}
|
|
||||||
isMaximized={isPreviewMaximized}
|
|
||||||
onClose={() => setShowPreview(false)}
|
|
||||||
onUrlChange={setPreviewUrl}
|
|
||||||
onToggleMaximize={() => setIsPreviewMaximized(!isPreviewMaximized)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
initialSplit={60}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
<MessageList
|
|
||||||
messages={messages}
|
|
||||||
projectPath={projectPath}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
onLinkDetected={handleLinkDetected}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<PromptQueue
|
|
||||||
queuedPrompts={queuedPrompts}
|
|
||||||
onRemove={(id) => setQueuedPrompts(prev => prev.filter(p => p.id !== id))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{error && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mx-4 mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md"
|
|
||||||
>
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Floating prompt input */}
|
|
||||||
{projectPath && (
|
|
||||||
<FloatingPromptInput
|
|
||||||
ref={promptInputRef}
|
|
||||||
onSend={handleSendPrompt}
|
|
||||||
disabled={!projectPath}
|
|
||||||
isLoading={isStreaming}
|
|
||||||
onCancel={async () => {
|
|
||||||
if (claudeSessionId && isStreaming) {
|
|
||||||
await api.cancelClaudeExecution(claudeSessionId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timeline Navigator */}
|
|
||||||
{showTimeline && claudeSessionId && session && (
|
|
||||||
<TimelineNavigator
|
|
||||||
sessionId={claudeSessionId}
|
|
||||||
projectId={session.project_id}
|
|
||||||
projectPath={projectPath}
|
|
||||||
currentMessageIndex={messages.length}
|
|
||||||
onCheckpointSelect={async (checkpoint) => {
|
|
||||||
const success = await restoreCheckpoint(checkpoint.id);
|
|
||||||
if (success) {
|
|
||||||
clearMessages();
|
|
||||||
loadMessages(claudeSessionId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFork={handleFork}
|
|
||||||
refreshVersion={timelineVersion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings dialogs */}
|
|
||||||
{showSettings && claudeSessionId && session && (
|
|
||||||
<CheckpointSettings
|
|
||||||
sessionId={claudeSessionId}
|
|
||||||
projectId={session.project_id}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onClose={() => setShowSettings(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSlashCommandsSettings && projectPath && (
|
|
||||||
<SlashCommandsManager
|
|
||||||
projectPath={projectPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fork dialog */}
|
|
||||||
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Fork Session from Checkpoint</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Create a new session branching from this checkpoint. The original session will remain unchanged.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="fork-name">New Session Name</Label>
|
|
||||||
<Input
|
|
||||||
id="fork-name"
|
|
||||||
value={forkSessionName}
|
|
||||||
onChange={(e) => setForkSessionName(e.target.value)}
|
|
||||||
placeholder="Enter a name for the forked session"
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setShowForkDialog(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmFork}
|
|
||||||
disabled={!forkSessionName.trim()}
|
|
||||||
>
|
|
||||||
Fork Session
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
@@ -45,6 +45,7 @@ import { WebviewPreview } from "./WebviewPreview";
|
|||||||
import { FileExplorerPanelEnhanced } from "./FileExplorerPanelEnhanced";
|
import { FileExplorerPanelEnhanced } from "./FileExplorerPanelEnhanced";
|
||||||
import { GitPanelEnhanced } from "./GitPanelEnhanced";
|
import { GitPanelEnhanced } from "./GitPanelEnhanced";
|
||||||
import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
||||||
|
import { SlashCommandsManager } from "./SlashCommandsManager";
|
||||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
|
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
|
||||||
@@ -162,7 +163,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
|
|
||||||
// 文件监控相关状态
|
// 文件监控相关状态
|
||||||
const [fileChanges, setFileChanges] = useState<FileChange[]>([]);
|
const [fileChanges, setFileChanges] = useState<FileChange[]>([]);
|
||||||
const [showFileMonitor, setShowFileMonitor] = useState(false);
|
|
||||||
const [isFileWatching, setIsFileWatching] = useState(false);
|
const [isFileWatching, setIsFileWatching] = useState(false);
|
||||||
const [fileMonitorCollapsed, setFileMonitorCollapsed] = useState(false);
|
const [fileMonitorCollapsed, setFileMonitorCollapsed] = useState(false);
|
||||||
const [fileMonitorExpanded, setFileMonitorExpanded] = useState(false);
|
const [fileMonitorExpanded, setFileMonitorExpanded] = useState(false);
|
||||||
|
@@ -1,416 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Folder,
|
|
||||||
File,
|
|
||||||
ArrowLeft,
|
|
||||||
FileCode,
|
|
||||||
FileText,
|
|
||||||
FileImage,
|
|
||||||
Search,
|
|
||||||
ChevronRight
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { FileEntry } from "@/lib/api";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
// Global caches that persist across component instances
|
|
||||||
const globalDirectoryCache = new Map<string, FileEntry[]>();
|
|
||||||
const globalSearchCache = new Map<string, FileEntry[]>();
|
|
||||||
|
|
||||||
interface FilePickerProps {
|
|
||||||
basePath: string;
|
|
||||||
onSelect: (entry: FileEntry) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
initialQuery?: string;
|
|
||||||
className?: string;
|
|
||||||
allowDirectorySelection?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized file icon selector
|
|
||||||
const getFileIcon = (entry: FileEntry) => {
|
|
||||||
if (entry.is_directory) return Folder;
|
|
||||||
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase();
|
|
||||||
switch (ext) {
|
|
||||||
case 'js':
|
|
||||||
case 'jsx':
|
|
||||||
case 'ts':
|
|
||||||
case 'tsx':
|
|
||||||
case 'py':
|
|
||||||
case 'java':
|
|
||||||
case 'cpp':
|
|
||||||
case 'c':
|
|
||||||
case 'go':
|
|
||||||
case 'rs':
|
|
||||||
return FileCode;
|
|
||||||
case 'md':
|
|
||||||
case 'txt':
|
|
||||||
case 'json':
|
|
||||||
case 'xml':
|
|
||||||
case 'yaml':
|
|
||||||
case 'yml':
|
|
||||||
return FileText;
|
|
||||||
case 'png':
|
|
||||||
case 'jpg':
|
|
||||||
case 'jpeg':
|
|
||||||
case 'gif':
|
|
||||||
case 'svg':
|
|
||||||
case 'webp':
|
|
||||||
return FileImage;
|
|
||||||
default:
|
|
||||||
return File;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FilePicker: React.FC<FilePickerProps> = React.memo(({
|
|
||||||
basePath,
|
|
||||||
onSelect,
|
|
||||||
onClose,
|
|
||||||
initialQuery = "",
|
|
||||||
className,
|
|
||||||
allowDirectorySelection = false
|
|
||||||
}) => {
|
|
||||||
const [currentPath, setCurrentPath] = useState(basePath);
|
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const searchDebounceRef = useRef<NodeJS.Timeout>();
|
|
||||||
|
|
||||||
// Filter and sort entries
|
|
||||||
const displayEntries = useMemo(() => {
|
|
||||||
const filtered = searchQuery.trim()
|
|
||||||
? entries.filter(entry =>
|
|
||||||
entry.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
: entries;
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
|
||||||
if (a.is_directory !== b.is_directory) {
|
|
||||||
return a.is_directory ? -1 : 1;
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
}, [entries, searchQuery]);
|
|
||||||
|
|
||||||
// Virtual scrolling setup
|
|
||||||
const virtualizer = useVirtualizer({
|
|
||||||
count: displayEntries.length,
|
|
||||||
getScrollElement: () => scrollContainerRef.current,
|
|
||||||
estimateSize: () => 32, // Height of each item
|
|
||||||
overscan: 10, // Number of items to render outside viewport
|
|
||||||
});
|
|
||||||
|
|
||||||
const virtualItems = virtualizer.getVirtualItems();
|
|
||||||
|
|
||||||
// Load directory contents
|
|
||||||
const loadDirectory = useCallback(async (path: string) => {
|
|
||||||
const cacheKey = path;
|
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
if (globalDirectoryCache.has(cacheKey)) {
|
|
||||||
setEntries(globalDirectoryCache.get(cacheKey)!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.listDirectoryContents(path);
|
|
||||||
globalDirectoryCache.set(cacheKey, result);
|
|
||||||
setEntries(result);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load directory');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
const performSearch = useCallback(async (query: string) => {
|
|
||||||
if (!query.trim()) {
|
|
||||||
loadDirectory(currentPath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = `${currentPath}:${query}`;
|
|
||||||
|
|
||||||
if (globalSearchCache.has(cacheKey)) {
|
|
||||||
setEntries(globalSearchCache.get(cacheKey)!);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.searchFiles(currentPath, query);
|
|
||||||
globalSearchCache.set(cacheKey, result);
|
|
||||||
setEntries(result);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Search failed');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentPath, loadDirectory]);
|
|
||||||
|
|
||||||
// Handle entry click
|
|
||||||
const handleEntryClick = useCallback((entry: FileEntry) => {
|
|
||||||
if (!entry.is_directory || allowDirectorySelection) {
|
|
||||||
onSelect(entry);
|
|
||||||
}
|
|
||||||
}, [onSelect, allowDirectorySelection]);
|
|
||||||
|
|
||||||
// Handle entry double click
|
|
||||||
const handleEntryDoubleClick = useCallback((entry: FileEntry) => {
|
|
||||||
if (entry.is_directory) {
|
|
||||||
setCurrentPath(entry.path);
|
|
||||||
setSearchQuery("");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else {
|
|
||||||
onSelect(entry);
|
|
||||||
}
|
|
||||||
}, [onSelect]);
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
||||||
if (displayEntries.length === 0) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
||||||
break;
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedIndex(prev => Math.min(displayEntries.length - 1, prev + 1));
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
const selectedEntry = displayEntries[selectedIndex];
|
|
||||||
if (selectedEntry) {
|
|
||||||
if (e.shiftKey || !selectedEntry.is_directory) {
|
|
||||||
handleEntryClick(selectedEntry);
|
|
||||||
} else {
|
|
||||||
handleEntryDoubleClick(selectedEntry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [displayEntries, selectedIndex, handleEntryClick, handleEntryDoubleClick, onClose]);
|
|
||||||
|
|
||||||
// Debounced search
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchDebounceRef.current) {
|
|
||||||
clearTimeout(searchDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchDebounceRef.current = setTimeout(() => {
|
|
||||||
performSearch(searchQuery);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (searchDebounceRef.current) {
|
|
||||||
clearTimeout(searchDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [searchQuery, performSearch]);
|
|
||||||
|
|
||||||
// Load initial directory
|
|
||||||
useEffect(() => {
|
|
||||||
loadDirectory(currentPath);
|
|
||||||
}, [currentPath, loadDirectory]);
|
|
||||||
|
|
||||||
// Focus search input on mount
|
|
||||||
useEffect(() => {
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Scroll selected item into view
|
|
||||||
useEffect(() => {
|
|
||||||
const item = virtualizer.getVirtualItems().find(
|
|
||||||
vItem => vItem.index === selectedIndex
|
|
||||||
);
|
|
||||||
if (item) {
|
|
||||||
virtualizer.scrollToIndex(selectedIndex, { align: 'center' });
|
|
||||||
}
|
|
||||||
}, [selectedIndex, virtualizer]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
className={cn("flex flex-col bg-background rounded-lg shadow-lg", className)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 p-4 border-b">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const parentPath = currentPath.split('/').slice(0, -1).join('/') || '/';
|
|
||||||
setCurrentPath(parentPath);
|
|
||||||
setSearchQuery("");
|
|
||||||
}}
|
|
||||||
disabled={currentPath === '/' || currentPath === basePath}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex-1 flex items-center gap-2">
|
|
||||||
<Search className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search files..."
|
|
||||||
className="flex-1 bg-transparent outline-none text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onClose}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current path */}
|
|
||||||
<div className="px-4 py-2 border-b">
|
|
||||||
<div className="text-xs text-muted-foreground truncate">
|
|
||||||
{currentPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File list with virtual scrolling */}
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="flex-1 overflow-auto"
|
|
||||||
style={{ height: '400px' }}
|
|
||||||
>
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-sm text-destructive">{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !error && displayEntries.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Search className="h-8 w-8 text-muted-foreground mb-2" />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayEntries.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualItems.map((virtualRow) => {
|
|
||||||
const entry = displayEntries[virtualRow.index];
|
|
||||||
const Icon = getFileIcon(entry);
|
|
||||||
const isSelected = virtualRow.index === selectedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={virtualRow.key}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: `${virtualRow.size}px`,
|
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEntryClick(entry)}
|
|
||||||
onDoubleClick={() => handleEntryDoubleClick(entry)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(virtualRow.index)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 px-2 py-1.5",
|
|
||||||
"hover:bg-accent transition-colors",
|
|
||||||
"text-left text-sm h-8",
|
|
||||||
isSelected && "bg-accent"
|
|
||||||
)}
|
|
||||||
title={entry.is_directory ? "Click to select • Double-click to enter" : "Click to select"}
|
|
||||||
>
|
|
||||||
<Icon className={cn(
|
|
||||||
"h-4 w-4 flex-shrink-0",
|
|
||||||
entry.is_directory ? "text-blue-500" : "text-muted-foreground"
|
|
||||||
)} />
|
|
||||||
|
|
||||||
<span className="flex-1 truncate">
|
|
||||||
{entry.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{!entry.is_directory && entry.size > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatFileSize(entry.size)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{entry.is_directory && (
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-t">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{displayEntries.length} {displayEntries.length === 1 ? 'item' : 'items'}
|
|
||||||
</div>
|
|
||||||
{allowDirectorySelection && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Shift+Enter to select directory
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
});
|
|
@@ -1,209 +0,0 @@
|
|||||||
import React, { useMemo, useCallback } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { FileText, ArrowLeft, Calendar, Clock } from "lucide-react";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
|
||||||
import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { formatUnixTimestamp, formatISOTimestamp } from "@/lib/date-utils";
|
|
||||||
import { usePagination } from "@/hooks/usePagination";
|
|
||||||
import type { Session, ClaudeMdFile } from "@/lib/api";
|
|
||||||
|
|
||||||
interface SessionListProps {
|
|
||||||
sessions: Session[];
|
|
||||||
projectPath: string;
|
|
||||||
onBack: () => void;
|
|
||||||
onSessionClick?: (session: Session) => void;
|
|
||||||
onEditClaudeFile?: (file: ClaudeMdFile) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized session card component to prevent unnecessary re-renders
|
|
||||||
const SessionCard = React.memo<{
|
|
||||||
session: Session;
|
|
||||||
projectPath: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
onEditClaudeFile?: (file: ClaudeMdFile) => void;
|
|
||||||
}>(({ session, projectPath, onClick, onEditClaudeFile }) => {
|
|
||||||
const formatTime = useCallback((timestamp: string | number | undefined) => {
|
|
||||||
if (!timestamp) return "Unknown time";
|
|
||||||
|
|
||||||
if (typeof timestamp === "string") {
|
|
||||||
return formatISOTimestamp(timestamp);
|
|
||||||
} else {
|
|
||||||
return formatUnixTimestamp(timestamp);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
whileHover={{ scale: 1.01 }}
|
|
||||||
whileTap={{ scale: 0.99 }}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-all",
|
|
||||||
"hover:shadow-lg hover:border-primary/20",
|
|
||||||
"bg-card/50 backdrop-blur-sm"
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
{/* Session title */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<FileText className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-lg truncate">
|
|
||||||
{`Session ${session.id.slice(0, 8)}`}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session metadata */}
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
<span>{formatTime(session.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
{session.message_timestamp && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3.5 w-3.5" />
|
|
||||||
<span>{formatTime(session.message_timestamp)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session ID */}
|
|
||||||
<div className="text-xs text-muted-foreground/60 font-mono">
|
|
||||||
ID: {session.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Claude memories dropdown */}
|
|
||||||
<div className="ml-4">
|
|
||||||
<ClaudeMemoriesDropdown
|
|
||||||
projectPath={projectPath}
|
|
||||||
onEditFile={onEditClaudeFile || (() => {})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
SessionCard.displayName = 'SessionCard';
|
|
||||||
|
|
||||||
export const SessionList: React.FC<SessionListProps> = React.memo(({
|
|
||||||
sessions,
|
|
||||||
projectPath,
|
|
||||||
onBack,
|
|
||||||
onSessionClick,
|
|
||||||
onEditClaudeFile,
|
|
||||||
className
|
|
||||||
}) => {
|
|
||||||
// Sort sessions by created_at in descending order
|
|
||||||
const sortedSessions = useMemo(() => {
|
|
||||||
return [...sessions].sort((a, b) => {
|
|
||||||
const timeA = a.created_at || 0;
|
|
||||||
const timeB = b.created_at || 0;
|
|
||||||
return timeB > timeA ? 1 : -1;
|
|
||||||
});
|
|
||||||
}, [sessions]);
|
|
||||||
|
|
||||||
// Use custom pagination hook
|
|
||||||
const {
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
paginatedData,
|
|
||||||
goToPage,
|
|
||||||
canGoNext: _canGoNext,
|
|
||||||
canGoPrevious: _canGoPrevious
|
|
||||||
} = usePagination(sortedSessions, {
|
|
||||||
initialPage: 1,
|
|
||||||
initialPageSize: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSessionClick = useCallback((session: Session) => {
|
|
||||||
onSessionClick?.(session);
|
|
||||||
}, [onSessionClick]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("space-y-6", className)}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onBack}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold">Sessions</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{projectPath}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{sessions.length} {sessions.length === 1 ? 'session' : 'sessions'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sessions list */}
|
|
||||||
{sessions.length === 0 ? (
|
|
||||||
<Card className="bg-muted/20">
|
|
||||||
<CardContent className="p-12 text-center">
|
|
||||||
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
No sessions found for this project
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={currentPage}
|
|
||||||
initial={{ opacity: 0, x: -20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
{paginatedData.map((session) => (
|
|
||||||
<SessionCard
|
|
||||||
key={session.id}
|
|
||||||
session={session}
|
|
||||||
projectPath={projectPath}
|
|
||||||
onClick={() => handleSessionClick(session)}
|
|
||||||
onEditClaudeFile={onEditClaudeFile}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={goToPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@@ -1,4 +0,0 @@
|
|||||||
// This file re-exports all widgets from the widgets directory
|
|
||||||
// It maintains backward compatibility with the original ToolWidgets.tsx
|
|
||||||
|
|
||||||
export * from './widgets';
|
|
Reference in New Issue
Block a user