add order hint

This commit is contained in:
2026-03-17 23:01:11 +08:00
parent 0f9f1a94fa
commit fca824beea
12 changed files with 253 additions and 137 deletions

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 180
}

4
package-lock.json generated
View File

@@ -14,6 +14,10 @@
"@codemirror/merge": "^6.9.0", "@codemirror/merge": "^6.9.0",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.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/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2", "@kintone/rest-api-client": "^6.1.2",

View File

@@ -25,6 +25,10 @@
"@codemirror/merge": "^6.9.0", "@codemirror/merge": "^6.9.0",
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.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/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2", "@kintone/rest-api-client": "^6.1.2",

View File

@@ -342,11 +342,8 @@ const AppDetail: React.FC = () => {
); );
} }
const changeCount = const changeCount = currentDomain && selectedAppId ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) : { added: 0, deleted: 0, reordered: 0 };
currentDomain && selectedAppId const hasChanges = changeCount.added > 0 || changeCount.deleted > 0 || changeCount.reordered > 0;
? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId)
: { added: 0, deleted: 0 };
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0;
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -394,8 +391,10 @@ const AppDetail: React.FC = () => {
}} }}
> >
{changeCount.added > 0 && `+${changeCount.added}`} {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.deleted > 0 && `-${changeCount.deleted}`}
{(changeCount.added > 0 || changeCount.deleted > 0) && changeCount.reordered > 0 && " "}
{changeCount.reordered > 0 && `~${changeCount.reordered}`}
</Tag> </Tag>
)} )}
</Button> </Button>

View File

@@ -1,6 +1,6 @@
/** /**
* FileItem Component * 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. * Shows appropriate action buttons based on status.
*/ */
@@ -84,69 +84,78 @@ const FileItem: React.FC<FileItemProps> = ({
const { styles, cx } = useStyles(); const { styles, cx } = useStyles();
const token = useTheme(); const token = useTheme();
const statusColor = { const statusColor: Record<string, string> = {
unchanged: "transparent", unchanged: "transparent",
added: token.colorSuccess, added: token.colorSuccess,
deleted: token.colorError, deleted: token.colorError,
}[entry.status]; reordered: token.colorWarning,
};
return ( return (
<SortableList.Item id={entry.id}> <div className={styles.item}>
<div className={styles.item}> <div className={styles.fileInfo}>
<div className={styles.fileInfo}> <SortableList.DragHandle />
<SortableList.DragHandle /> {entry.status !== "unchanged" && (
{entry.status !== "unchanged" && ( <div
<div className={styles.statusDot}
className={styles.statusDot} style={{ background: statusColor[entry.status] }}
style={{ background: statusColor }}
/>
)}
<MaterialFileTypeIcon
type="file"
filename={`file.${entry.fileType}`}
size={16}
/> />
<span )}
className={cx( <MaterialFileTypeIcon
styles.fileName, type="file"
entry.status === "deleted" && styles.fileNameDeleted, filename={`file.${entry.fileType}`}
)} size={16}
/>
<span
className={cx(
styles.fileName,
entry.status === "deleted" && styles.fileNameDeleted,
)}
>
{entry.fileName}
</span>
{entry.status === "added" && (
<Badge
color={token.colorSuccess}
text={t("statusAdded")}
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
/>
)}
{entry.status === "deleted" && (
<Badge
color={token.colorError}
text={t("statusDeleted")}
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
/>
)}
{entry.status === "reordered" && (
<Badge
color={token.colorWarning}
text={t("statusReordered")}
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
/>
)}
</div>
<Space>
{entry.size && (
<span className={styles.fileSize}>{formatFileSize(entry.size)}</span>
)}
{entry.status === "deleted" ? (
<Button
type="text"
size="small"
icon={<Undo2 size={16} />}
onClick={onRestore}
> >
{entry.fileName} {t("restore")}
</span> </Button>
{entry.status === "added" && ( ) : (
<Badge <>
color={token.colorSuccess} {(entry.status === "unchanged" || entry.status === "reordered") &&
text={t("statusAdded")} onView &&
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }} entry.fileKey && (
/>
)}
{entry.status === "deleted" && (
<Badge
color={token.colorError}
text={t("statusDeleted")}
style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }}
/>
)}
</div>
<Space>
{entry.size && (
<span className={styles.fileSize}>{formatFileSize(entry.size)}</span>
)}
{entry.status === "deleted" ? (
<Button
type="text"
size="small"
icon={<Undo2 size={16} />}
onClick={onRestore}
>
{t("restore")}
</Button>
) : (
<>
{entry.status === "unchanged" && onView && entry.fileKey && (
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -156,7 +165,9 @@ const FileItem: React.FC<FileItemProps> = ({
{t("view")} {t("view")}
</Button> </Button>
)} )}
{entry.status === "unchanged" && onDownload && entry.fileKey && ( {(entry.status === "unchanged" || entry.status === "reordered") &&
onDownload &&
entry.fileKey && (
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -167,18 +178,17 @@ const FileItem: React.FC<FileItemProps> = ({
{t("download", { ns: "common" })} {t("download", { ns: "common" })}
</Button> </Button>
)} )}
<Button <Button
type="text" type="text"
size="small" size="small"
danger danger
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
onClick={onDelete} onClick={onDelete}
/> />
</> </>
)} )}
</Space> </Space>
</div> </div>
</SortableList.Item>
); );
}; };

