From 6abff8474c593118fc52afaa9e0b432346aeffa5 Mon Sep 17 00:00:00 2001
From: blackhao <13851610112@163.com>
Date: Tue, 9 Dec 2025 17:32:10 -0600
Subject: file management sys

---
 frontend/src/components/LeftSidebar.tsx | 481 +++++++++++++++++++++++++++++++-
 1 file changed, 472 insertions(+), 9 deletions(-)

(limited to 'frontend/src/components/LeftSidebar.tsx')

diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx
index 1eaa62c..1b7ccb2 100644
--- a/frontend/src/components/LeftSidebar.tsx
+++ b/frontend/src/components/LeftSidebar.tsx
@@ -1,6 +1,10 @@
-import React, { useState } from 'react';
-import { Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare } from 'lucide-react';
-import useFlowStore from '../store/flowStore';
+import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
+import { useReactFlow } from 'reactflow';
+import { 
+  Folder, FileText, Archive, ChevronLeft, ChevronRight, Trash2, MessageSquare, 
+  MoreVertical, Download, Upload, Plus, RefreshCw, Edit3 
+} from 'lucide-react';
+import useFlowStore, { type FSItem, type BlueprintDocument } from '../store/flowStore';
 
 interface LeftSidebarProps {
   isOpen: boolean;
@@ -9,14 +13,325 @@ interface LeftSidebarProps {
 
 const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
   const [activeTab, setActiveTab] = useState<'project' | 'files' | 'archive'>('project');
-  const { archivedNodes, removeFromArchive, createNodeFromArchive, theme } = useFlowStore();
+  const {
+    archivedNodes,
+    removeFromArchive,
+    createNodeFromArchive,
+    theme,
+    projectTree,
+    currentBlueprintPath,
+    saveStatus,
+    refreshProjectTree,
+    loadArchivedNodes,
+    readBlueprintFile,
+    loadBlueprint,
+    saveBlueprintFile,
+    saveCurrentBlueprint,
+    createProjectFolder,
+    renameProjectItem,
+    deleteProjectItem,
+    setCurrentBlueprintPath,
+    serializeBlueprint,
+    clearBlueprint
+  } = useFlowStore();
+  const { setViewport, getViewport } = useReactFlow();
   const isDark = theme === 'dark';
+  const fileInputRef = useRef<HTMLInputElement | null>(null);
+  const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item?: FSItem } | null>(null);
+  const [currentFolder, setCurrentFolder] = useState<string>('.');
+  const [dragItem, setDragItem] = useState<FSItem | null>(null);
+  const [showSaveStatus, setShowSaveStatus] = useState(false);
+  const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['.']));
   
   const handleDragStart = (e: React.DragEvent, archiveId: string) => {
     e.dataTransfer.setData('archiveId', archiveId);
     e.dataTransfer.effectAllowed = 'copy';
   };
 
