UI improve

This commit is contained in:
2026-03-18 09:21:23 +08:00
parent 47d3bd0124
commit c3a333e2ed
16 changed files with 330 additions and 137 deletions

View File

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

View File

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

View File

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

View File

@@ -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,27 +350,35 @@ 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}>
{t("deploy")}
{hasChanges && (
<Tag
color="white"
style={{
color: token.colorPrimary,
marginLeft: 4,
fontSize: token.fontSizeSM,
padding: "0 4px",
lineHeight: "16px",
}}
>
{changeCount.added > 0 && `+${changeCount.added}`}
{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>
<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
color="white"
style={{
color: token.colorPrimary,
marginLeft: 4,
fontSize: token.fontSizeSM,
padding: "0 4px",
lineHeight: "16px",
}}
>
{changeCount.added > 0 && `+${changeCount.added}`}
{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>
</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>

View File

@@ -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,57 +75,80 @@ 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.fileInfo}>
<SortableList.DragHandle />
{entry.status !== "unchanged" && <div className={styles.statusDot} style={{ background: statusColor[entry.status] }} />}
<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 className={styles.wrapper}>
<div className={styles.item} onClick={handleItemClick}>
<div className={styles.fileInfo}>
<div onClick={handleDragHandleClick}>
<SortableList.DragHandle />
</div>
<MaterialFileTypeIcon type="file" filename={`file.${entry.fileType}`} size={16} />
<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" }} />
)}
{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={(e) => handleButtonClick(e, onRestore)}>
{t("restore")}
</Button>
) : (
<>
{(entry.status === "unchanged" || entry.status === "reordered") && onDownload && entry.fileKey && (
<Tooltip title={t("downloadFile", { ns: "common" })}>
<Button type="text" size="small" icon={<Download size={16} />} loading={isDownloading} onClick={(e) => handleButtonClick(e, onDownload!)} />
</Tooltip>
)}
<Tooltip title={t("deleteFile", { ns: "common" })}>
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={(e) => handleButtonClick(e, onDelete)} />
</Tooltip>
</>
)}
</Space>
</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" || 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>
)}
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={onDelete} />
</>
)}
</Space>
{showDivider && <div className={styles.divider} />}
</div>
);
};

View File

@@ -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}`}
</Tag>
<>
{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>
);
};

View File

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

View File

@@ -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,46 +73,60 @@ 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 {
const result = await window.api.getFileContent({
domainId: currentDomain.id,
fileKey,
});
// 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 (including Japanese characters)
const base64 = result.data.content || "";
const binaryString = atob(base64);
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
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);
// Detect language from file name
if (fileName.endsWith(".css")) {
setLanguage("css");
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
setLanguage("js");
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 {
setLanguage(fileType);
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,
});
if (result.success) {
// Decode base64 content properly for UTF-8 (including Japanese characters)
const base64 = result.data.content || "";
const binaryString = atob(base64);
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
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 file content");
}
} else {
setError(result.error || "Failed to load file content");
setError("No file source available");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
@@ -119,6 +135,17 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType })
}
};
const detectLanguage = () => {
// Detect language from file name
if (fileName.endsWith(".css")) {
setLanguage("css");
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
setLanguage("js");
} else {
setLanguage(fileType);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(content);
message.success(t("copiedToClipboard"));

View File

@@ -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)"
}

View File

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

View File

@@ -48,5 +48,7 @@
"statusAdded": "新規",
"statusDeleted": "削除",
"statusReordered": "順序変更",
"restore": "復元"
"restore": "復元",
"fileLimitWarning": "このセクションには30以上のファイルがあります。デプロイ前にファイル数を減らしてください。",
"deployDisabledReason": "1つ以上のセクションがファイル制限30を超えています"
}

View File

@@ -36,5 +36,7 @@
"downloadFailed": "ダウンロード失敗",
"downloadAllSuccess": "ダウンロード先: {{path}}",
"refreshSuccess": "更新しました",
"refreshFailed": "更新に失敗しました"
"refreshFailed": "更新に失敗しました",
"downloadFile": "ファイルをダウンロード",
"deleteFile": "ファイルを削除"
}

View File

@@ -48,5 +48,7 @@
"statusAdded": "新增",
"statusDeleted": "删除",
"statusReordered": "已移动",
"restore": "恢复"
"restore": "恢复",
"fileLimitWarning": "此区块文件数超过30个请减少文件后再部署。",
"deployDisabledReason": "一个或多个区块超过文件数量限制30"
}

View File

@@ -36,5 +36,7 @@
"downloadFailed": "下载失败",
"downloadAllSuccess": "已下载到: {{path}}",
"refreshSuccess": "刷新成功",
"refreshFailed": "刷新失败"
"refreshFailed": "刷新失败",
"downloadFile": "下载文件",
"deleteFile": "删除文件"
}

View File

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

View File

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