diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1a1e213 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 180 +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d79ffeb..936e486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "@codemirror/merge": "^6.9.0", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.36.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@kintone/rest-api-client": "^6.1.2", diff --git a/package.json b/package.json index dbe68d8..2eb3888 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,10 @@ "@codemirror/merge": "^6.9.0", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.36.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@kintone/rest-api-client": "^6.1.2", diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index f953ef1..63b5755 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -342,11 +342,8 @@ const AppDetail: React.FC = () => { ); } - const changeCount = - currentDomain && selectedAppId - ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) - : { added: 0, deleted: 0 }; - const hasChanges = changeCount.added > 0 || changeCount.deleted > 0; + const changeCount = currentDomain && selectedAppId ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) : { added: 0, deleted: 0, reordered: 0 }; + const hasChanges = changeCount.added > 0 || changeCount.deleted > 0 || changeCount.reordered > 0; return (
@@ -394,8 +391,10 @@ const AppDetail: React.FC = () => { }} > {changeCount.added > 0 && `+${changeCount.added}`} - {changeCount.added > 0 && changeCount.deleted > 0 && " "} + {changeCount.added > 0 && (changeCount.deleted > 0 || changeCount.reordered > 0) && " "} {changeCount.deleted > 0 && `-${changeCount.deleted}`} + {(changeCount.added > 0 || changeCount.deleted > 0) && changeCount.reordered > 0 && " "} + {changeCount.reordered > 0 && `~${changeCount.reordered}`} )} diff --git a/src/renderer/src/components/AppDetail/FileItem.tsx b/src/renderer/src/components/AppDetail/FileItem.tsx index a7c0b87..751519e 100644 --- a/src/renderer/src/components/AppDetail/FileItem.tsx +++ b/src/renderer/src/components/AppDetail/FileItem.tsx @@ -1,6 +1,6 @@ /** * FileItem Component - * Displays a single file with status indicator (unchanged/added/deleted). + * Displays a single file with status indicator (unchanged/added/deleted/reordered). * Shows appropriate action buttons based on status. */ @@ -84,69 +84,78 @@ const FileItem: React.FC = ({ const { styles, cx } = useStyles(); const token = useTheme(); - const statusColor = { + const statusColor: Record = { unchanged: "transparent", added: token.colorSuccess, deleted: token.colorError, - }[entry.status]; + reordered: token.colorWarning, + }; return ( - -
-
- - {entry.status !== "unchanged" && ( -
- )} - +
+ + {entry.status !== "unchanged" && ( +
- + + {entry.fileName} + + {entry.status === "added" && ( + + )} + {entry.status === "deleted" && ( + + )} + {entry.status === "reordered" && ( + + )} +
+ + + {entry.size && ( + {formatFileSize(entry.size)} + )} + + {entry.status === "deleted" ? ( +
- - - {entry.size && ( - {formatFileSize(entry.size)} - )} - - {entry.status === "deleted" ? ( - - ) : ( - <> - {entry.status === "unchanged" && onView && entry.fileKey && ( + {t("restore")} + + ) : ( + <> + {(entry.status === "unchanged" || entry.status === "reordered") && + onView && + entry.fileKey && ( )} - {entry.status === "unchanged" && onDownload && entry.fileKey && ( + {(entry.status === "unchanged" || entry.status === "reordered") && + onDownload && + entry.fileKey && ( )} -
- +
); }; -export default FileItem; +export default FileItem; \ No newline at end of file diff --git a/src/renderer/src/components/AppDetail/FileSection.tsx b/src/renderer/src/components/AppDetail/FileSection.tsx index 043f4ae..4a18eab 100644 --- a/src/renderer/src/components/AppDetail/FileSection.tsx +++ b/src/renderer/src/components/AppDetail/FileSection.tsx @@ -7,12 +7,12 @@ import React, { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Tag, App as AntApp } from "antd"; -import { SortableList } from "@lobehub/ui"; import { createStyles, useTheme } from "antd-style"; import { useFileChangeStore } from "@renderer/stores"; import type { FileEntry } from "@renderer/stores"; import FileItem from "./FileItem"; import DropZone from "./DropZone"; +import SortableFileList from "./SortableFileList"; const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB @@ -86,13 +86,6 @@ const useStyles = createStyles(({ token, css }) => ({ pointer-events: none; z-index: 10; `, - sortableItem: css` - background: ${token.colorBgContainer}; - - &:hover { - background: ${token.colorBgTextHover}; - } - `, })); const FileSection: React.FC = ({ @@ -123,7 +116,8 @@ const FileSection: React.FC = ({ const addedCount = files.filter((f) => f.status === "added").length; const deletedCount = files.filter((f) => f.status === "deleted").length; - const hasChanges = addedCount > 0 || deletedCount > 0; + const reorderedCount = files.filter((f) => f.status === "reordered").length; + const hasChanges = addedCount > 0 || deletedCount > 0 || reorderedCount > 0; // ── Shared save logic ───────────────────────────────────────────────────── const saveFile = useCallback( @@ -281,14 +275,15 @@ const FileSection: React.FC = ({ ); // ── Reorder ──────────────────────────────────────────────────────────────── - const handleSortChange = useCallback( - (newItems: { id: string }[]) => { + const handleReorder = useCallback( + (newOrder: string[], draggedFileId: string) => { reorderSection( domainId, appId, platform, fileType, - newItems.map((i) => i.id), + newOrder, + draggedFileId ); }, [domainId, appId, platform, fileType, reorderSection], @@ -296,39 +291,32 @@ const FileSection: React.FC = ({ // ── Render item ──────────────────────────────────────────────────────────── const renderItem = useCallback( - (item: { id: string }) => { - const entry = files.find((f) => f.id === item.id); - if (!entry) return null; - + (entry: FileEntry) => { return ( -
- handleDelete(entry)} - onRestore={() => handleRestore(entry.id)} - onView={ - entry.fileKey - ? () => onView(entry.fileKey!, entry.fileName) - : undefined - } - onDownload={ - entry.fileKey - ? () => onDownload(entry.fileKey!, entry.fileName) - : undefined - } - isDownloading={downloadingKey === entry.fileKey} - /> -
+ handleDelete(entry)} + onRestore={() => handleRestore(entry.id)} + onView={ + entry.fileKey + ? () => onView(entry.fileKey!, entry.fileName) + : undefined + } + onDownload={ + entry.fileKey + ? () => onDownload(entry.fileKey!, entry.fileName) + : undefined + } + isDownloading={downloadingKey === entry.fileKey} + /> ); }, [ - files, handleDelete, handleRestore, onView, onDownload, downloadingKey, - styles.sortableItem, ], ); @@ -348,8 +336,10 @@ const FileSection: React.FC = ({ }} > {addedCount > 0 && `+${addedCount}`} - {addedCount > 0 && deletedCount > 0 && " "} + {addedCount > 0 && (deletedCount > 0 || reorderedCount > 0) && " "} {deletedCount > 0 && `-${deletedCount}`} + {(addedCount > 0 || deletedCount > 0) && reorderedCount > 0 && " "} + {reorderedCount > 0 && `~${reorderedCount}`} )}
@@ -388,11 +378,10 @@ const FileSection: React.FC = ({ {files.length === 0 ? (
{t("noConfig")}
) : ( - )} diff --git a/src/renderer/src/components/AppDetail/SortableFileList.tsx b/src/renderer/src/components/AppDetail/SortableFileList.tsx new file mode 100644 index 0000000..3e4242c --- /dev/null +++ b/src/renderer/src/components/AppDetail/SortableFileList.tsx @@ -0,0 +1,75 @@ +/** + * SortableFileList Component + * A sortable list component using dnd-kit for drag-and-drop reordering. + * Provides precise tracking of which item was dragged. + * Uses LobeHub UI's SortableList.Item and SortableList.DragHandle for consistent styling. + */ + +import React, { useCallback } from "react"; +import { SortableList } from "@lobehub/ui"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { restrictToVerticalAxis, restrictToWindowEdges } from "@dnd-kit/modifiers"; +import type { FileEntry } from "@renderer/stores"; + +// ── SortableFileList Component ──────────────────────────────────────────────── +interface SortableFileListProps { + items: FileEntry[]; + onReorder: (newOrder: string[], draggedItemId: string) => void; + renderItem: (entry: FileEntry) => React.ReactNode; +} + +const SortableFileList: React.FC = ({ items, onReorder, renderItem }) => { + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = items.findIndex((f) => f.id === active.id); + const newIndex = items.findIndex((f) => f.id === over.id); + const newOrder = arrayMove(items, oldIndex, newIndex).map((f) => f.id); + + onReorder(newOrder, active.id as string); + } + }, + [items, onReorder] + ); + + return ( + + + {items.map((entry) => ( + + {renderItem(entry)} + + ))} + + + ); +}; + +export default SortableFileList; \ No newline at end of file diff --git a/src/renderer/src/locales/en-US/app.json b/src/renderer/src/locales/en-US/app.json index 1ce00bb..d1819a4 100644 --- a/src/renderer/src/locales/en-US/app.json +++ b/src/renderer/src/locales/en-US/app.json @@ -47,5 +47,6 @@ "fileDeleteFailed": "Failed to delete file", "statusAdded": "New", "statusDeleted": "Deleted", + "statusReordered": "Moved", "restore": "Restore" } diff --git a/src/renderer/src/locales/ja-JP/app.json b/src/renderer/src/locales/ja-JP/app.json index eee3d64..d0a9a67 100644 --- a/src/renderer/src/locales/ja-JP/app.json +++ b/src/renderer/src/locales/ja-JP/app.json @@ -47,5 +47,6 @@ "fileDeleteFailed": "ファイルの削除に失敗しました", "statusAdded": "新規", "statusDeleted": "削除", + "statusReordered": "順序変更", "restore": "復元" } diff --git a/src/renderer/src/locales/zh-CN/app.json b/src/renderer/src/locales/zh-CN/app.json index 375a979..97fc8fd 100644 --- a/src/renderer/src/locales/zh-CN/app.json +++ b/src/renderer/src/locales/zh-CN/app.json @@ -47,5 +47,6 @@ "fileDeleteFailed": "删除文件失败", "statusAdded": "新增", "statusDeleted": "删除", + "statusReordered": "已移动", "restore": "恢复" } diff --git a/src/renderer/src/stores/fileChangeStore.ts b/src/renderer/src/stores/fileChangeStore.ts index 70414fc..05c5066 100644 --- a/src/renderer/src/stores/fileChangeStore.ts +++ b/src/renderer/src/stores/fileChangeStore.ts @@ -1,6 +1,6 @@ /** * File Change Store - * Manages file change state (added/deleted/unchanged) per app. + * Manages file change state (added/deleted/unchanged/reordered) per app. * File content is NOT stored here — only metadata and local disk paths. * State is persisted so pending changes survive app restarts. */ @@ -29,6 +29,8 @@ export interface FileEntry { interface AppFileState { files: FileEntry[]; initialized: boolean; + /** Original order for each section: key is `${platform}:${fileType}`, value is ordered file IDs */ + originalSectionOrders: Record; } interface FileChangeState { @@ -52,7 +54,7 @@ interface FileChangeState { /** * Mark a file for deletion, or remove an added file from the list. - * - unchanged → deleted + * - unchanged/reordered → deleted * - added → removed from list entirely * - deleted → no-op */ @@ -66,7 +68,7 @@ interface FileChangeState { /** * Reorder files within a specific (platform, fileType) section. - * newOrder is the new ordered array of file IDs for that section. + * The dragged file's status will be set to "reordered" (if it was "unchanged"). */ reorderSection: ( domainId: string, @@ -74,6 +76,7 @@ interface FileChangeState { platform: "desktop" | "mobile", fileType: "js" | "css", newOrder: string[], + draggedFileId: string ) => void; /** @@ -93,11 +96,11 @@ interface FileChangeState { fileType: "js" | "css", ) => FileEntry[]; - /** Count of added and deleted files */ + /** Count of added, deleted, and reordered files */ getChangeCount: ( domainId: string, - appId: string, - ) => { added: number; deleted: number }; + appId: string + ) => { added: number; deleted: number; reordered: number }; isInitialized: (domainId: string, appId: string) => boolean; } @@ -119,10 +122,20 @@ export const useFileChangeStore = create()( status: "unchanged" as FileStatus, })); + // Build original section orders + const originalSectionOrders: Record = {}; + for (const f of entries) { + const sectionKey = `${f.platform}:${f.fileType}`; + if (!originalSectionOrders[sectionKey]) { + originalSectionOrders[sectionKey] = []; + } + originalSectionOrders[sectionKey].push(f.id); + } + set((state) => ({ appFiles: { ...state.appFiles, - [key]: { files: entries, initialized: true }, + [key]: { files: entries, initialized: true, originalSectionOrders }, }, })); }, @@ -130,7 +143,11 @@ export const useFileChangeStore = create()( addFile: (domainId, appId, entry) => { const key = appKey(domainId, appId); set((state) => { - const existing = state.appFiles[key] ?? { files: [], initialized: true }; + const existing = state.appFiles[key] ?? { + files: [], + initialized: true, + originalSectionOrders: {}, + }; return { appFiles: { ...state.appFiles, @@ -156,8 +173,8 @@ export const useFileChangeStore = create()( if (file.status === "added") { // Remove added files entirely updatedFiles = existing.files.filter((f) => f.id !== fileId); - } else if (file.status === "unchanged") { - // Mark unchanged files as deleted + } else if (file.status === "unchanged" || file.status === "reordered") { + // Mark unchanged/reordered files as deleted updatedFiles = existing.files.map((f) => f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f, ); @@ -196,7 +213,7 @@ export const useFileChangeStore = create()( }); }, - reorderSection: (domainId, appId, platform, fileType, newOrder) => { + reorderSection: (domainId, appId, platform, fileType, newOrder, draggedFileId) => { const key = appKey(domainId, appId); set((state) => { const existing = state.appFiles[key]; @@ -221,11 +238,15 @@ export const useFileChangeStore = create()( if (!newOrder.includes(f.id)) reordered.push(f); } - // Merge: maintain overall section order in the flat array - // Order: all non-section files first (in their original positions), - // but to maintain section integrity, rebuild in platform/fileType groups. - // Simple approach: replace section slice with reordered. - const finalFiles = [...otherFiles, ...reordered]; + // Mark the dragged file as "reordered" if it was "unchanged" + const finalSectionFiles = reordered.map((f) => { + if (f.id === draggedFileId && f.status === "unchanged") { + return { ...f, status: "reordered" as FileStatus }; + } + return f; + }); + + const finalFiles = [...otherFiles, ...finalSectionFiles]; return { appFiles: { @@ -241,7 +262,11 @@ export const useFileChangeStore = create()( set((state) => ({ appFiles: { ...state.appFiles, - [key]: { files: [], initialized: false }, + [key]: { + files: [], + initialized: false, + originalSectionOrders: {}, + }, }, })); }, @@ -262,9 +287,11 @@ export const useFileChangeStore = create()( getChangeCount: (domainId, appId) => { const key = appKey(domainId, appId); const files = get().appFiles[key]?.files ?? []; + return { added: files.filter((f) => f.status === "added").length, deleted: files.filter((f) => f.status === "deleted").length, + reordered: files.filter((f) => f.status === "reordered").length, }; }, @@ -275,6 +302,6 @@ export const useFileChangeStore = create()( }), { name: "file-change-storage", - }, - ), + } + ) ); diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index e53f2ad..fcc02c1 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -66,7 +66,7 @@ export interface DeployFile { position: string; } -export type FileStatus = "unchanged" | "added" | "deleted"; +export type FileStatus = "unchanged" | "added" | "deleted" | "reordered"; export interface DeployFileEntry { id: string;