UI improve
This commit is contained in:
@@ -31,6 +31,8 @@ import type {
|
||||
GetAppsParams,
|
||||
GetAppDetailParams,
|
||||
GetFileContentParams,
|
||||
GetLocalFileContentParams,
|
||||
LocalFileContent,
|
||||
DeployParams,
|
||||
DeployResult,
|
||||
DownloadParams,
|
||||
@@ -288,7 +290,7 @@ function registerGetAppDetail(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file content
|
||||
* Get file content from Kintone
|
||||
*/
|
||||
function registerGetFileContent(): void {
|
||||
handle<GetFileContentParams, Awaited<ReturnType<KintoneClient["getFileContent"]>>>("getFileContent", async (params) => {
|
||||
@@ -297,6 +299,48 @@ function registerGetFileContent(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local file content from storage
|
||||
*/
|
||||
function registerGetLocalFileContent(): void {
|
||||
handle<GetLocalFileContentParams, LocalFileContent>("getLocalFileContent", async (params) => {
|
||||
const fs = await import("fs");
|
||||
const path = await import("path");
|
||||
|
||||
const { storagePath } = params;
|
||||
|
||||
// Read file content
|
||||
const buffer = fs.readFileSync(storagePath);
|
||||
|
||||
// Get file name
|
||||
const fileName = path.basename(storagePath);
|
||||
|
||||
// Get file size
|
||||
const stats = fs.statSync(storagePath);
|
||||
|
||||
// Determine MIME type from extension
|
||||
const ext = path.extname(storagePath).toLowerCase();
|
||||
let mimeType: string;
|
||||
switch (ext) {
|
||||
case ".js":
|
||||
mimeType = "application/javascript";
|
||||
break;
|
||||
case ".css":
|
||||
mimeType = "text/css";
|
||||
break;
|
||||
default:
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
|
||||
return {
|
||||
name: fileName,
|
||||
size: stats.size,
|
||||
mimeType,
|
||||
content: buffer.toString("base64"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Deploy IPC Handlers ====================
|
||||
|
||||
/**
|
||||
@@ -824,6 +868,7 @@ export function registerIpcHandlers(): void {
|
||||
registerGetApps();
|
||||
registerGetAppDetail();
|
||||
registerGetFileContent();
|
||||
registerGetLocalFileContent();
|
||||
|
||||
// Deploy
|
||||
registerDeploy();
|
||||
|
||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -7,6 +7,8 @@ import type {
|
||||
GetAppsParams,
|
||||
GetAppDetailParams,
|
||||
GetFileContentParams,
|
||||
GetLocalFileContentParams,
|
||||
LocalFileContent,
|
||||
DeployParams,
|
||||
DeployResult,
|
||||
DownloadParams,
|
||||
@@ -50,6 +52,7 @@ export interface SelfAPI {
|
||||
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
|
||||
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||
getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
|
||||
getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
|
||||
|
||||
// ==================== Deploy ====================
|
||||
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||
|
||||
@@ -18,6 +18,7 @@ const api: SelfAPI = {
|
||||
getApps: (params) => ipcRenderer.invoke("getApps", params),
|
||||
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
|
||||
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
|
||||
getLocalFileContent: (params) => ipcRenderer.invoke("getLocalFileContent", params),
|
||||
|
||||
// Deploy
|
||||
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Displays app configuration details with file management and deploy functionality.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spin, Tag, Space, App as AntApp } from "antd";
|
||||
import { Spin, Tag, Space, App as AntApp, Tooltip } from "antd";
|
||||
import { Button, Empty } from "@lobehub/ui";
|
||||
import { LayoutGrid, Download, History, Rocket, Monitor, Smartphone, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
@@ -89,6 +89,22 @@ const AppDetail: React.FC = () => {
|
||||
const [downloadingAll, setDownloadingAll] = React.useState(false);
|
||||
const [deploying, setDeploying] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [isAnySectionOverLimit, setIsAnySectionOverLimit] = useState(false);
|
||||
|
||||
// Track over-limit status from each FileSection using refs
|
||||
const overLimitSectionsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const handleOverLimitChange = useCallback(
|
||||
(sectionId: string) => (isOverLimit: boolean) => {
|
||||
if (isOverLimit) {
|
||||
overLimitSectionsRef.current.add(sectionId);
|
||||
} else {
|
||||
overLimitSectionsRef.current.delete(sectionId);
|
||||
}
|
||||
setIsAnySectionOverLimit(overLimitSectionsRef.current.size > 0);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset view mode when app changes
|
||||
useEffect(() => {
|
||||
@@ -140,10 +156,10 @@ const AppDetail: React.FC = () => {
|
||||
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
|
||||
}, [currentApp]);
|
||||
|
||||
const handleFileClick = useCallback((fileKey: string, name: string) => {
|
||||
const handleFileClick = useCallback((fileKey: string | undefined, name: string, storagePath?: string) => {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
const type = ext === "css" ? "css" : "js";
|
||||
setSelectedFile({ type, fileKey, name });
|
||||
setSelectedFile({ type, fileKey, name, storagePath });
|
||||
setViewMode("code");
|
||||
}, []);
|
||||
|
||||
@@ -334,7 +350,14 @@ const AppDetail: React.FC = () => {
|
||||
<Button icon={<Download size={16} />} loading={downloadingAll} onClick={handleDownloadAll}>
|
||||
{t("downloadAll")}
|
||||
</Button>
|
||||
<Button type="primary" icon={<Rocket size={16} />} loading={deploying} disabled={!hasChanges && !deploying} onClick={handleDeploy}>
|
||||
<Tooltip title={isAnySectionOverLimit ? t("deployDisabledReason") : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Rocket size={16} />}
|
||||
loading={deploying}
|
||||
disabled={(!hasChanges && !deploying) || isAnySectionOverLimit}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
{t("deploy")}
|
||||
{hasChanges && (
|
||||
<Tag
|
||||
@@ -355,6 +378,7 @@ const AppDetail: React.FC = () => {
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -371,6 +395,7 @@ const AppDetail: React.FC = () => {
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("desktop-js")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("pcCss")}
|
||||
@@ -382,6 +407,7 @@ const AppDetail: React.FC = () => {
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("desktop-css")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("mobileJs")}
|
||||
@@ -393,6 +419,7 @@ const AppDetail: React.FC = () => {
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("mobile-js")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("mobileCss")}
|
||||
@@ -404,6 +431,7 @@ const AppDetail: React.FC = () => {
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("mobile-css")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -411,7 +439,9 @@ const AppDetail: React.FC = () => {
|
||||
<Button type="text" icon={<ArrowLeft size={16} />} onClick={handleBackToList} className={styles.backButton}>
|
||||
{t("backToList")}
|
||||
</Button>
|
||||
{selectedFile && <CodeViewer fileKey={selectedFile.fileKey} fileName={selectedFile.name} fileType={selectedFile.type} />}
|
||||
{selectedFile && (
|
||||
<CodeViewer fileKey={selectedFile.fileKey} fileName={selectedFile.name} fileType={selectedFile.type} storagePath={selectedFile.storagePath} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Space, Badge } from "antd";
|
||||
import { Space, Badge, Tooltip } from "antd";
|
||||
import { Button, MaterialFileTypeIcon, SortableList } from "@lobehub/ui";
|
||||
import { Code, Download, Trash2, Undo2 } from "lucide-react";
|
||||
import { Download, Trash2, Undo2 } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import type { FileEntry } from "@renderer/stores";
|
||||
|
||||
@@ -19,21 +19,31 @@ interface FileItemProps {
|
||||
onView?: () => void;
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
showDivider?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
wrapper: css`
|
||||
width: 100%;
|
||||
`,
|
||||
item: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
&:hover {
|
||||
background-color: ${token.colorBgTextHover};
|
||||
}
|
||||
`,
|
||||
divider: css`
|
||||
height: 1px;
|
||||
background-color: ${token.colorBorder};
|
||||
margin: 0 ${token.paddingMD}px;
|
||||
`,
|
||||
fileInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -49,7 +59,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
`,
|
||||
fileNameDeleted: css`
|
||||
text-decoration: line-through;
|
||||
color: ${token.colorTextDisabled};
|
||||
`,
|
||||
fileSize: css`
|
||||
min-width: 40px;
|
||||
@@ -57,12 +66,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
statusDot: css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
const formatFileSize = (size: number | undefined): string => {
|
||||
@@ -72,26 +75,50 @@ const formatFileSize = (size: number | undefined): string => {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FileItem: React.FC<FileItemProps> = ({ entry, onDelete, onRestore, onView, onDownload, isDownloading }) => {
|
||||
const FileItem: React.FC<FileItemProps> = ({ entry, onDelete, onRestore, onView, onDownload, isDownloading, showDivider = true }) => {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const { styles, cx } = useStyles();
|
||||
const token = useTheme();
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
unchanged: "transparent",
|
||||
added: token.colorSuccess,
|
||||
deleted: token.colorError,
|
||||
reordered: token.colorWarning,
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
// Allow viewing if fileKey (Kintone file) OR storagePath (local file)
|
||||
if (onView && (entry.fileKey || entry.storagePath)) {
|
||||
onView();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragHandleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, handler: () => void) => {
|
||||
e.stopPropagation();
|
||||
handler();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.item} onClick={handleItemClick}>
|
||||
<div className={styles.fileInfo}>
|
||||
<div onClick={handleDragHandleClick}>
|
||||
<SortableList.DragHandle />
|
||||
{entry.status !== "unchanged" && <div className={styles.statusDot} style={{ background: statusColor[entry.status] }} />}
|
||||
</div>
|
||||
<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" }} />}
|
||||
<span
|
||||
className={cx(styles.fileName, entry.status === "deleted" && styles.fileNameDeleted)}
|
||||
style={entry.status !== "unchanged" ? { color: statusColor[entry.status] } : undefined}
|
||||
>
|
||||
{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" }} />
|
||||
)}
|
||||
@@ -104,26 +131,25 @@ const FileItem: React.FC<FileItemProps> = ({ entry, onDelete, onRestore, onView,
|
||||
{entry.size && <span className={styles.fileSize}>{formatFileSize(entry.size)}</span>}
|
||||
|
||||
{entry.status === "deleted" ? (
|
||||
<Button type="text" size="small" icon={<Undo2 size={16} />} onClick={onRestore}>
|
||||
<Button type="text" size="small" icon={<Undo2 size={16} />} onClick={(e) => handleButtonClick(e, onRestore)}>
|
||||
{t("restore")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{(entry.status === "unchanged" || entry.status === "reordered") && onView && entry.fileKey && (
|
||||
<Button type="text" size="small" icon={<Code size={16} />} onClick={onView}>
|
||||
{t("view")}
|
||||
</Button>
|
||||
)}
|
||||
{(entry.status === "unchanged" || entry.status === "reordered") && onDownload && entry.fileKey && (
|
||||
<Button type="text" size="small" icon={<Download size={16} />} loading={isDownloading} onClick={onDownload}>
|
||||
{t("download", { ns: "common" })}
|
||||
</Button>
|
||||
<Tooltip title={t("downloadFile", { ns: "common" })}>
|
||||
<Button type="text" size="small" icon={<Download size={16} />} loading={isDownloading} onClick={(e) => handleButtonClick(e, onDownload!)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={onDelete} />
|
||||
<Tooltip title={t("deleteFile", { ns: "common" })}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={(e) => handleButtonClick(e, onDelete)} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{showDivider && <div className={styles.divider} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* The entire section is a drag-and-drop target for files.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tag, App as AntApp } from "antd";
|
||||
import { Alert } from "@lobehub/ui";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import { useFileChangeStore } from "@renderer/stores";
|
||||
import type { FileEntry } from "@renderer/stores";
|
||||
@@ -24,8 +25,9 @@ interface FileSectionProps {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
downloadingKey: string | null;
|
||||
onView: (fileKey: string, fileName: string) => void;
|
||||
onView: (fileKey: string | undefined, fileName: string, storagePath?: string) => void;
|
||||
onDownload: (fileKey: string, fileName: string) => void;
|
||||
onOverLimitChange?: (isOverLimit: boolean) => void;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
@@ -90,7 +92,18 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
const FileSection: React.FC<FileSectionProps> = ({ title, icon, platform, fileType, domainId, appId, downloadingKey, onView, onDownload }) => {
|
||||
const FileSection: React.FC<FileSectionProps> = ({
|
||||
title,
|
||||
icon,
|
||||
platform,
|
||||
fileType,
|
||||
domainId,
|
||||
appId,
|
||||
downloadingKey,
|
||||
onView,
|
||||
onDownload,
|
||||
onOverLimitChange,
|
||||
}) => {
|
||||
const { t } = useTranslation("app");
|
||||
const { styles, cx } = useStyles();
|
||||
const token = useTheme();
|
||||
@@ -104,6 +117,14 @@ const FileSection: React.FC<FileSectionProps> = ({ title, icon, platform, fileTy
|
||||
const { getSectionFiles, addFile, deleteFile, restoreFile, reorderSection } = useFileChangeStore();
|
||||
|
||||
const files = getSectionFiles(domainId, appId, platform, fileType);
|
||||
// Count files excluding deleted ones for the 30-file limit
|
||||
const fileCount = files.filter((f) => f.status !== "deleted").length;
|
||||
const isOverLimit = fileCount > 30;
|
||||
|
||||
// Notify parent when over-limit state changes
|
||||
useEffect(() => {
|
||||
onOverLimitChange?.(isOverLimit);
|
||||
}, [isOverLimit, onOverLimitChange]);
|
||||
|
||||
const addedCount = files.filter((f) => f.status === "added").length;
|
||||
const deletedCount = files.filter((f) => f.status === "deleted").length;
|
||||
@@ -266,15 +287,20 @@ const FileSection: React.FC<FileSectionProps> = ({ title, icon, platform, fileTy
|
||||
|
||||
// ── Render item ────────────────────────────────────────────────────────────
|
||||
const renderItem = useCallback(
|
||||
(entry: FileEntry) => {
|
||||
(entry: FileEntry, index: number, totalCount: number) => {
|
||||
// Determine if file is viewable (has fileKey for Kintone files OR storagePath for local files)
|
||||
const isViewable = entry.fileKey || entry.storagePath;
|
||||
const isLast = index === totalCount - 1;
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
entry={entry}
|
||||
onDelete={() => handleDelete(entry)}
|
||||
onRestore={() => handleRestore(entry.id)}
|
||||
onView={entry.fileKey ? () => onView(entry.fileKey!, entry.fileName) : undefined}
|
||||
onView={isViewable ? () => onView(entry.fileKey, entry.fileName, entry.storagePath) : undefined}
|
||||
onDownload={entry.fileKey ? () => onDownload(entry.fileKey!, entry.fileName) : undefined}
|
||||
isDownloading={downloadingKey === entry.fileKey}
|
||||
showDivider={!isLast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -288,23 +314,29 @@ const FileSection: React.FC<FileSectionProps> = ({ title, icon, platform, fileTy
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{hasChanges && (
|
||||
<Tag
|
||||
color={token.colorPrimary}
|
||||
style={{
|
||||
fontSize: token.fontSizeSM,
|
||||
padding: "0 4px",
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
{addedCount > 0 && `+${addedCount}`}
|
||||
{addedCount > 0 && (deletedCount > 0 || reorderedCount > 0) && " "}
|
||||
{deletedCount > 0 && `-${deletedCount}`}
|
||||
{(addedCount > 0 || deletedCount > 0) && reorderedCount > 0 && " "}
|
||||
{reorderedCount > 0 && `~${reorderedCount}`}
|
||||
<>
|
||||
{addedCount > 0 && (
|
||||
<Tag color="success" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
+{addedCount}
|
||||
</Tag>
|
||||
)}
|
||||
{deletedCount > 0 && (
|
||||
<Tag color="error" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
-{deletedCount}
|
||||
</Tag>
|
||||
)}
|
||||
{reorderedCount > 0 && (
|
||||
<Tag color="warning" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
~{reorderedCount}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File limit warning */}
|
||||
{isOverLimit && <Alert type="warning" title={t("fileLimitWarning")} showIcon style={{ marginBottom: token.marginSM }} />}
|
||||
|
||||
{/* The entire card is the drop target */}
|
||||
<div
|
||||
className={cx(
|
||||
@@ -343,6 +375,9 @@ const FileSection: React.FC<FileSectionProps> = ({ title, icon, platform, fileTy
|
||||
<DropZone fileType={fileType} isSaving={isSaving} onFileSelected={saveFile} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File limit warning - bottom */}
|
||||
{isOverLimit && <Alert type="warning" title={t("fileLimitWarning")} showIcon style={{ marginTop: token.marginSM }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { FileEntry } from "@renderer/stores";
|
||||
interface SortableFileListProps {
|
||||
items: FileEntry[];
|
||||
onReorder: (newOrder: string[], draggedItemId: string) => void;
|
||||
renderItem: (entry: FileEntry) => React.ReactNode;
|
||||
renderItem: (entry: FileEntry, index: number, totalCount: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
const SortableFileList: React.FC<SortableFileListProps> = ({ items, onReorder, renderItem }) => {
|
||||
@@ -45,9 +45,9 @@ const SortableFileList: React.FC<SortableFileListProps> = ({ items, onReorder, r
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={undefined} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items.map((entry) => (
|
||||
{items.map((entry, index) => (
|
||||
<SortableList.Item key={entry.id} id={entry.id}>
|
||||
{renderItem(entry)}
|
||||
{renderItem(entry, index, items.length)}
|
||||
</SortableList.Item>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -51,12 +51,14 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
}));
|
||||
|
||||
interface CodeViewerProps {
|
||||
fileKey: string;
|
||||
fileKey?: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
/** For locally added files: absolute path on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType }) => {
|
||||
const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, storagePath }) => {
|
||||
const { t } = useTranslation("file");
|
||||
const { styles } = useStyles();
|
||||
const { appearance } = useTheme();
|
||||
@@ -71,18 +73,37 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType })
|
||||
|
||||
// Load file content
|
||||
React.useEffect(() => {
|
||||
if (currentDomain && fileKey) {
|
||||
if (currentDomain && (fileKey || storagePath)) {
|
||||
loadFileContent();
|
||||
}
|
||||
}, [currentDomain, fileKey]);
|
||||
}, [currentDomain, fileKey, storagePath]);
|
||||
|
||||
const loadFileContent = async () => {
|
||||
if (!currentDomain) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Prefer local file (storagePath) for added files
|
||||
if (storagePath) {
|
||||
const result = await window.api.getLocalFileContent({ storagePath });
|
||||
|
||||
if (result.success) {
|
||||
// Decode base64 content properly for UTF-8
|
||||
const base64 = result.data.content || "";
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoded = decoder.decode(bytes);
|
||||
setContent(decoded);
|
||||
detectLanguage();
|
||||
} else {
|
||||
setError(result.error || "Failed to load local file content");
|
||||
}
|
||||
} else if (currentDomain && fileKey) {
|
||||
// Load from Kintone
|
||||
const result = await window.api.getFileContent({
|
||||
domainId: currentDomain.id,
|
||||
fileKey,
|
||||
@@ -100,7 +121,21 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType })
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoded = decoder.decode(bytes);
|
||||
setContent(decoded);
|
||||
detectLanguage();
|
||||
} else {
|
||||
setError(result.error || "Failed to load file content");
|
||||
}
|
||||
} else {
|
||||
setError("No file source available");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detectLanguage = () => {
|
||||
// Detect language from file name
|
||||
if (fileName.endsWith(".css")) {
|
||||
setLanguage("css");
|
||||
@@ -109,14 +144,6 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType })
|
||||
} else {
|
||||
setLanguage(fileType);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Failed to load file content");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"statusAdded": "New",
|
||||
"statusDeleted": "Deleted",
|
||||
"statusReordered": "Moved",
|
||||
"restore": "Restore"
|
||||
"restore": "Restore",
|
||||
"fileLimitWarning": "This section has more than 30 files. Please reduce files before deploying.",
|
||||
"deployDisabledReason": "One or more sections exceed the file limit (30)"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadAllSuccess": "Downloaded to: {{path}}",
|
||||
"refreshSuccess": "Refreshed successfully",
|
||||
"refreshFailed": "Refresh failed"
|
||||
"refreshFailed": "Refresh failed",
|
||||
"downloadFile": "Download file",
|
||||
"deleteFile": "Delete file"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"statusAdded": "新規",
|
||||
"statusDeleted": "削除",
|
||||
"statusReordered": "順序変更",
|
||||
"restore": "復元"
|
||||
"restore": "復元",
|
||||
"fileLimitWarning": "このセクションには30以上のファイルがあります。デプロイ前にファイル数を減らしてください。",
|
||||
"deployDisabledReason": "1つ以上のセクションがファイル制限(30)を超えています"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
"downloadFailed": "ダウンロード失敗",
|
||||
"downloadAllSuccess": "ダウンロード先: {{path}}",
|
||||
"refreshSuccess": "更新しました",
|
||||
"refreshFailed": "更新に失敗しました"
|
||||
"refreshFailed": "更新に失敗しました",
|
||||
"downloadFile": "ファイルをダウンロード",
|
||||
"deleteFile": "ファイルを削除"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,7 @@
|
||||
"statusAdded": "新增",
|
||||
"statusDeleted": "删除",
|
||||
"statusReordered": "已移动",
|
||||
"restore": "恢复"
|
||||
"restore": "恢复",
|
||||
"fileLimitWarning": "此区块文件数超过30个,请减少文件后再部署。",
|
||||
"deployDisabledReason": "一个或多个区块超过文件数量限制(30)"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,7 @@
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadAllSuccess": "已下载到: {{path}}",
|
||||
"refreshSuccess": "刷新成功",
|
||||
"refreshFailed": "刷新失败"
|
||||
"refreshFailed": "刷新失败",
|
||||
"downloadFile": "下载文件",
|
||||
"deleteFile": "删除文件"
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ export type ViewMode = "list" | "code";
|
||||
|
||||
export interface SelectedFile {
|
||||
type: "js" | "css";
|
||||
fileKey: string;
|
||||
fileKey?: string;
|
||||
name: string;
|
||||
/** For locally added files: absolute path on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
|
||||
@@ -51,6 +51,17 @@ export interface GetFileContentParams {
|
||||
fileKey: string;
|
||||
}
|
||||
|
||||
export interface GetLocalFileContentParams {
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
export interface LocalFileContent {
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
content: string; // Base64 encoded
|
||||
}
|
||||
|
||||
// ==================== Deploy IPC Types ====================
|
||||
|
||||
/**
|
||||
@@ -197,6 +208,7 @@ export interface ElectronAPI {
|
||||
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
|
||||
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||
getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
|
||||
getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
|
||||
|
||||
// Deploy
|
||||
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||
|
||||
Reference in New Issue
Block a user