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/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",

View File

@@ -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",

View File

@@ -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 (
<div className={styles.container}>
@@ -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}`}
</Tag>
)}
</Button>

View File

@@ -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<FileItemProps> = ({
const { styles, cx } = useStyles();
const token = useTheme();
const statusColor = {
const statusColor: Record<string, string> = {
unchanged: "transparent",
added: token.colorSuccess,
deleted: token.colorError,
}[entry.status];
reordered: token.colorWarning,
};
return (
<SortableList.Item id={entry.id}>
<div className={styles.item}>
<div className={styles.fileInfo}>
<SortableList.DragHandle />
{entry.status !== "unchanged" && (
<div
className={styles.statusDot}
style={{ background: statusColor }}
/>
)}
<MaterialFileTypeIcon
type="file"
filename={`file.${entry.fileType}`}
size={16}
<div className={styles.item}>
<div className={styles.fileInfo}>
<SortableList.DragHandle />
{entry.status !== "unchanged" && (
<div
className={styles.statusDot}
style={{ background: statusColor[entry.status] }}
/>
<span
className={cx(
styles.fileName,
entry.status === "deleted" && styles.fileNameDeleted,
)}
)}
<MaterialFileTypeIcon
type="file"
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}
</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" }}
/>
)}
</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 && (
{t("restore")}
</Button>
) : (
<>
{(entry.status === "unchanged" || entry.status === "reordered") &&
onView &&
entry.fileKey && (
<Button
type="text"
size="small"
@@ -156,7 +165,9 @@ const FileItem: React.FC<FileItemProps> = ({
{t("view")}
</Button>
)}
{entry.status === "unchanged" && onDownload && entry.fileKey && (
{(entry.status === "unchanged" || entry.status === "reordered") &&
onDownload &&
entry.fileKey && (
<Button
type="text"
size="small"
@@ -167,19 +178,18 @@ const FileItem: React.FC<FileItemProps> = ({
{t("download", { ns: "common" })}
</Button>
)}
<Button
type="text"
size="small"
danger
icon={<Trash2 size={16} />}
onClick={onDelete}
/>
</>
)}
</Space>
</div>
</SortableList.Item>
<Button
type="text"
size="small"
danger
icon={<Trash2 size={16} />}
onClick={onDelete}
/>
</>
)}
</Space>
</div>
);
};
export default FileItem;
export default FileItem;

View File

@@ -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<FileSectionProps> = ({
@@ -123,7 +116,8 @@ const FileSection: React.FC<FileSectionProps> = ({
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<FileSectionProps> = ({
);
// ── 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<FileSectionProps> = ({
// ── Render item ────────────────────────────────────────────────────────────
const renderItem = useCallback(
(item: { id: string }) => {
const entry = files.find((f) => f.id === item.id);
if (!entry) return null;
(entry: FileEntry) => {
return (
<div className={styles.sortableItem}>
<FileItem
entry={entry}
onDelete={() => 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}
/>
</div>
<FileItem
entry={entry}
onDelete={() => 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<FileSectionProps> = ({
}}
>
{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}`}
</Tag>
)}
</div>
@@ -388,11 +378,10 @@ const FileSection: React.FC<FileSectionProps> = ({
{files.length === 0 ? (
<div className={styles.emptySection}>{t("noConfig")}</div>
) : (
<SortableList
<SortableFileList
items={files}
onReorder={handleReorder}
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",
"statusAdded": "New",
"statusDeleted": "Deleted",
"statusReordered": "Moved",
"restore": "Restore"
}

View File

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

View File

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

View File

@@ -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<string, string[]>;
}
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<FileChangeState>()(
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) => ({
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) => {
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<FileChangeState>()(
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<FileChangeState>()(
});
},
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<FileChangeState>()(
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<FileChangeState>()(
set((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) => {
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<FileChangeState>()(
}),
{
name: "file-change-storage",
},
),
}
)
);

View File

@@ -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;