View File

@@ -7,12 +7,12 @@
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Tag, App as AntApp } from "antd"; import { Tag, App as AntApp } from "antd";
import { SortableList } from "@lobehub/ui";
import { createStyles, useTheme } from "antd-style"; import { createStyles, useTheme } from "antd-style";
import { useFileChangeStore } from "@renderer/stores"; import { useFileChangeStore } from "@renderer/stores";
import type { FileEntry } from "@renderer/stores"; import type { FileEntry } from "@renderer/stores";
import FileItem from "./FileItem"; import FileItem from "./FileItem";
import DropZone from "./DropZone"; import DropZone from "./DropZone";
import SortableFileList from "./SortableFileList";
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
@@ -86,13 +86,6 @@ const useStyles = createStyles(({ token, css }) => ({
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
`, `,
sortableItem: css`
background: ${token.colorBgContainer};
&:hover {
background: ${token.colorBgTextHover};
}
`,
})); }));
const FileSection: React.FC<FileSectionProps> = ({ const FileSection: React.FC<FileSectionProps> = ({
@@ -123,7 +116,8 @@ const FileSection: React.FC<FileSectionProps> = ({
const addedCount = files.filter((f) => f.status === "added").length; const addedCount = files.filter((f) => f.status === "added").length;
const deletedCount = files.filter((f) => f.status === "deleted").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 ───────────────────────────────────────────────────── // ── Shared save logic ─────────────────────────────────────────────────────
const saveFile = useCallback( const saveFile = useCallback(
@@ -281,14 +275,15 @@ const FileSection: React.FC<FileSectionProps> = ({
); );
// ── Reorder ──────────────────────────────────────────────────────────────── // ── Reorder ────────────────────────────────────────────────────────────────
const handleSortChange = useCallback( const handleReorder = useCallback(
(newItems: { id: string }[]) => { (newOrder: string[], draggedFileId: string) => {
reorderSection( reorderSection(
domainId, domainId,
appId, appId,
platform, platform,
fileType, fileType,
newItems.map((i) => i.id), newOrder,
draggedFileId
); );
}, },
[domainId, appId, platform, fileType, reorderSection], [domainId, appId, platform, fileType, reorderSection],
@@ -296,39 +291,32 @@ const FileSection: React.FC<FileSectionProps> = ({
// ── Render item ──────────────────────────────────────────────────────────── // ── Render item ────────────────────────────────────────────────────────────
const renderItem = useCallback( const renderItem = useCallback(
(item: { id: string }) => { (entry: FileEntry) => {
const entry = files.find((f) => f.id === item.id);
if (!entry) return null;
return ( return (
<div className={styles.sortableItem}> <FileItem
<FileItem entry={entry}
entry={entry} onDelete={() => handleDelete(entry)}
onDelete={() => handleDelete(entry)} onRestore={() => handleRestore(entry.id)}
onRestore={() => handleRestore(entry.id)} onView={
onView={ entry.fileKey
entry.fileKey ? () => onView(entry.fileKey!, entry.fileName)
? () => onView(entry.fileKey!, entry.fileName) : undefined
: undefined }
} onDownload={
onDownload={ entry.fileKey
entry.fileKey ? () => onDownload(entry.fileKey!, entry.fileName)
? () => onDownload(entry.fileKey!, entry.fileName) : undefined
: undefined }
} isDownloading={downloadingKey === entry.fileKey}
isDownloading={downloadingKey === entry.fileKey} />
/>
</div>
); );
}, },
[ [
files,
handleDelete, handleDelete,
handleRestore, handleRestore,
onView, onView,
onDownload, onDownload,
downloadingKey, downloadingKey,
styles.sortableItem,
], ],
); );
@@ -348,8 +336,10 @@ const FileSection: React.FC<FileSectionProps> = ({
}} }}
> >
{addedCount > 0 && `+${addedCount}`} {addedCount > 0 && `+${addedCount}`}
{addedCount > 0 && deletedCount > 0 && " "} {addedCount > 0 && (deletedCount > 0 || reorderedCount > 0) && " "}
{deletedCount > 0 && `-${deletedCount}`} {deletedCount > 0 && `-${deletedCount}`}
{(addedCount > 0 || deletedCount > 0) && reorderedCount > 0 && " "}
{reorderedCount > 0 && `~${reorderedCount}`}
</Tag> </Tag>
)} )}
</div> </div>
@@ -388,11 +378,10 @@ const FileSection: React.FC<FileSectionProps> = ({
{files.length === 0 ? ( {files.length === 0 ? (
<div className={styles.emptySection}>{t("noConfig")}</div> <div className={styles.emptySection}>{t("noConfig")}</div>
) : ( ) : (
<SortableList <SortableFileList
items={files} items={files}
onReorder={handleReorder}
renderItem={renderItem} renderItem={renderItem}
onChange={handleSortChange}
gap={0}
/> />
)} )}

View File

@@ -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<SortableFileListProps> = ({ 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 (
<DndContext
sensors={sensors}
collisionDetection={undefined}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items.map((entry) => (
<SortableList.Item key={entry.id} id={entry.id}>
{renderItem(entry)}
</SortableList.Item>
))}
</SortableContext>
</DndContext>
);
};
export default SortableFileList;

View File

@@ -47,5 +47,6 @@
"fileDeleteFailed": "Failed to delete file", "fileDeleteFailed": "Failed to delete file",
"statusAdded": "New", "statusAdded": "New",
"statusDeleted": "Deleted", "statusDeleted": "Deleted",
"statusReordered": "Moved",
"restore": "Restore" "restore": "Restore"
} }

View File

@@ -47,5 +47,6 @@
"fileDeleteFailed": "ファイルの削除に失敗しました", "fileDeleteFailed": "ファイルの削除に失敗しました",
"statusAdded": "新規", "statusAdded": "新規",
"statusDeleted": "削除", "statusDeleted": "削除",
"statusReordered": "順序変更",
"restore": "復元" "restore": "復元"
} }

View File

@@ -47,5 +47,6 @@
"fileDeleteFailed": "删除文件失败", "fileDeleteFailed": "删除文件失败",
"statusAdded": "新增", "statusAdded": "新增",
"statusDeleted": "删除", "statusDeleted": "删除",
"statusReordered": "已移动",
"restore": "恢复" "restore": "恢复"
} }

View File

@@ -1,6 +1,6 @@
/** /**
* File Change Store * 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. * File content is NOT stored here — only metadata and local disk paths.
* State is persisted so pending changes survive app restarts. * State is persisted so pending changes survive app restarts.
*/ */
@@ -29,6 +29,8 @@ export interface FileEntry {
interface AppFileState { interface AppFileState {
files: FileEntry[]; files: FileEntry[];
initialized: boolean; initialized: boolean;
/** Original order for each section: key is `${platform}:${fileType}`, value is ordered file IDs */
originalSectionOrders: Record<string, string[]>;
} }
interface FileChangeState { interface FileChangeState {
@@ -52,7 +54,7 @@ interface FileChangeState {
/** /**
* Mark a file for deletion, or remove an added file from the list. * Mark a file for deletion, or remove an added file from the list.
* - unchanged → deleted * - unchanged/reordered → deleted
* - added → removed from list entirely * - added → removed from list entirely
* - deleted → no-op * - deleted → no-op
*/ */
@@ -66,7 +68,7 @@ interface FileChangeState {
/** /**
* Reorder files within a specific (platform, fileType) section. * 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: ( reorderSection: (
domainId: string, domainId: string,
@@ -74,6 +76,7 @@ interface FileChangeState {
platform: "desktop" | "mobile", platform: "desktop" | "mobile",
fileType: "js" | "css", fileType: "js" | "css",
newOrder: string[], newOrder: string[],
draggedFileId: string
) => void; ) => void;
/** /**
@@ -93,11 +96,11 @@ interface FileChangeState {
fileType: "js" | "css", fileType: "js" | "css",
) => FileEntry[]; ) => FileEntry[];
/** Count of added and deleted files */ /** Count of added, deleted, and reordered files */
getChangeCount: ( getChangeCount: (
domainId: string, domainId: string,
appId: string, appId: string
) => { added: number; deleted: number }; ) => { added: number; deleted: number; reordered: number };
isInitialized: (domainId: string, appId: string) => boolean; isInitialized: (domainId: string, appId: string) => boolean;
} }
@@ -119,10 +122,20 @@ export const useFileChangeStore = create<FileChangeState>()(
status: "unchanged" as FileStatus, status: "unchanged" as FileStatus,
})); }));
// Build original section orders
const originalSectionOrders: Record<string, string[]> = {};
for (const f of entries) {
const sectionKey = `${f.platform}:${f.fileType}`;
if (!originalSectionOrders[sectionKey]) {
originalSectionOrders[sectionKey] = [];
}
originalSectionOrders[sectionKey].push(f.id);
}
set((state) => ({ set((state) => ({
appFiles: { appFiles: {
...state.appFiles, ...state.appFiles,
[key]: { files: entries, initialized: true }, [key]: { files: entries, initialized: true, originalSectionOrders },
}, },
})); }));
}, },
@@ -130,7 +143,11 @@ export const useFileChangeStore = create<FileChangeState>()(
addFile: (domainId, appId, entry) => { addFile: (domainId, appId, entry) => {
const key = appKey(domainId, appId); const key = appKey(domainId, appId);
set((state) => { set((state) => {
const existing = state.appFiles[key] ?? { files: [], initialized: true }; const existing = state.appFiles[key] ?? {
files: [],
initialized: true,
originalSectionOrders: {},
};
return { return {
appFiles: { appFiles: {
...state.appFiles, ...state.appFiles,
@@ -156,8 +173,8 @@ export const useFileChangeStore = create<FileChangeState>()(
if (file.status === "added") { if (file.status === "added") {
// Remove added files entirely // Remove added files entirely
updatedFiles = existing.files.filter((f) => f.id !== fileId); updatedFiles = existing.files.filter((f) => f.id !== fileId);
} else if (file.status === "unchanged") { } else if (file.status === "unchanged" || file.status === "reordered") {
// Mark unchanged files as deleted // Mark unchanged/reordered files as deleted
updatedFiles = existing.files.map((f) => updatedFiles = existing.files.map((f) =>
f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f, f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f,
); );
@@ -196,7 +213,7 @@ export const useFileChangeStore = create<FileChangeState>()(
}); });
}, },
reorderSection: (domainId, appId, platform, fileType, newOrder) => { reorderSection: (domainId, appId, platform, fileType, newOrder, draggedFileId) => {
const key = appKey(domainId, appId); const key = appKey(domainId, appId);
set((state) => { set((state) => {
const existing = state.appFiles[key]; const existing = state.appFiles[key];
@@ -221,11 +238,15 @@ export const useFileChangeStore = create<FileChangeState>()(
if (!newOrder.includes(f.id)) reordered.push(f); if (!newOrder.includes(f.id)) reordered.push(f);
} }
// Merge: maintain overall section order in the flat array // Mark the dragged file as "reordered" if it was "unchanged"
// Order: all non-section files first (in their original positions), const finalSectionFiles = reordered.map((f) => {
// but to maintain section integrity, rebuild in platform/fileType groups. if (f.id === draggedFileId && f.status === "unchanged") {
// Simple approach: replace section slice with reordered. return { ...f, status: "reordered" as FileStatus };
const finalFiles = [...otherFiles, ...reordered]; }
return f;
});
const finalFiles = [...otherFiles, ...finalSectionFiles];
return { return {
appFiles: { appFiles: {
@@ -241,7 +262,11 @@ export const useFileChangeStore = create<FileChangeState>()(
set((state) => ({ set((state) => ({
appFiles: { appFiles: {
...state.appFiles, ...state.appFiles,
[key]: { files: [], initialized: false }, [key]: {
files: [],
initialized: false,
originalSectionOrders: {},
},
}, },
})); }));
}, },
@@ -262,9 +287,11 @@ export const useFileChangeStore = create<FileChangeState>()(
getChangeCount: (domainId, appId) => { getChangeCount: (domainId, appId) => {
const key = appKey(domainId, appId); const key = appKey(domainId, appId);
const files = get().appFiles[key]?.files ?? []; const files = get().appFiles[key]?.files ?? [];
return { return {
added: files.filter((f) => f.status === "added").length, added: files.filter((f) => f.status === "added").length,
deleted: files.filter((f) => f.status === "deleted").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<FileChangeState>()(
}), }),
{ {
name: "file-change-storage", name: "file-change-storage",
}, }
), )
); );

View File

@@ -66,7 +66,7 @@ export interface DeployFile {
position: string; position: string;
} }
export type FileStatus = "unchanged" | "added" | "deleted"; export type FileStatus = "unchanged" | "added" | "deleted" | "reordered";
export interface DeployFileEntry { export interface DeployFileEntry {
id: string; id: string;