diff --git a/__init__.py b/__init__.py index 6638cef..18eed92 100644 --- a/__init__.py +++ b/__init__.py @@ -261,6 +261,22 @@ def file_handle(name, file, existFlowIds, fileList): else: fileList.append(fileInfo) +def folder_handle(path, existFlowIds): + fileList = [] + for item in os.listdir(path): + item_path = os.path.join(path, item) + if os.path.isfile(item_path) and item_path.endswith('.json'): + with open(item_path, 'r') as f: + file_handle(item, f, existFlowIds, fileList) + + elif os.path.isdir(item_path): + fileList.append({ + 'name': item, + 'list': folder_handle(item_path, existFlowIds) + }) + return fileList + + # Scan all files and subfolders in the local save directory. # For files, compare the extra.workspace_info.id in the json format file with the flow of the current DB to determine whether it is a flow that needs to be added; # For subfolders, scan the json files in the subfolder and use the same processing method as the file to determine whether it is a flow that needs to be added; @@ -270,29 +286,8 @@ async def scan_local_new_files(request): path = reqJson['path'] existFlowIds = reqJson['existFlowIds'] - fileList = [] - folderList = [] - - for item in os.listdir(path): - item_path = os.path.join(path, item) - if os.path.isfile(item_path) and item_path.endswith('.json'): - with open(item_path, 'r') as f: - file_handle(item, f, existFlowIds, fileList) - - elif os.path.isdir(item_path): - folder = { - 'name': item, - 'list': [] - } - for sub_item in os.listdir(item_path): - sub_item_path = os.path.join(item_path, sub_item) - if os.path.isfile(sub_item_path) and sub_item_path.endswith('.json'): - with open(sub_item_path, 'r') as f: - file_handle(sub_item, f, existFlowIds, folder['list']) - - if len(folder['list']) > 0: - folderList.append(folder) - return web.Response(text=json.dumps({'fileList': fileList, 'folderList': folderList}), content_type='application/json') + fileList = folder_handle(path, existFlowIds) + return web.Response(text=json.dumps(fileList), content_type='application/json') @server.PromptServer.instance.routes.post("/workspace/delete_folder") diff --git a/ui/src/Api.ts b/ui/src/Api.ts index 4ae1547..cd36b3d 100644 --- a/ui/src/Api.ts +++ b/ui/src/Api.ts @@ -122,6 +122,16 @@ export async function openWorkflowsFolder() { } } +export type ScanLocalFile = { + name: string; + json: string; +}; +export type ScanLocalFolder = { + name: string; + list: ScanLocalResult[]; +}; +export type ScanLocalResult = ScanLocalFile | ScanLocalFolder; + export async function scanLocalNewFiles(path: string, existFlowIds: string[]) { try { const response = await fetch("/workspace/scan_local_new_files", { @@ -134,7 +144,7 @@ export async function scanLocalNewFiles(path: string, existFlowIds: string[]) { existFlowIds, }), }); - const result = await response.json(); + const result: ScanLocalResult[] = await response.json(); return result; } catch (error) { console.error("Error scan local new files:", error); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 12d7afb..daa4bcf 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -154,11 +154,9 @@ export default function App() { await userSettingsTable?.getSetting("myWorkflowsDir"); const allFlows = await workflowsTable?.listAll(); const existFlowIds = (allFlows && allFlows.map((flow) => flow.id)) || []; - const { fileList, folderList } = await scanLocalNewFiles( - myWorkflowsDir!, - existFlowIds, - ); - await syncNewFlowOfLocalDisk(fileList, folderList); + const fileList = await scanLocalNewFiles(myWorkflowsDir!, existFlowIds); + if (!fileList || fileList.length === 0) return; + await syncNewFlowOfLocalDisk(fileList); } }; diff --git a/ui/src/RecentFilesDrawer/FilesListFolderItem.tsx b/ui/src/RecentFilesDrawer/FilesListFolderItem.tsx index e7d0f6e..1820e93 100644 --- a/ui/src/RecentFilesDrawer/FilesListFolderItem.tsx +++ b/ui/src/RecentFilesDrawer/FilesListFolderItem.tsx @@ -4,13 +4,20 @@ import { isFolder, workflowsTable, } from "../db-tables/WorkspaceDB"; -import { useState, memo, useContext, useEffect, MouseEvent } from "react"; +import { + useState, + memo, + useContext, + useEffect, + MouseEvent, + DragEvent, +} from "react"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { RecentFilesContext } from "../WorkspaceContext"; -import WorkflowListItem from "./WorkflowListItem"; import FilesListFolderItemRightClickMenu from "./FilesListFolderItemRightClickMenu"; import { ESortTypes, sortTypeLocalStorageKey } from "./types"; import { Folder, Workflow } from "../types/dbTypes"; +import ItemsList from "./ItemsList"; type Props = { folder: Folder; @@ -22,8 +29,12 @@ export default memo(function FilesListFolderItem({ folder }: Props) { const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const [isMenuOpen, setIsMenuOpen] = useState(false); const { colorMode } = useColorMode(); - const { draggingFile, refreshFolderStamp, onRefreshFilesList } = - useContext(RecentFilesContext); + const { + draggingFile, + setDraggingFile, + refreshFolderStamp, + onRefreshFilesList, + } = useContext(RecentFilesContext); const activeStyle = colorMode === "light" ? { backgroundColor: "#E2E8F0" } @@ -45,6 +56,24 @@ export default memo(function FilesListFolderItem({ folder }: Props) { setMenuPosition({ x: event.clientX, y: event.clientY }); setIsMenuOpen(true); }; + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggingFile) return setIsActive(false); + if (isFolder(draggingFile)) { + if (draggingFile.id === folder.id) return setIsActive(false); + await foldersTable?.update({ + id: draggingFile.id, + parentFolderID: folder.id, + }); + } else if (!isFolder(draggingFile)) { + await workflowsTable?.updateFlow(draggingFile.id, { + parentFolderID: folder.id, + }); + } + await onRefreshFilesList?.(); + setIsActive(false); + }; useEffect(() => { if (!!folder.isCollapse === isCollapsed) return; foldersTable?.update({ @@ -62,24 +91,22 @@ export default memo(function FilesListFolderItem({ folder }: Props) { setIsCollapsed(!isCollapsed); }} onContextMenu={handleContextMenu} + draggable="true" onDragOver={(e) => { e.preventDefault(); + e.stopPropagation(); setIsActive(true); }} - onDragLeave={() => { - setIsActive(false); - }} - onDrop={async () => { - if (draggingFile && !isFolder(draggingFile)) { - await workflowsTable?.updateFlow(draggingFile.id, { - parentFolderID: folder.id, - }); - await onRefreshFilesList?.(); - } + onDragLeave={(e) => { + e.preventDefault(); + e.stopPropagation(); setIsActive(false); }} + onDragStart={() => setDraggingFile?.(folder)} + onDrop={handleDrop} _hover={activeStyle} style={isActive || isMenuOpen ? activeStyle : undefined} + className="droppable" > {isCollapsed ? ( @@ -97,23 +124,16 @@ export default memo(function FilesListFolderItem({ folder }: Props) { {folder.name} - - setIsMenuOpen(false)} - folder={folder} - /> + setIsMenuOpen(false)} + folder={folder} + /> {!isCollapsed && ( - {children.map((file) => { - if (isFolder(file)) { - return ; - } else { - return ; - } - })} + )} diff --git a/ui/src/RecentFilesDrawer/ItemsList.tsx b/ui/src/RecentFilesDrawer/ItemsList.tsx new file mode 100644 index 0000000..0f00bdd --- /dev/null +++ b/ui/src/RecentFilesDrawer/ItemsList.tsx @@ -0,0 +1,98 @@ +import { DragEvent, useContext, useEffect, useState } from "react"; +import { RecentFilesContext } from "../WorkspaceContext"; +import { + foldersTable, + isFolder, + workflowsTable, +} from "../db-tables/WorkspaceDB"; +import { Folder, Workflow } from "../types/dbTypes"; +import FilesListFolderItem from "./FilesListFolderItem"; +import WorkflowListItem from "./WorkflowListItem"; +import { Box } from "@chakra-ui/react"; +import { folderOnTopLocalStorageKey } from "./types"; +import { userSettingsTable } from "../db-tables/WorkspaceDB"; + +export default function ItemsList({ + items, +}: { + items: Array; +}) { + const [folderOnTop, setFolderOnTop] = useState(false); + const folders = items.filter(isFolder); + const workflows = items.filter((item): item is Workflow => !isFolder(item)); + const parentFolderID = workflows[0]?.parentFolderID; + + const [isDraggingOver, setIsDraggingOver] = useState(false); + const { onRefreshFilesList, draggingFile, refreshFolderStamp } = + useContext(RecentFilesContext); + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const targetIsDroppable = + e.relatedTarget instanceof Element && + e.relatedTarget.classList.contains("droppable") && + e.relatedTarget !== e.currentTarget; + const targetIsChild = e.currentTarget.contains(e.relatedTarget as Node); + // fixes a bug where the dragleave event is fired when dragging over a child element, causing elements to flicker + if (targetIsDroppable || !targetIsChild) { + setIsDraggingOver(false); + } + }; + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!draggingFile) return setIsDraggingOver(false); + if (isFolder(draggingFile)) { + if (draggingFile.id === parentFolderID) return setIsDraggingOver(false); + await foldersTable?.update({ + id: draggingFile.id, + parentFolderID: parentFolderID, + }); + } else { + await workflowsTable?.updateFlow(draggingFile.id, { + parentFolderID: parentFolderID, + }); + } + await onRefreshFilesList?.(); + setIsDraggingOver(false); + }; + + useEffect(() => { + userSettingsTable?.getSetting("foldersOnTop").then((res) => { + setFolderOnTop(res ?? false); + }); + }, [refreshFolderStamp]); + + return ( + <> + {folderOnTop && + folders.map((folder) => ( + + ))} + { + e.preventDefault(); + e.stopPropagation(); + setIsDraggingOver(true); + }} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className="droppable" + > + {folderOnTop + ? workflows.map((workflow) => ( + + )) + : items.map((n) => { + if (isFolder(n)) { + return ; + } + return ; + })} + + + ); +} diff --git a/ui/src/RecentFilesDrawer/RecentFilesDrawer.tsx b/ui/src/RecentFilesDrawer/RecentFilesDrawer.tsx index d03157d..dd364a3 100644 --- a/ui/src/RecentFilesDrawer/RecentFilesDrawer.tsx +++ b/ui/src/RecentFilesDrawer/RecentFilesDrawer.tsx @@ -12,12 +12,21 @@ import { Box, Flex, Tooltip, + Switch, } from "@chakra-ui/react"; -import { useEffect, useState, useRef, useCallback, useContext } from "react"; +import { + useEffect, + useState, + useRef, + useCallback, + useContext, + ChangeEvent, +} from "react"; import { workflowsTable, isFolder, foldersTable, + userSettingsTable, } from "../db-tables/WorkspaceDB"; import { IconChevronDown, @@ -28,19 +37,18 @@ import { import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext"; import RecentFilesDrawerMenu from "./RecentFilesDrawerMenu"; import { sortFileItem } from "../utils"; -import WorkflowListItem from "./WorkflowListItem"; import MultipleSelectionOperation from "./MultipleSelectionOperation"; import { ESortTypes, sortTypeLocalStorageKey } from "./types"; -// @ts-ignore +// @ts-expect-error ComfyUI import import { app } from "/scripts/app.js"; import { insertWorkflowToCanvas3 } from "./InsertWorkflowToCanvas"; -import FilesListFolderItem from "./FilesListFolderItem"; import { useDebounce } from "../customHooks/useDebounce"; import SearchInput from "../components/SearchInput"; import { openWorkflowsFolder } from "../Api"; import { Folder, Workflow } from "../types/dbTypes"; import ImportFileButton from "./ImportFileButton"; import MyTagsRow from "./MyTagsRow"; +import ItemsList from "./ItemsList"; type Props = { onClose: () => void; @@ -68,6 +76,12 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) { (window.localStorage.getItem(sortTypeLocalStorageKey) as ESortTypes) ?? ESortTypes.RECENTLY_MODIFIED, ); + const [folderOnTop, setFolderOnTop] = useState(false); + useEffect(() => { + userSettingsTable?.getSetting("foldersOnTop").then((res) => { + setFolderOnTop(res ?? false); + }); + }, []); const loadLatestWorkflows = async () => { const all = (await workflowsTable?.listFolderContent()) ?? []; @@ -112,6 +126,18 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) { !isFilter && setRefreshFolderStamp(Date.now()); }; + const onFolderOnTopChange = (e: ChangeEvent) => { + const state = e.target.checked; + userSettingsTable + ?.upsert({ + foldersOnTop: state, + }) + .then(() => { + setFolderOnTop(state); + !isFilter && setRefreshFolderStamp(Date.now()); + }); + }; + useEffect(() => { filterFlows(); }, [debounceSearchValue, selectedTag]); @@ -129,7 +155,7 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) { useEffect(() => { loadLatestWorkflows(); - const handleDrop = async (e: any) => { + const handleDrop = async (e: { canvasX: number; canvasY: number }) => { if (draggingWorkflowID.current) { const flow = await workflowsTable?.get(draggingWorkflowID.current); flow && @@ -319,18 +345,20 @@ export default function RecentFilesDrawer({ onClose, onClickNewFlow }: Props) { + + Folders on Top + + - {currentRenderingData.map((n) => { - if (isFolder(n)) { - return ; - } - return ; - })} + diff --git a/ui/src/RecentFilesDrawer/WorkflowListItem.tsx b/ui/src/RecentFilesDrawer/WorkflowListItem.tsx index 9b9e133..57e6708 100644 --- a/ui/src/RecentFilesDrawer/WorkflowListItem.tsx +++ b/ui/src/RecentFilesDrawer/WorkflowListItem.tsx @@ -11,10 +11,9 @@ import { Tooltip, } from "@chakra-ui/react"; import { IconExternalLink } from "@tabler/icons-react"; -import { isFolder, workflowsTable } from "../db-tables/WorkspaceDB"; import { formatTimestamp, openWorkflowInNewTab, isImageFormat } from "../utils"; import AddTagToWorkflowPopover from "./AddTagToWorkflowPopover"; -import { useState, memo, ChangeEvent, useContext } from "react"; +import { MouseEvent, useState, memo, ChangeEvent, useContext } from "react"; import WorkflowListItemRightClickMenu from "./WorkflowListItemRightClickMenu"; import DeleteConfirm from "../components/DeleteConfirm"; import { RecentFilesContext, WorkspaceContext } from "../WorkspaceContext"; @@ -25,7 +24,6 @@ type Props = { }; export default memo(function WorkflowListItem({ workflow }: Props) { const { colorMode } = useColorMode(); - const [isDraggingOver, setIsDraggingOver] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const [isMenuOpen, setIsMenuOpen] = useState(false); const { @@ -34,8 +32,6 @@ export default memo(function WorkflowListItem({ workflow }: Props) { onMultiSelectFlow, onDeleteFlow, multiSelectedFlowsID, - onRefreshFilesList, - draggingFile, } = useContext(RecentFilesContext); const isChecked = multiSelectedFlowsID && @@ -43,7 +39,7 @@ export default memo(function WorkflowListItem({ workflow }: Props) { multiSelectedFlowsID.includes(workflow.id); const { curFlowID, loadWorkflowID } = useContext(WorkspaceContext); const isSelected = curFlowID === workflow.id; - const handleContextMenu = (event: any) => { + const handleContextMenu = (event: MouseEvent) => { event.preventDefault(); setMenuPosition({ x: event.clientX, y: event.clientY }); setIsMenuOpen(true); @@ -58,30 +54,12 @@ export default memo(function WorkflowListItem({ workflow }: Props) { { - e.preventDefault(); - setIsDraggingOver(true); - }} - onDragLeave={() => { - setIsDraggingOver(false); - }} - onDrop={async () => { - if (draggingFile && !isFolder(draggingFile)) { - await workflowsTable?.updateFlow(draggingFile.id, { - parentFolderID: workflow.parentFolderID, - }); - await onRefreshFilesList?.(); - } - setIsDraggingOver(false); - }} backgroundColor={ isSelected ? "teal.200" : isMenuOpen ? hoverBgColor : undefined } color={isSelected && !isMultiSelecting ? "#333" : undefined} draggable={!isMultiSelecting} - onDragStart={(e) => { - setDraggingFile && setDraggingFile(workflow); - }} + onDragStart={() => setDraggingFile?.(workflow)} borderRadius={6} px={1} py={1} @@ -120,9 +98,6 @@ export default memo(function WorkflowListItem({ workflow }: Props) { - {isDraggingOver && ( - - )} ); diff --git a/ui/src/db-tables/FoldersTable.ts b/ui/src/db-tables/FoldersTable.ts index 62a87ec..7dd9595 100644 --- a/ui/src/db-tables/FoldersTable.ts +++ b/ui/src/db-tables/FoldersTable.ts @@ -23,7 +23,10 @@ export class FoldersTable extends TableBase { name: string; parentFolderID?: string; }): Promise { - const uniqueName = await this.generateUniqueName(input.name); + const uniqueName = await this.generateUniqueName( + input.name, + input.parentFolderID, + ); const folder: Folder = { id: uuidv4(), name: uniqueName, @@ -46,6 +49,10 @@ export class FoldersTable extends TableBase { if (folder == null) { return; } + const nameChanged = input.name != undefined && input.name != folder.name; + const parentFolderChanged = + input.parentFolderID != undefined && + input.parentFolderID != folder.parentFolderID; const newRecord = { ...folder, ...input, @@ -53,11 +60,17 @@ export class FoldersTable extends TableBase { if (input.name != null) { newRecord.updateTime = Date.now(); } + if (parentFolderChanged) { + input.name = await this.generateUniqueName( + newRecord.name, + newRecord.parentFolderID ?? undefined, + ); + } await indexdb.folders.update(input.id, input); this.saveDiskDB(); // folder moved or renamed - move all workflows to the right directory(not required when folded state changes) - if (input.name != null || input.parentFolderID != null) { + if (nameChanged || parentFolderChanged) { validateOrSaveAllJsonFileMyWorkflows(true); } } @@ -109,10 +122,10 @@ export class FoldersTable extends TableBase { this.saveDiskDB(); } - public async generateUniqueName(name?: string) { + public async generateUniqueName(name?: string, parentFolderID?: string) { let newFlowName = name ?? "New folder"; const folderNameList = await this.listAll().then((list) => - list.map((f) => f.name), + list.filter((f) => f.parentFolderID == parentFolderID).map((f) => f.name), ); if (folderNameList.includes(newFlowName)) { let num = 2; diff --git a/ui/src/db-tables/WorkflowsTable.ts b/ui/src/db-tables/WorkflowsTable.ts index c455e26..53d2ac6 100644 --- a/ui/src/db-tables/WorkflowsTable.ts +++ b/ui/src/db-tables/WorkflowsTable.ts @@ -113,10 +113,7 @@ export class WorkflowsTable extends TableBase { } await this.saveDiskDB(); // save to my_workflows/ - if ( - Object.prototype.hasOwnProperty.call(input, "name") || - Object.prototype.hasOwnProperty.call(input, "parentFolderID") - ) { + if ("name" in input || "parentFolderID" in input) { // renamed file or moved file folder await deleteJsonFileMyWorkflows(before); await saveJsonFileMyWorkflows(after); diff --git a/ui/src/types/dbTypes.ts b/ui/src/types/dbTypes.ts index 554902c..16b1e8f 100644 --- a/ui/src/types/dbTypes.ts +++ b/ui/src/types/dbTypes.ts @@ -54,6 +54,7 @@ export type UserSettings = { }; autoSave?: boolean; twoWaySync?: boolean; + foldersOnTop?: boolean; }; export interface PanelPosition { diff --git a/ui/src/utils.tsx b/ui/src/utils.tsx index 71e734c..7f61a94 100644 --- a/ui/src/utils.tsx +++ b/ui/src/utils.tsx @@ -1,5 +1,5 @@ // @ts-ignore -import { deleteFile, updateFile } from "./Api"; +import { ScanLocalFile, ScanLocalFolder, ScanLocalResult, deleteFile, updateFile } from "./Api"; import { ESortTypes } from "./RecentFilesDrawer/types"; import { workflowsTable, @@ -157,6 +157,7 @@ export async function validateOrSaveAllJsonFileMyWorkflows( for (const workflow of flowList) { const fullPath = await generateFilePathAbsolute(workflow); if (workflow.filePath != fullPath) { + console.log(workflow.filePath, fullPath); // file path changed workflow.filePath != null && (await deleteFile(workflow.filePath, deleteEmptyFolder)); @@ -546,16 +547,15 @@ export function getFileUrl(relativePath: string) { } export async function syncNewFlowOfLocalDisk( - singleFlowList: Workflow[], - folderList: { - name: string; - list: Workflow[]; - }[], + scanList: ScanLocalResult[], + parentFolderID?: string, ) { - if (singleFlowList.length) { - await workflowsTable?.batchCreateFlows(singleFlowList, true); + const fileList = scanList.filter((s): s is ScanLocalFile => "json" in s); + if (fileList.length) { + await workflowsTable?.batchCreateFlows(fileList, true, parentFolderID); } + const folderList = scanList.filter((s): s is ScanLocalFolder => "list" in s); if (folderList.length) { const currentFolderList = await foldersTable?.listAll(); @@ -571,11 +571,12 @@ export async function syncNewFlowOfLocalDisk( } else { const newFolder = await foldersTable?.create({ name: folder.name, + parentFolderID, }); folderId = newFolder?.id; } - await workflowsTable?.batchCreateFlows(folder.list, true, folderId); + await syncNewFlowOfLocalDisk(folder.list, folderId); } } }