+  const joinPath = (folder: string, name: string) => {
+    if (!folder || folder === '.' || folder === '/') return name;
+    return `${folder.replace(/\\/g, '/').replace(/\/+$/, '')}/${name}`;
+  };
+
+  const stripJson = (name: string) => name.endsWith('.json') ? name.slice(0, -5) : name;
+
+  const findChildren = useCallback((folder: string, list: FSItem[] = projectTree): FSItem[] => {
+    const norm = folder.replace(/\\/g, '/').replace(/^\.\/?/, '');
+    if (folder === '.' || folder === '' || folder === '/') return list;
+    for (const item of list) {
+      if (item.type === 'folder') {
+        if (item.path === norm) return item.children || [];
+        const found = findChildren(folder, item.children || []);
+        if (found) return found;
+      }
+    }
+    return [];
+  }, [projectTree]);
+
+  const ensureUniqueName = useCallback((base: string, targetFolder: string, isFolder: boolean) => {
+    const siblings = findChildren(targetFolder).map(i => i.name);
+    const ext = isFolder ? '' : '.json';
+    const rawBase = stripJson(base);
+    let candidate = rawBase + ext;
+    let idx = 1;
+    while (siblings.includes(candidate)) {
+      idx += 1;
+      candidate = `${rawBase} (${idx})${ext}`;
+    }
+    return candidate;
+  }, [findChildren]);
+
+  // Load project tree on mount and when tab switches to project
+  useEffect(() => {
+    if (activeTab === 'project') {
+      refreshProjectTree().catch(() => {});
+    }
+  }, [activeTab, refreshProjectTree]);
+
+  // Load archived nodes on mount
+  useEffect(() => {
+    loadArchivedNodes().catch(() => {});
+  }, [loadArchivedNodes]);
+
+  // Context menu handlers
+  const openContextMenu = (e: React.MouseEvent, item?: FSItem) => {
+    e.preventDefault();
+    e.stopPropagation();
+    setContextMenu({ x: e.clientX, y: e.clientY, item });
+  };
+
+  const closeContextMenu = () => setContextMenu(null);
+
+  const promptName = (message: string, defaultValue: string) => {
+    const val = window.prompt(message, defaultValue);
+    return val?.trim() || null;
+  };
+
+  const handleCreateFolder = async (base: string) => {
+    const input = promptName('Folder name', 'new-folder');
+    if (!input) return;
+    const name = ensureUniqueName(input, base, true);
+    await createProjectFolder(joinPath(base, name));
+  };
+
+  const handleNewBlueprint = async (base: string) => {
+    const input = promptName('Blueprint file name', 'untitled');
+    if (!input) return;
+    const name = ensureUniqueName(input, base, false);
+    const path = joinPath(base, name);
+    // Create empty blueprint and save immediately
+    const empty: BlueprintDocument = {
+      version: 1,
+      nodes: [],
+      edges: [],
+      viewport: getViewport(),
+      theme,
+    };
+    await saveBlueprintFile(path, empty.viewport);
+    await loadBlueprint(empty);
+    setCurrentBlueprintPath(path);
+  };
+
+  const handleRename = async (item: FSItem) => {
+    const newName = promptName('Rename to', item.name);
+    if (!newName || newName === item.name) return;
+    await renameProjectItem(item.path, newName);
+  };
+
+  const handleDelete = async (item: FSItem) => {
+    const currentPath = currentBlueprintPath;
+    const isDeletingOpen =
+      currentPath === item.path ||
+      (item.type === 'folder' && currentPath && (currentPath === item.path || currentPath.startsWith(`${item.path}/`)));
+    const ok = window.confirm(
+      isDeletingOpen
+        ? `The opened blueprint is in this ${item.type}. Delete and clear canvas?`
+        : `Delete ${item.name}?`
+    );
+    if (!ok) return;
+    await deleteProjectItem(item.path, item.type === 'folder');
+    if (isDeletingOpen) {
+      clearBlueprint();
+    }
+    await refreshProjectTree();
+  };
+
+  const handleLoadFile = async (item: FSItem) => {
+    if (item.type !== 'file') return;
+    try {
+      const doc = await readBlueprintFile(item.path);
+      const vp = loadBlueprint(doc);
+      setCurrentBlueprintPath(item.path);
+      if (vp) {
+        setViewport(vp);
+      }
+    } catch (e) {
+      console.error(e);
+      alert('Not a valid blueprint JSON.');
+    }
+  };
+
+  const handleDownload = async (item: FSItem) => {
+    if (item.type !== 'file') return;
+    const url = `${import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000'}/api/projects/download?user=test&path=${encodeURIComponent(item.path)}`;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = item.name;
+    a.click();
+  };
+
+  const handleUploadClick = () => fileInputRef.current?.click();
+
+  const promptForPath = (base: string) => {
+    const input = window.prompt('Save as (filename without extension)', 'untitled')?.trim();
+    if (!input) return null;
+    const name = ensureUniqueName(input, base, false);
+    return joinPath(base, name);
+  };
+
+  const handleSave = async () => {
+    let path = currentBlueprintPath;
+    if (!path) {
+      const p = promptForPath(currentFolder);
+      if (!p) return;
+      path = p;
+      setCurrentBlueprintPath(path);
+    }
+    const viewport = getViewport();
+    await saveCurrentBlueprint(path, viewport);
+  };
+
+  const handleUploadFiles = async (files: FileList, targetFolder: string) => {
+    for (const file of Array.from(files)) {
+      if (!file.name.toLowerCase().endsWith('.json')) continue;
+      const text = await file.text();
+      try {
+        const json = JSON.parse(text);
+        const viewport = json.viewport;
+        const uniqueName = ensureUniqueName(file.name, targetFolder, false);
+        await saveBlueprintFile(joinPath(targetFolder, uniqueName), viewport);
+      } catch {
+        // skip invalid json
+      }
+    }
+    await refreshProjectTree();
+  };
+
+  // Fade-out for "Saved" indicator
+  useEffect(() => {
+    if (saveStatus === 'saved') {
+      setShowSaveStatus(true);
+      const t = window.setTimeout(() => setShowSaveStatus(false), 1000);
+      return () => window.clearTimeout(t);
+    }
+    if (saveStatus === 'saving' || saveStatus === 'error') {
+      setShowSaveStatus(true);
+      return;
+    }
+    setShowSaveStatus(false);
+  }, [saveStatus]);
+
+  const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const files = e.target.files;
+    if (files && files.length > 0) {
+      await handleUploadFiles(files, currentFolder);
+      e.target.value = '';
+    }
+  };
+
+  // Drag move blueprint into folder
+  const onItemDragStart = (e: React.DragEvent, item: FSItem) => {
+    setDragItem(item);
+    e.dataTransfer.effectAllowed = 'move';
+  };
+  const onItemDragOver = (e: React.DragEvent, item: FSItem) => {
+    if (item.type === 'folder') {
+      e.preventDefault();
+      e.dataTransfer.dropEffect = 'move';
+    }
+  };
+  const onItemDrop = async (e: React.DragEvent, target: FSItem) => {
+    e.preventDefault();
+    if (!dragItem || target.type !== 'folder') return;
+    const newPath = joinPath(target.path, dragItem.name);
+    if (newPath === dragItem.path) return;
+    await renameProjectItem(dragItem.path, undefined, newPath);
+    setDragItem(null);
+  };
+
+  const toggleFolder = (path: string) => {
+    setExpanded(prev => {
+      const next = new Set(prev);
+      if (next.has(path)) next.delete(path);
+      else next.add(path);
+      return next;
+    });
+  };
+
+  const renderTree = useCallback((items: FSItem[], depth = 0) => {
+    return items.map(item => {
+      const isActive = currentBlueprintPath === item.path;
+      const isExpanded = expanded.has(item.path);
+      const padding = depth * 12;
+      const hasChildren = (item.children?.length || 0) > 0;
+      return (
+        <div key={item.path}>
+          <div
+            className={`flex items-center justify-between px-2 py-1 rounded cursor-pointer ${
+              isActive
+                ? isDark ? 'bg-blue-900/40 border border-blue-700' : 'bg-blue-50 border-blue-200 border'
+                : isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-100'
+            }`}
+            style={{ paddingLeft: padding + 8 }}
+            onContextMenu={(e) => openContextMenu(e, item)}
+            onClick={() => {
+              if (item.type === 'folder') {
+                toggleFolder(item.path);
+                setCurrentFolder(item.path);
+              } else {
+                setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.');
+              }
+            }}
+            onDoubleClick={() => {
+              if (item.type === 'file') {
+                handleLoadFile(item);
+                setCurrentFolder(item.path.split('/').slice(0, -1).join('/') || '.');
+              }
+            }}
+            draggable
+            onDragStart={(e) => onItemDragStart(e, item)}
+            onDragOver={(e) => onItemDragOver(e, item)}
+            onDrop={(e) => onItemDrop(e, item)}
+          >
+            <div className="flex items-center gap-2">
+              {item.type === 'folder' ? (
+                <button
+                  className="w-4 text-left"
+                  onClick={(e) => { e.stopPropagation(); toggleFolder(item.path); }}
+                  title={hasChildren ? undefined : 'Empty folder'}
+                >
+                  {isExpanded ? '▾' : '▸'}
+                </button>
+              ) : (
+                <span className="w-4" />
+              )}
+              {item.type === 'folder' ? <Folder size={14} /> : <FileText size={14} />}
+              <span className="truncate">{stripJson(item.name)}</span>
+            </div>
+            <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" onClick={(e) => { e.stopPropagation(); openContextMenu(e as any, item); }}>
+              <MoreVertical size={14} />
+            </button>
+          </div>
+          {item.type === 'folder' && isExpanded && item.children && item.children.length > 0 && (
+            <div>
+              {renderTree(item.children, depth + 1)}
+            </div>
+          )}
+        </div>
+      );
+    });
+  }, [isDark, currentBlueprintPath, expanded, handleLoadFile]);
+
   if (!isOpen) {
     return (
       <div className={`border-r h-screen flex flex-col items-center py-4 w-12 z-10 transition-all duration-300 ${
@@ -40,9 +355,18 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
   }
 
   return (
-    <div className={`w-64 border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${
+    <div 
+      className={`w-[14%] min-w-[260px] max-w-[360px] border-r h-screen flex flex-col shadow-xl z-10 transition-all duration-300 ${
       isDark ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
-    }`}>
+    }`} 
+      onClick={() => setContextMenu(null)}
+      onContextMenu={(e) => {
+        // Default empty-area context menu
+        if (activeTab === 'project') {
+          openContextMenu(e);
+        }
+      }}
+    >
       {/* Header */}
       <div className={`p-3 border-b flex justify-between items-center ${
         isDark ? 'border-gray-700 bg-gray-900' : 'border-gray-200 bg-gray-50'
@@ -93,9 +417,103 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
       {/* Content Area */}
       <div className={`flex-1 overflow-y-auto p-4 text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
         {activeTab === 'project' && (
-          <div className="flex flex-col items-center justify-center h-full opacity-50">
-            <Folder size={48} className="mb-2" />
-            <p>Project settings coming soon</p>
+          <div 
+            className="space-y-2"
+          >
+            <div 
+              className="flex items-center gap-2 mb-2"
+              onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
+            >
+              <button
+                onClick={() => refreshProjectTree()}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+                title="Refresh"
+              >
+                <RefreshCw size={14} />
+              </button>
+              <button
+                onClick={() => handleNewBlueprint(currentFolder)}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+                title="New Blueprint"
+              >
+                <Plus size={14} />
+              </button>
+              <button
+                onClick={() => handleCreateFolder(currentFolder)}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+                title="New Folder"
+              >
+                <Folder size={14} />
+              </button>
+              <button
+                onClick={handleUploadClick}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+                title="Upload Blueprint"
+              >
+                <Upload size={14} />
+              </button>
+              <button
+                onClick={handleSave}
+                className={`p-1 rounded ${isDark ? 'hover:bg-gray-700' : 'hover:bg-gray-200'}`}
+                title="Save (Ctrl+S)"
+              >
+                <Edit3 size={14} />
+              </button>
+              <span 
+                className={`text-xs transition-opacity duration-300 ${
+                  saveStatus === 'saved' ? 'text-green-500' : saveStatus === 'saving' ? 'text-blue-500' : saveStatus === 'error' ? 'text-red-500' : isDark ? 'text-gray-500' : 'text-gray-400'
+                }`}
+                style={{ opacity: showSaveStatus && saveStatus !== 'idle' ? 1 : 0 }}
+              >
+                {saveStatus === 'saved' ? 'Saved' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed' : ''}
+              </span>
+              <input
+                ref={fileInputRef}
+                type="file"
+                accept=".json,application/json"
+                multiple
+                className="hidden"
+                onChange={handleFileInputChange}
+              />
+            </div>
+            {!currentBlueprintPath && (
+              <div className={`text-xs italic ${isDark ? 'text-gray-500' : 'text-gray-400'}`}>
+                No file open; Save will create a new file.
+              </div>
+            )}
+
+            <div 
+              className={`${isDark ? 'border-gray-700' : 'border-gray-200'} border-t border-dashed mx-1`} 
+              onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
+            />
+
+            <div
+              onContextMenu={(e) => {
+                if (activeTab === 'project') {
+                  openContextMenu(e);
+                }
+              }}
+              onDragOver={(e) => { 
+                if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); }
+              }}
+              onDrop={async (e) => {
+                if (e.dataTransfer.files?.length) {
+                  e.preventDefault();
+                  await handleUploadFiles(e.dataTransfer.files, currentFolder);
+                }
+              }}
+            >
+              {projectTree.length === 0 ? (
+                <div className="flex flex-col items-center justify-center h-40 opacity-50">
+                  <Folder size={32} className="mb-2" />
+                  <p className="text-xs text-center">No files. Right-click to add.</p>
+                </div>
+              ) : (
+                <div className="space-y-1">
+                  {renderTree(projectTree, 0)}
+                </div>
+              )}
+            </div>
           </div>
         )}
         {activeTab === 'files' && (
@@ -127,6 +545,7 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
                         ? 'bg-gray-700 border-gray-600 hover:bg-gray-600 hover:border-gray-500' 
                         : 'bg-gray-50 border-gray-200 hover:bg-gray-100 hover:border-gray-300'
                     }`}
+                    title={`Label: ${archived.label}\nModel: ${archived.model}\nSystem: ${archived.systemPrompt || '(empty)'}\nUser: ${(archived.userPrompt || '').slice(0,80)}${(archived.userPrompt || '').length>80?'…':''}\nResp: ${(archived.response || '').slice(0,80)}${(archived.response || '').length>80?'…':''}`}
                   >
                     <div className="flex items-center justify-between">
                       <div className="flex items-center gap-2">
@@ -151,6 +570,50 @@ const LeftSidebar: React.FC<LeftSidebarProps> = ({ isOpen, onToggle }) => {
           </div>
         )}
       </div>
+
+      {/* Context Menu */}
+      {contextMenu && (
+        <div
+          className={`fixed z-50 rounded-md shadow-lg border py-1 text-sm ${isDark ? 'bg-gray-800 border-gray-700 text-gray-200' : 'bg-white border-gray-200 text-gray-700'}`}
+          style={{ top: contextMenu.y, left: contextMenu.x }}
+          onClick={(e) => e.stopPropagation()}
+        >
+          {(() => {
+            const item = contextMenu.item;
+            const targetFolder = item
+              ? (item.type === 'folder' ? item.path : item.path.split('/').slice(0, -1).join('/') || '.')
+              : '.'; // empty area => root
+            const commonNew = (
+              <>
+                <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleCreateFolder(targetFolder); }}>New Folder</button>
+                <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleNewBlueprint(targetFolder); }}>New Blueprint</button>
+                <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleUploadClick(); }}>Upload</button>
+              </>
+            );
+            if (!item) {
+              return commonNew;
+            }
+            if (item.type === 'file') {
+              return (
+                <>
+                  {commonNew}
+                  <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleDownload(item); }}>Download</button>
+                  <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button>
+                  <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button>
+                </>
+              );
+            }
+            // folder
+            return (
+              <>
+                {commonNew}
+                <button className="block w-full text-left px-3 py-1 hover:bg-gray-100 dark:hover:bg-gray-700" onClick={() => { closeContextMenu(); handleRename(item); }}>Rename</button>
+                <button className="block w-full text-left px-3 py-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/40" onClick={() => { closeContextMenu(); handleDelete(item); }}>Delete</button>
+              </>
+            );
+          })()}
+        </div>
+      )}
     </div>
   );
 };
-- 
cgit v1.2.3

