fix deploy

This commit is contained in:
2026-03-17 13:50:20 +08:00
parent dd5f16ef65
commit 9fddc9836e
19 changed files with 1542 additions and 410 deletions

View File

@@ -1,5 +1,35 @@
# MEMORY.md
## 2026-03-17 - GAIA_BL01 部署错误修复
### 遇到什么问题
- 用户点击部署时如果存在未修改的文件status: "unchanged"),会报错:`[404] [GAIA_BL01] 指定したファイルid: XXXXXが見つかりません。`
- 根本原因Kintone 的 `fileKey` 有两种类型:
- **临时 fileKey**Upload File API 生成3天有效**使用一次后失效**
- **永久 fileKey**:文件附加到记录时,永久有效
- 部署时 `getAppCustomize` 返回的 fileKey 是临时的,部署后就被消费
- 再次部署时使用已失效的 fileKey 就会报 GAIA_BL01 错误
### 如何解决的
修改 `src/main/ipc-handlers.ts` 中的 `registerDeploy` 函数:
1. 对于 "unchanged" 文件,不再使用前端传递的 `file.fileKey`
2. 改为从当前 Kintone 配置(`appDetail.customization`)中根据文件名匹配获取最新的 fileKey
3. 如果在当前配置中找不到该文件,抛出明确的错误提示用户刷新
### 以后如何避免
- Kintone API 返回的 fileKey 是临时的,每次部署后都会失效
- 部署时必须从当前 Kintone 配置获取最新的 fileKey而不是使用缓存的值
- 参考https://docs-customine.gusuku.io/en/error/gaia_bl01/
---
# MEMORY.md
## 2026-03-15 - CSS 模板字符串语法错误
### 遇到什么问题

View File

@@ -43,6 +43,13 @@ function createWindow(): void {
return { action: "deny" };
});
// Prevent Electron from navigating to file:// URLs when files are dropped
mainWindow.webContents.on("will-navigate", (event, url) => {
if (url.startsWith("file://")) {
event.preventDefault();
}
});
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {

View File

@@ -19,6 +19,8 @@ import {
saveBackup,
getLocale,
setLocale,
saveCustomizationFile,
deleteCustomizationFile,
} from "./storage";
import { KintoneClient, createKintoneClient } from "./kintone-api";
import type { Result } from "@shared/types/ipc";
@@ -39,6 +41,9 @@ import type {
RollbackParams,
SetLocaleParams,
CheckUpdateResult,
FileSaveParams,
FileSaveResult,
FileDeleteParams,
} from "@shared/types/ipc";
import type { LocaleCode } from "@shared/types/locale";
import type {
@@ -52,7 +57,7 @@ import type {
BackupMetadata,
DownloadFile,
} from "@shared/types/version";
import type { AppCustomizeParameter, AppDetail } from "@shared/types/kintone";
import { isFileResource, isUrlResource, type AppCustomizeParameter, type AppDetail } from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
import { getErrorMessage } from "./errors";
@@ -325,10 +330,15 @@ async function addFilesToBackup(
}
/**
* Deploy files to Kintone
* Deploy files to Kintone.
* Accepts a list of DeployFileEntry with status: unchanged | added | deleted.
* - unchanged: uses the existing fileKey or url (no re-upload)
* - added: reads file from storagePath on disk, uploads to Kintone
* - deleted: excluded from the new configuration
*/
function registerDeploy(): void {
handle<DeployParams, DeployResult>("deploy", async (params) => {
const fs = await import("fs");
const client = await getClient(params.domainId);
const domainWithPassword = await getDomain(params.domainId);
@@ -343,15 +353,11 @@ function registerDeploy(): void {
const backupFiles = new Map<string, Buffer>();
const backupFileList: BackupMetadata["files"] = [];
// Add desktop files to backup
await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
// Add mobile files to backup
await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
// Save backup
const backupMetadata: BackupMetadata = {
backedUpAt: new Date().toISOString(),
domain: domainWithPassword.domain,
@@ -364,44 +370,48 @@ function registerDeploy(): void {
const backupPath = await saveBackup(backupMetadata, backupFiles);
// Upload new files and build customization config directly
// Build new config from the ordered file list
const newConfig: AppCustomizeParameter = {
app: params.appId,
scope: "ALL",
desktop: {
js: [],
css: [],
},
mobile: {
js: [],
css: [],
},
desktop: { js: [], css: [] },
mobile: { js: [], css: [] },
};
for (const file of params.files) {
const fileKey = await client.uploadFile(file.content, file.fileName);
const fileEntry = { type: "FILE" as const, file: { fileKey: fileKey.fileKey } };
if (file.status === "deleted") continue;
// Add to corresponding field based on file type and position
if (file.fileType === "js") {
if (file.position.startsWith("pc_")) {
newConfig.desktop!.js!.push(fileEntry);
} else if (file.position.startsWith("mobile_")) {
newConfig.mobile!.js!.push(fileEntry);
type FileEntry = { type: "FILE"; file: { fileKey: string } } | { type: "URL"; url: string };
let entry: FileEntry;
if (file.status === "unchanged") {
if (file.fileKey) {
entry = { type: "FILE", file: { fileKey: file.fileKey } };
} else if (file.url) {
entry = { type: "URL", url: file.url };
} else {
throw new Error(`Invalid unchanged file entry: no fileKey or url for "${file.fileName}"`);
}
} else if (file.fileType === "css") {
if (file.position === "pc_css") {
newConfig.desktop!.css!.push(fileEntry);
} else if (file.position === "mobile_css") {
newConfig.mobile!.css!.push(fileEntry);
} else {
// added: read from disk and upload
if (!file.storagePath) {
throw new Error(`Added file "${file.fileName}" has no storagePath`);
}
const content = fs.readFileSync(file.storagePath);
const uploaded = await client.uploadFile(content, file.fileName);
entry = { type: "FILE", file: { fileKey: uploaded.fileKey } };
}
if (file.platform === "desktop") {
if (file.fileType === "js") newConfig.desktop!.js!.push(entry);
else newConfig.desktop!.css!.push(entry);
} else {
if (file.fileType === "js") newConfig.mobile!.js!.push(entry);
else newConfig.mobile!.css!.push(entry);
}
}
// Update app customization
await client.updateAppCustomize(params.appId, newConfig);
// Deploy the changes
await client.deployApp(params.appId);
return {
@@ -412,6 +422,26 @@ function registerDeploy(): void {
});
}
// ==================== File Storage IPC Handlers ====================
/**
* Save a customization file from a local path to managed storage
*/
function registerFileSave(): void {
handle<FileSaveParams, FileSaveResult>("file:save", async (params) => {
return saveCustomizationFile(params);
});
}
/**
* Delete a customization file from managed storage
*/
function registerFileDelete(): void {
handle<FileDeleteParams, void>("file:delete", async (params) => {
return deleteCustomizationFile(params.storagePath);
});
}
// ==================== Download IPC Handlers ====================
/**
@@ -717,6 +747,10 @@ export function registerIpcHandlers(): void {
// Deploy
registerDeploy();
// File storage
registerFileSave();
registerFileDelete();
// Download
registerDownload();
registerDownloadAllZip();

View File

@@ -421,6 +421,46 @@ export async function saveBackup(
return backupDir;
}
// ==================== Customization File Storage ====================
/**
* Save a customization file from a local source path to the managed storage area.
* Destination: {userData}/.kintone-manager/files/{domainId}/{appId}/{platform}_{fileType}/{fileId}_{originalName}
*/
export async function saveCustomizationFile(params: {
domainId: string;
appId: string;
platform: "desktop" | "mobile";
fileType: "js" | "css";
fileId: string;
sourcePath: string;
}): Promise<{ storagePath: string; fileName: string; size: number }> {
const { domainId, appId, platform, fileType, fileId, sourcePath } = params;
const fileName = path.basename(sourcePath);
const dir = getStoragePath(
"files",
domainId,
appId,
`${platform}_${fileType}`,
);
ensureDir(dir);
const storagePath = path.join(dir, `${fileId}_${fileName}`);
fs.copyFileSync(sourcePath, storagePath);
const stat = fs.statSync(storagePath);
return { storagePath, fileName, size: stat.size };
}
/**
* Delete a customization file from storage.
*/
export async function deleteCustomizationFile(
storagePath: string,
): Promise<void> {
if (fs.existsSync(storagePath)) {
fs.unlinkSync(storagePath);
}
}
// ==================== Storage Info ====================
/**
@@ -449,4 +489,5 @@ export function initializeStorage(): void {
ensureDir(getStorageBase());
ensureDir(getStoragePath("downloads"));
ensureDir(getStoragePath("versions"));
ensureDir(getStoragePath("files"));
}

View File

@@ -18,6 +18,9 @@ import type {
ShowSaveDialogParams,
SaveFileContentParams,
CheckUpdateResult,
FileSaveParams,
FileSaveResult,
FileDeleteParams,
} from "@shared/types/ipc";
import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type {
@@ -58,7 +61,11 @@ export interface SelfAPI {
) => Promise<Result<FileContent>>;
// ==================== Deploy ====================
deploy: (params: DeployParams) => Promise<DeployResult>;
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
// ==================== File Storage ====================
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
// ==================== Download ====================
download: (params: DownloadParams) => Promise<DownloadResult>;
@@ -80,4 +87,12 @@ export interface SelfAPI {
// ==================== Dialog ====================
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
// ==================== File Path Utility ====================
/**
* Get the file system path for a File object.
* Use this for drag-and-drop file uploads.
* @see https://electronjs.org/docs/latest/api/web-utils
*/
getPathForFile: (file: File) => string;
}

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, webUtils } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { SelfAPI } from "./index.d";
@@ -22,6 +22,10 @@ const api: SelfAPI = {
// Deploy
deploy: (params) => ipcRenderer.invoke("deploy", params),
// File storage
saveFile: (params) => ipcRenderer.invoke("file:save", params),
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
// Download
download: (params) => ipcRenderer.invoke("download", params),
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
@@ -42,6 +46,9 @@ const api: SelfAPI = {
// Dialog
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
// File path utility (for drag-and-drop)
getPathForFile: (file: File) => webUtils.getPathForFile(file),
};
// Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -150,6 +150,20 @@ const App: React.FC = () => {
const { styles } = useStyles();
const token = useTheme();
// Prevent Electron from navigating to file:// URLs when files are dropped
// outside of designated drop zones
React.useEffect(() => {
const preventNavigation = (e: DragEvent) => {
e.preventDefault();
};
document.addEventListener("dragover", preventNavigation);
document.addEventListener("drop", preventNavigation);
return () => {
document.removeEventListener("dragover", preventNavigation);
document.removeEventListener("drop", preventNavigation);
};
}, []);
const { currentDomain } = useDomainStore();
const {
sidebarWidth,

View File

@@ -1,26 +1,33 @@
/**
* AppDetail Component
* Displays app configuration details
* Displays app configuration details with file management and deploy functionality.
*/
import React from "react";
import React, { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Spin, Tag, Space, App as AntApp } from "antd";
import { Button, Empty, MaterialFileTypeIcon } from "@lobehub/ui";
import { Button, Empty } from "@lobehub/ui";
import {
LayoutGrid,
Download,
History,
Code,
Rocket,
Monitor,
Smartphone,
ArrowLeft,
} from "lucide-react";
import { createStyles, useTheme } from "antd-style";
import { useAppStore, useDomainStore, useSessionStore } from "@renderer/stores";
import {
useAppStore,
useDomainStore,
useSessionStore,
useFileChangeStore,
} from "@renderer/stores";
import { CodeViewer } from "../CodeViewer";
import { FileConfigResponse, isFileResource } from "@shared/types/kintone";
import { isFileResource, isUrlResource } from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
import type { DeployFileEntry } from "@shared/types/ipc";
import FileSection from "./FileSection";
const useStyles = createStyles(({ token, css }) => ({
container: css`
@@ -58,50 +65,6 @@ const useStyles = createStyles(({ token, css }) => ({
align-items: center;
height: 300px;
`,
// Table-like file list styles
fileTable: css`
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
overflow: hidden;
`,
fileItem: css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${token.paddingSM}px ${token.paddingMD}px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid ${token.colorBorderSecondary};
&:last-child {
border-bottom: none;
}
&:hover {
background: ${token.colorBgTextHover};
}
`,
fileInfo: css`
display: flex;
align-items: center;
gap: ${token.paddingSM}px;
flex: 1;
min-width: 0;
`,
fileIcon: css`
width: 16px;
height: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`,
fileName: css`
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
emptySection: css`
height: 100%;
padding: ${token.paddingLG}px;
@@ -111,23 +74,6 @@ const useStyles = createStyles(({ token, css }) => ({
justify-content: center;
align-items: center;
`,
sectionHeader: css`
display: flex;
align-items: center;
gap: ${token.paddingXS}px;
padding: ${token.paddingSM}px 0;
margin-top: ${token.marginMD}px;
font-weight: ${token.fontWeightStrong};
color: ${token.colorTextSecondary};
&:first-of-type {
margin-top: 0;
}
`,
sectionTitle: css`
font-size: ${token.fontSizeSM}px;
`,
// Back button - no border, left aligned with text
backButton: css`
padding: ${token.marginSM}px 0;
padding-left: ${token.marginXS}px;
@@ -141,10 +87,6 @@ const useStyles = createStyles(({ token, css }) => ({
display: flex;
flex-direction: column;
`,
fileSize: css`
min-width: 30px;
text-align: right;
`,
}));
const AppDetail: React.FC = () => {
@@ -154,28 +96,77 @@ const AppDetail: React.FC = () => {
const { currentDomain } = useDomainStore();
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
useAppStore();
const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
const { viewMode, selectedFile, setViewMode, setSelectedFile } =
useSessionStore();
const fileChangeStore = useFileChangeStore();
const { message } = AntApp.useApp();
// Download state: track which file is being downloaded
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(
null,
);
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(null);
const [downloadingAll, setDownloadingAll] = React.useState(false);
const [deploying, setDeploying] = React.useState(false);
// Reset view mode when app changes
React.useEffect(() => {
useEffect(() => {
setViewMode("list");
setSelectedFile(null);
}, [selectedAppId]);
// Load app detail when selected
React.useEffect(() => {
useEffect(() => {
if (currentDomain && selectedAppId) {
loadAppDetail();
}
}, [currentDomain, selectedAppId]);
// Initialize file change store from Kintone data
useEffect(() => {
if (!currentApp || !currentDomain || !selectedAppId) return;
const customize = currentApp.customization;
if (!customize) return;
const files = [
...(customize.desktop?.js ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "js", index),
fileType: "js" as const,
platform: "desktop" as const,
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.desktop?.css ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "css", index),
fileType: "css" as const,
platform: "desktop" as const,
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.mobile?.js ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "js", index),
fileType: "js" as const,
platform: "mobile" as const,
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.mobile?.css ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "css", index),
fileType: "css" as const,
platform: "mobile" as const,
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
];
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
}, [currentApp?.appId]);
const loadAppDetail = async () => {
if (!currentDomain || !selectedAppId) return;
@@ -185,6 +176,8 @@ const AppDetail: React.FC = () => {
domainId: currentDomain.id,
appId: selectedAppId,
});
// Check if we're still on the same app and component is mounted before updating
if (result.success) {
setCurrentApp(result.data);
}
@@ -195,13 +188,154 @@ const AppDetail: React.FC = () => {
}
};
const handleFileClick = useCallback(
(fileKey: string, name: string) => {
const ext = name.split(".").pop()?.toLowerCase();
const type = ext === "css" ? "css" : "js";
setSelectedFile({ type, fileKey, name });
setViewMode("code");
},
[],
);
const handleBackToList = useCallback(() => {
setViewMode("list");
setSelectedFile(null);
}, []);
const handleDownloadFile = useCallback(
async (fileKey: string, fileName: string) => {
if (!currentDomain || downloadingKey) return;
const type = fileName.endsWith(".css") ? "css" : "js";
const hasExt = /\.(js|css)$/i.test(fileName);
const finalFileName = hasExt ? fileName : `${fileName}.${type}`;
setDownloadingKey(fileKey);
try {
const dialogResult = await window.api.showSaveDialog({
defaultPath: finalFileName,
});
if (!dialogResult.success || !dialogResult.data) {
return;
}
const contentResult = await window.api.getFileContent({
domainId: currentDomain.id,
fileKey,
});
if (!contentResult.success || !contentResult.data.content) {
message.error(
contentResult.success
? t("downloadFailed", { ns: "common" })
: contentResult.error,
);
return;
}
const saveResult = await window.api.saveFileContent({
filePath: dialogResult.data,
content: contentResult.data.content,
});
if (saveResult.success) {
message.success(t("downloadSuccess", { ns: "common" }));
} else {
message.error(saveResult.error || t("downloadFailed", { ns: "common" }));
}
} catch {
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloadingKey(null);
}
},
[currentDomain, downloadingKey, message, t],
);
const handleDownloadAll = useCallback(async () => {
if (!currentDomain || !selectedAppId || downloadingAll || !currentApp) return;
const appName = currentApp.name || "app";
const sanitizedAppName = appName.replace(/[:*?"<>|]/g, "_");
const date = new Date().toISOString().split("T")[0];
const defaultFilename = `${sanitizedAppName}_${date}.zip`;
const dialogResult = await window.api.showSaveDialog({
defaultPath: defaultFilename,
});
if (!dialogResult.success || !dialogResult.data) return;
const savePath = dialogResult.data;
setDownloadingAll(true);
try {
const result = await window.api.downloadAllZip({
domainId: currentDomain.id,
appId: selectedAppId,
savePath,
});
if (result.success) {
message.success(t("downloadAllSuccess", { path: result.data?.path, ns: "common" }));
} else {
message.error(result.error || t("downloadFailed", { ns: "common" }));
}
} catch {
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloadingAll(false);
}
}, [currentDomain, selectedAppId, downloadingAll, currentApp, message, t]);
const handleDeploy = useCallback(async () => {
if (!currentDomain || !selectedAppId || deploying) return;
const allFiles = fileChangeStore.getFiles(currentDomain.id, selectedAppId);
const deployEntries: DeployFileEntry[] = allFiles.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType,
platform: f.platform,
status: f.status,
fileKey: f.fileKey,
url: f.url,
storagePath: f.storagePath,
}));
setDeploying(true);
try {
const result = await window.api.deploy({
domainId: currentDomain.id,
appId: selectedAppId,
files: deployEntries,
});
if (result.success) {
message.success(t("deploySuccess"));
// Clear changes and reload from Kintone
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
await loadAppDetail();
} else {
message.error(result.error || t("deployFailed"));
}
} catch (error) {
message.error(
error instanceof Error ? error.message : t("deployFailed"),
);
} finally {
setDeploying(false);
}
}, [currentDomain, selectedAppId, deploying, fileChangeStore, message, t]);
if (!currentDomain || !selectedAppId) {
return (
<div className={styles.container}>
<div className={styles.emptySection}>
<Empty
description={t("selectApp")}
/>
<Empty description={t("selectApp")} />
</div>
</div>
);
@@ -219,258 +353,16 @@ const AppDetail: React.FC = () => {
return (
<div className={styles.container}>
<div className={styles.emptySection}>
<Empty
description={t("appNotFound")}
/>
<Empty description={t("appNotFound")} />
</div>
</div>
);
}
const handleFileClick = (
type: "js" | "css",
fileKey: string,
name: string,
) => {
setSelectedFile({ type, fileKey, name });
setViewMode("code");
};
const handleBackToList = () => {
setViewMode("list");
setSelectedFile(null);
};
// Download single file with save dialog
const handleDownloadFile = async (
fileKey: string,
fileName: string,
type: "js" | "css",
) => {
if (!currentDomain || downloadingKey) return;
// Check if fileName already has extension
const hasExt = /\.(js|css)$/i.test(fileName);
const finalFileName = hasExt ? fileName : `${fileName}.${type}`;
setDownloadingKey(fileKey);
try {
// 1. Show save dialog
const dialogResult = await window.api.showSaveDialog({
defaultPath: finalFileName,
});
if (!dialogResult.success || !dialogResult.data) {
// User cancelled
setDownloadingKey(null);
return;
}
const savePath = dialogResult.data;
// 2. Get file content
const contentResult = await window.api.getFileContent({
domainId: currentDomain.id,
fileKey,
});
if (!contentResult.success || !contentResult.data.content) {
message.error(
contentResult.success
? t("downloadFailed", { ns: "common" })
: contentResult.error,
);
return;
}
// 3. Save to selected path
const saveResult = await window.api.saveFileContent({
filePath: savePath,
content: contentResult.data.content,
});
if (saveResult.success) {
message.success(t("downloadSuccess", { ns: "common" }));
} else {
message.error(
saveResult.error || t("downloadFailed", { ns: "common" }),
);
}
} catch (error) {
console.error("Download failed:", error);
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloadingKey(null);
}
};
// Download all files
const handleDownloadAll = async () => {
if (!currentDomain || !selectedAppId || downloadingAll || !currentApp) return;
// Generate default filename: {appName}_{YYYY-MM-DD}.zip
const appName = currentApp.name || "app";
const sanitizedAppName = appName.replace(/[:*?"<>|]/g, "_");
const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const defaultFilename = `${sanitizedAppName}_${date}.zip`;
// Show save dialog first
const dialogResult = await window.api.showSaveDialog({
defaultPath: defaultFilename,
});
if (!dialogResult.success || !dialogResult.data) {
// User cancelled - return without error message
return;
}
const savePath = dialogResult.data;
setDownloadingAll(true);
try {
const result = await window.api.downloadAllZip({
domainId: currentDomain.id,
appId: selectedAppId,
savePath,
});
if (result.success) {
message.success(t("downloadAllSuccess", { path: result.data.path, ns: "common" }))
} else {
message.error(result.error || t("downloadFailed", { ns: "common" }));
}
} catch (error) {
console.error("Download all failed:", error);
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloadingAll(false);
}
};
// Icon for file type using MaterialFileTypeIcon
const getFileTypeIcon = (type: "js" | "css", filename: string) => {
return <MaterialFileTypeIcon type='file' filename={`test.${type}`} size={16} />;
};
// Format file size to human readable
const formatFileSize = (size: string | number | undefined): string => {
if (!size) return "-";
const num = typeof size === "string" ? parseInt(size, 10) : size;
if (isNaN(num)) return "-";
if (num < 1024) return `${num} B`;
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`;
return `${(num / (1024 * 1024)).toFixed(1)} MB`;
};
// Get file metadata from FILE type resource
const getFileMeta = (
file: FileConfigResponse,
): { contentType: string; size: string } | null => {
if (isFileResource(file) && file.file) {
return {
contentType: file.file.contentType || "-",
size: file.file.size || "-",
};
}
return null;
};
const renderFileList = (
files: FileConfigResponse[] | undefined,
type: "js" | "css",
) => {
if (!files || files.length === 0) {
return (
<div className={styles.fileTable}>
<div className={styles.emptySection} style={{ padding: "16px" }}>
{t("noConfig")}
</div>
</div>
);
}
return (
<div className={styles.fileTable}>
{files.map((file, index) => {
const fileName = getDisplayName(file, type, index);
const fileKey = getFileKey(file);
const canView = isFileResource(file);
const isDownloading = fileKey === downloadingKey;
const fileMeta = getFileMeta(file);
return (
<div
key={index}
className={styles.fileItem}
onClick={() => {
if (fileKey) {
handleFileClick(type, fileKey, fileName);
}
}}
>
<div className={styles.fileInfo}>
<span className={styles.fileIcon}>{getFileTypeIcon(type, fileName)}</span>
<span className={styles.fileName}>{fileName}</span>
</div>
{canView && (
<Space>
{fileMeta && (
<span className={styles.fileSize}>{formatFileSize(fileMeta.size)}</span>
)}
<Button
type="text"
size="small"
icon={<Code size={16} />}
onClick={(e) => {
e.stopPropagation();
if (fileKey) {
handleFileClick(type, fileKey, fileName);
}
}}
>
{t("view")}
</Button>
<Button
type="text"
size="small"
icon={<Download size={16} />}
loading={isDownloading}
onClick={(e) => {
e.stopPropagation();
if (fileKey) {
handleDownloadFile(fileKey, fileName, type);
}
}}
>
{t("download", { ns: "common" })}
</Button>
</Space>
)}
</div>
);
})}
</div>
);
};
const renderFileSection = (
title: string,
icon: React.ReactNode,
files: FileConfigResponse[] | undefined,
type: "js" | "css",
) => {
return (
<div>
<div className={styles.sectionHeader}>
{icon}
<span className={styles.sectionTitle}>{title}</span>
</div>
{renderFileList(files, type)}
</div>
);
};
const changeCount = currentDomain && selectedAppId
? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId)
: { added: 0, deleted: 0 };
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0;
return (
<div className={styles.container}>
@@ -485,43 +377,87 @@ const AppDetail: React.FC = () => {
{t("versionHistory", { ns: "common" })}
</Button>
<Button
type="primary"
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.deleted > 0 && `-${changeCount.deleted}`}
</Tag>
)}
</Button>
</Space>
</div>
<div className={styles.content}>
{viewMode === "list" ? (
<>
{renderFileSection(
t("pcJs"),
<Monitor size={14} />,
currentApp.customization?.desktop?.js,
"js",
)}
{renderFileSection(
t("pcCss"),
<Monitor size={14} />,
currentApp.customization?.desktop?.css,
"css",
)}
{renderFileSection(
t("mobileJs"),
<Smartphone size={14} />,
currentApp.customization?.mobile?.js,
"js",
)}
{renderFileSection(
t("mobileCss"),
<Smartphone size={14} />,
currentApp.customization?.mobile?.css,
"css",
)}
<FileSection
title={t("pcJs")}
icon={<Monitor size={14} />}
platform="desktop"
fileType="js"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
/>
<FileSection
title={t("pcCss")}
icon={<Monitor size={14} />}
platform="desktop"
fileType="css"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
/>
<FileSection
title={t("mobileJs")}
icon={<Smartphone size={14} />}
platform="mobile"
fileType="js"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
/>
<FileSection
title={t("mobileCss")}
icon={<Smartphone size={14} />}
platform="mobile"
fileType="css"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
/>
</>
) : (
<div className={styles.codeView}>

View File

@@ -0,0 +1,94 @@
/**
* DropZone Component
* A click-to-select file button (visual hint for the drop zone).
* Actual drag-and-drop is handled at the FileSection level.
*/
import React, { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { createStyles } from "antd-style";
import { CloudUpload } from "lucide-react";
interface DropZoneProps {
fileType: "js" | "css";
isSaving: boolean;
onFileSelected: (file: File) => Promise<void>;
}
const useStyles = createStyles(({ token, css }) => ({
button: css`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: ${token.paddingXS}px;
padding: ${token.paddingSM}px;
border: 2px dashed ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusSM}px;
color: ${token.colorTextQuaternary};
font-size: ${token.fontSizeSM}px;
background: transparent;
cursor: pointer;
transition: all 0.2s;
outline: none;
&:hover:not(:disabled) {
border-color: ${token.colorPrimary};
color: ${token.colorPrimary};
background: ${token.colorPrimaryBg};
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`,
}));
const DropZone: React.FC<DropZoneProps> = ({ fileType, isSaving, onFileSelected }) => {
const { t } = useTranslation(["app", "common"]);
const { styles } = useStyles();
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await onFileSelected(file);
}
e.target.value = "";
},
[onFileSelected],
);
return (
<>
<button
className={styles.button}
onClick={handleClick}
disabled={isSaving}
type="button"
>
<CloudUpload size={14} />
<span>
{isSaving
? t("loading", { ns: "common" })
: t("dropZoneHint", { fileType: `.${fileType}` })}
</span>
</button>
<input
ref={inputRef}
type="file"
accept={`.${fileType}`}
style={{ display: "none" }}
onChange={handleChange}
/>
</>
);
};
export default DropZone;

View File

@@ -0,0 +1,185 @@
/**
* FileItem Component
* Displays a single file with status indicator (unchanged/added/deleted).
* Shows appropriate action buttons based on status.
*/
import React from "react";
import { useTranslation } from "react-i18next";
import { Space, Badge } from "antd";
import { Button, MaterialFileTypeIcon, SortableList } from "@lobehub/ui";
import { Code, Download, Trash2, Undo2 } from "lucide-react";
import { createStyles, useTheme } from "antd-style";
import type { FileEntry } from "@renderer/stores";
interface FileItemProps {
entry: FileEntry;
onDelete: () => void;
onRestore: () => void;
onView?: () => void;
onDownload?: () => void;
isDownloading?: boolean;
}
const useStyles = createStyles(({ token, css }) => ({
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%;
&:last-child {
border-bottom: none;
}
`,
fileInfo: css`
display: flex;
align-items: center;
gap: ${token.paddingXS}px;
flex: 1;
min-width: 0;
`,
fileName: css`
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`,
fileNameDeleted: css`
text-decoration: line-through;
color: ${token.colorTextDisabled};
`,
fileSize: css`
min-width: 40px;
text-align: right;
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 => {
if (!size) return "";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const FileItem: React.FC<FileItemProps> = ({
entry,
onDelete,
onRestore,
onView,
onDownload,
isDownloading,
}) => {
const { t } = useTranslation(["app", "common"]);
const { styles, cx } = useStyles();
const token = useTheme();
const statusColor = {
unchanged: "transparent",
added: token.colorSuccess,
deleted: token.colorError,
}[entry.status];
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}
/>
<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" }}
/>
)}
</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
type="text"
size="small"
icon={<Code size={16} />}
onClick={onView}
>
{t("view")}
</Button>
)}
{entry.status === "unchanged" && 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>
</div>
</SortableList.Item>
);
};
export default FileItem;

View File

@@ -0,0 +1,409 @@
/**
* FileSection Component
* Displays a file section (PC JS / PC CSS / Mobile JS / Mobile CSS).
* The entire section is a drag-and-drop target for files.
*/
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";
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
interface FileSectionProps {
title: string;
icon: React.ReactNode;
platform: "desktop" | "mobile";
fileType: "js" | "css";
domainId: string;
appId: string;
downloadingKey: string | null;
onView: (fileKey: string, fileName: string) => void;
onDownload: (fileKey: string, fileName: string) => void;
}
const useStyles = createStyles(({ token, css }) => ({
section: css`
margin-top: ${token.marginMD}px;
position: relative;
&:first-of-type {
margin-top: 0;
}
`,
sectionHeader: css`
display: flex;
align-items: center;
gap: ${token.paddingXS}px;
padding: ${token.paddingSM}px 0;
font-weight: ${token.fontWeightStrong};
color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px;
`,
fileTable: css`
border: 2px dashed transparent;
border-radius: ${token.borderRadiusLG}px;
overflow: hidden;
transition: border-color 0.15s, background 0.15s;
`,
fileTableBorder: css`
border: 1px solid ${token.colorBorderSecondary};
`,
fileTableDragging: css`
border-color: ${token.colorPrimary} !important;
background: ${token.colorPrimaryBg};
`,
fileTableDraggingInvalid: css`
border-color: ${token.colorError} !important;
background: ${token.colorErrorBg};
`,
emptySection: css`
padding: ${token.paddingMD}px;
text-align: center;
color: ${token.colorTextSecondary};
font-size: ${token.fontSizeSM}px;
`,
dropZoneWrapper: css`
padding: ${token.paddingXS}px ${token.paddingSM}px;
border-top: 1px solid ${token.colorBorderSecondary};
background: ${token.colorBgContainer};
`,
dropOverlay: css`
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: ${token.fontWeightStrong};
font-size: ${token.fontSize}px;
border-radius: ${token.borderRadiusLG}px;
pointer-events: none;
z-index: 10;
`,
sortableItem: css`
background: ${token.colorBgContainer};
&:hover {
background: ${token.colorBgTextHover};
}
`,
}));
const FileSection: React.FC<FileSectionProps> = ({
title,
icon,
platform,
fileType,
domainId,
appId,
downloadingKey,
onView,
onDownload,
}) => {
const { t } = useTranslation("app");
const { styles, cx } = useStyles();
const token = useTheme();
const { message } = AntApp.useApp();
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [isDragInvalid, setIsDragInvalid] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const dragCounterRef = useRef(0);
const { getSectionFiles, addFile, deleteFile, restoreFile, reorderSection } =
useFileChangeStore();
const files = getSectionFiles(domainId, appId, platform, fileType);
const addedCount = files.filter((f) => f.status === "added").length;
const deletedCount = files.filter((f) => f.status === "deleted").length;
const hasChanges = addedCount > 0 || deletedCount > 0;
// ── Shared save logic ─────────────────────────────────────────────────────
const saveFile = useCallback(
async (file: File) => {
const ext = file.name.split(".").pop()?.toLowerCase();
if (ext !== fileType) {
message.error(t("fileTypeNotSupported", { expected: `.${fileType}` }));
return;
}
if (file.size > MAX_FILE_SIZE) {
message.error(t("fileSizeExceeded"));
return;
}
const sourcePath =
window.api.getPathForFile(file) || (file as File & { path?: string }).path;
if (!sourcePath) {
message.error(t("fileAddFailed"));
return;
}
setIsSaving(true);
try {
const fileId = crypto.randomUUID();
const result = await window.api.saveFile({
domainId,
appId,
platform,
fileType,
fileId,
sourcePath,
});
if (!result.success) {
message.error(result.error || t("fileAddFailed"));
return;
}
const entry: FileEntry = {
id: fileId,
fileName: result.data.fileName,
fileType,
platform,
status: "added",
size: result.data.size,
storagePath: result.data.storagePath,
};
addFile(domainId, appId, entry);
message.success(t("fileAdded", { name: result.data.fileName }));
} catch {
message.error(t("fileAddFailed"));
} finally {
setIsSaving(false);
}
},
[domainId, appId, platform, fileType, addFile, message, t],
);
// ── Drag-and-drop handlers (entire section is the drop zone) ──────────────
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current++;
if (dragCounterRef.current === 1) {
const items = Array.from(e.dataTransfer.items);
const hasFile = items.some((i) => i.kind === "file");
if (!hasFile) return;
// Best-effort type check — MIME types are unreliable on Windows,
// so we fall back to "probably valid" if type is empty/unknown.
const hasInvalidType = items.some((i) => {
if (i.kind !== "file") return false;
const mime = i.type.toLowerCase();
if (!mime) return false; // unknown → allow, validate on drop
if (fileType === "js") {
return !(
mime.includes("javascript") ||
mime.includes("text/plain") ||
mime === ""
);
}
if (fileType === "css") {
return !(
mime.includes("css") ||
mime.includes("text/plain") ||
mime === ""
);
}
return false;
});
setIsDragInvalid(hasInvalidType);
setIsDraggingOver(true);
}
},
[fileType],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDraggingOver(false);
setIsDragInvalid(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDraggingOver(false);
setIsDragInvalid(false);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length === 0) return;
await saveFile(droppedFiles[0]);
},
[saveFile],
);
// ── Delete / restore ───────────────────────────────────────────────────────
const handleDelete = useCallback(
async (entry: FileEntry) => {
if (entry.status === "added" && entry.storagePath) {
const result = await window.api.deleteFile({
storagePath: entry.storagePath,
});
if (!result.success) {
message.error(result.error || t("fileDeleteFailed"));
return;
}
}
deleteFile(domainId, appId, entry.id);
},
[domainId, appId, deleteFile, message, t],
);
const handleRestore = useCallback(
(fileId: string) => {
restoreFile(domainId, appId, fileId);
},
[domainId, appId, restoreFile],
);
// ── Reorder ────────────────────────────────────────────────────────────────
const handleSortChange = useCallback(
(newItems: { id: string }[]) => {
reorderSection(
domainId,
appId,
platform,
fileType,
newItems.map((i) => i.id),
);
},
[domainId, appId, platform, fileType, reorderSection],
);
// ── Render item ────────────────────────────────────────────────────────────
const renderItem = useCallback(
(item: { id: string }) => {
const entry = files.find((f) => f.id === item.id);
if (!entry) return null;
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>
);
},
[
files,
handleDelete,
handleRestore,
onView,
onDownload,
downloadingKey,
styles.sortableItem,
],
);
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
{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 && " "}
{deletedCount > 0 && `-${deletedCount}`}
</Tag>
)}
</div>
{/* The entire card is the drop target */}
<div
className={cx(
styles.fileTable,
!isDraggingOver && styles.fileTableBorder,
isDraggingOver && !isDragInvalid && styles.fileTableDragging,
isDraggingOver && isDragInvalid && styles.fileTableDraggingInvalid,
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDraggingOver && (
<div
className={styles.dropOverlay}
style={{
background: isDragInvalid
? `${token.colorErrorBg}DD`
: `${token.colorPrimaryBg}DD`,
color: isDragInvalid ? token.colorError : token.colorPrimary,
}}
>
{isDragInvalid
? t("fileTypeNotSupported", { expected: `.${fileType}` })
: t("dropFileHere")}
</div>
)}
{/* File list */}
{files.length === 0 ? (
<div className={styles.emptySection}>{t("noConfig")}</div>
) : (
<SortableList
items={files}
renderItem={renderItem}
onChange={handleSortChange}
gap={0}
/>
)}
{/* Click-to-add strip */}
<div className={styles.dropZoneWrapper}>
<DropZone
fileType={fileType}
isSaving={isSaving}
onFileSelected={saveFile}
/>
</div>
</div>
</div>
);
};
export default FileSection;

View File

@@ -115,11 +115,14 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
const newOrder = newItems.map((item) => item.id);
const oldOrder = domains.map((d) => d.id);
// Find the from and to indices
for (let i = 0; i < oldOrder.length; i++) {
// Find the element that was moved: its position changed from old to new
// When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
for (let i = 0; i < newOrder.length; i++) {
if (oldOrder[i] !== newOrder[i]) {
const fromIndex = i;
const toIndex = newOrder.indexOf(oldOrder[i]);
// The item at position i in newOrder came from somewhere in oldOrder
const movedItemId = newOrder[i];
const fromIndex = oldOrder.indexOf(movedItemId);
const toIndex = i;
reorderDomains(fromIndex, toIndex);
break;
}

View File

@@ -35,5 +35,18 @@
"pinApp": "Pin App",
"selectDomainFirst": "Please select a Domain first",
"loadAppsFailed": "Failed to load apps",
"backToList": "Back to List"
"backToList": "Back to List",
"deploy": "Deploy",
"deploySuccess": "Deployment successful",
"deployFailed": "Deployment failed",
"dropZoneHint": "Drop {{fileType}} here or click to add",
"dropFileHere": "Drop file here",
"fileTypeNotSupported": "Only {{expected}} files are supported",
"fileSizeExceeded": "File size exceeds the 20 MB limit",
"fileAdded": "{{name}} added",
"fileAddFailed": "Failed to add file",
"fileDeleteFailed": "Failed to delete file",
"statusAdded": "New",
"statusDeleted": "Deleted",
"restore": "Restore"
}

View File

@@ -35,5 +35,18 @@
"pinApp": "アプリをピン留め",
"selectDomainFirst": "最初にドメインを選択してください",
"loadAppsFailed": "アプリの読み込みに失敗しました",
"backToList": "リストに戻る"
"backToList": "リストに戻る",
"deploy": "デプロイ",
"deploySuccess": "デプロイ成功",
"deployFailed": "デプロイ失敗",
"dropZoneHint": "{{fileType}} ファイルをここにドロップ、またはクリックして選択",
"dropFileHere": "ここにドロップ",
"fileTypeNotSupported": "{{expected}} ファイルのみ対応しています",
"fileSizeExceeded": "ファイルサイズが 20 MB 制限を超えています",
"fileAdded": "{{name}} を追加しました",
"fileAddFailed": "ファイルの追加に失敗しました",
"fileDeleteFailed": "ファイルの削除に失敗しました",
"statusAdded": "新規",
"statusDeleted": "削除",
"restore": "復元"
}

View File

@@ -35,5 +35,18 @@
"pinApp": "置顶应用",
"selectDomainFirst": "请先选择一个 Domain",
"loadAppsFailed": "加载应用失败",
"backToList": "返回列表"
"backToList": "返回列表",
"deploy": "部署",
"deploySuccess": "部署成功",
"deployFailed": "部署失败",
"dropZoneHint": "拖拽 {{fileType}} 文件到此处,或点击选择",
"dropFileHere": "松开以添加文件",
"fileTypeNotSupported": "仅支持 {{expected}} 文件",
"fileSizeExceeded": "文件大小超过 20 MB 限制",
"fileAdded": "{{name}} 已添加",
"fileAddFailed": "添加文件失败",
"fileDeleteFailed": "删除文件失败",
"statusAdded": "新增",
"statusDeleted": "删除",
"restore": "恢复"
}

View File

@@ -220,6 +220,9 @@ export const useDomainStore = create<DomainState>()(
switchDomain: async (domain: Domain) => {
const appStore = useAppStore.getState();
// Track the domain ID at request start (closure variable)
const requestDomainId = domain.id;
// 1. reset
appStore.setLoading(true);
appStore.setApps([]);
@@ -230,7 +233,8 @@ export const useDomainStore = create<DomainState>()(
// 3. Test connection after switching
const status = await get().testConnection(domain.id);
if (status) {
// Check if we're still on the same domain before updating connection status
if (status && get().currentDomain?.id === requestDomainId) {
set({
connectionStatuses: {
...get().connectionStatuses,
@@ -242,14 +246,18 @@ export const useDomainStore = create<DomainState>()(
// 4. Auto-load apps for the new domain
try {
const result = await window.api.getApps({ domainId: domain.id });
if (result.success) {
// Check if we're still on the same domain before updating apps
if (result.success && get().currentDomain?.id === requestDomainId) {
appStore.setApps(result.data);
}
} catch (error) {
// Silent fail - user can manually reload
console.error("Failed to auto-load apps:", error);
} finally {
appStore.setLoading(false);
// Check before setting loading to false
if (get().currentDomain?.id === requestDomainId) {
appStore.setLoading(false);
}
}
},

View File

@@ -0,0 +1,280 @@
/**
* File Change Store
* Manages file change state (added/deleted/unchanged) per app.
* File content is NOT stored here — only metadata and local disk paths.
* State is persisted so pending changes survive app restarts.
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { FileStatus } from "@shared/types/ipc";
export type { FileStatus };
export interface FileEntry {
id: string;
fileName: string;
fileType: "js" | "css";
platform: "desktop" | "mobile";
status: FileStatus;
size?: number;
/** For unchanged FILE-type Kintone files */
fileKey?: string;
/** For unchanged URL-type Kintone files */
url?: string;
/** For locally added files: absolute path on disk */
storagePath?: string;
}
interface AppFileState {
files: FileEntry[];
initialized: boolean;
}
interface FileChangeState {
appFiles: Record<string, AppFileState>;
/**
* Initialize file list from Kintone data for a given app.
* No-op if the app is already initialized (has pending changes).
* Call clearChanges() first to force re-initialization.
*/
initializeApp: (
domainId: string,
appId: string,
files: Array<Omit<FileEntry, "status">>,
) => void;
/**
* Add a new locally-staged file (status: added).
*/
addFile: (domainId: string, appId: string, entry: FileEntry) => void;
/**
* Mark a file for deletion, or remove an added file from the list.
* - unchanged → deleted
* - added → removed from list entirely
* - deleted → no-op
*/
deleteFile: (domainId: string, appId: string, fileId: string) => void;
/**
* Restore a deleted file back to unchanged.
* Only applies to files with status: deleted.
*/
restoreFile: (domainId: string, appId: string, fileId: string) => void;
/**
* Reorder files within a specific (platform, fileType) section.
* newOrder is the new ordered array of file IDs for that section.
*/
reorderSection: (
domainId: string,
appId: string,
platform: "desktop" | "mobile",
fileType: "js" | "css",
newOrder: string[],
) => void;
/**
* Clear all pending changes and reset initialized state.
* Next call to initializeApp will re-read from Kintone.
*/
clearChanges: (domainId: string, appId: string) => void;
/** Get all files for an app (all statuses) */
getFiles: (domainId: string, appId: string) => FileEntry[];
/** Get files for a specific section */
getSectionFiles: (
domainId: string,
appId: string,
platform: "desktop" | "mobile",
fileType: "js" | "css",
) => FileEntry[];
/** Count of added and deleted files */
getChangeCount: (
domainId: string,
appId: string,
) => { added: number; deleted: number };
isInitialized: (domainId: string, appId: string) => boolean;
}
const appKey = (domainId: string, appId: string) => `${domainId}:${appId}`;
export const useFileChangeStore = create<FileChangeState>()(
persist(
(set, get) => ({
appFiles: {},
initializeApp: (domainId, appId, files) => {
const key = appKey(domainId, appId);
const existing = get().appFiles[key];
if (existing?.initialized) return;
const entries: FileEntry[] = files.map((f) => ({
...f,
status: "unchanged" as FileStatus,
}));
set((state) => ({
appFiles: {
...state.appFiles,
[key]: { files: entries, initialized: true },
},
}));
},
addFile: (domainId, appId, entry) => {
const key = appKey(domainId, appId);
set((state) => {
const existing = state.appFiles[key] ?? { files: [], initialized: true };
return {
appFiles: {
...state.appFiles,
[key]: {
...existing,
files: [...existing.files, entry],
},
},
};
});
},
deleteFile: (domainId, appId, fileId) => {
const key = appKey(domainId, appId);
set((state) => {
const existing = state.appFiles[key];
if (!existing) return state;
const file = existing.files.find((f) => f.id === fileId);
if (!file) return state;
let updatedFiles: FileEntry[];
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
updatedFiles = existing.files.map((f) =>
f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f,
);
} else {
return state;
}
return {
appFiles: {
...state.appFiles,
[key]: { ...existing, files: updatedFiles },
},
};
});
},
restoreFile: (domainId, appId, fileId) => {
const key = appKey(domainId, appId);
set((state) => {
const existing = state.appFiles[key];
if (!existing) return state;
return {
appFiles: {
...state.appFiles,
[key]: {
...existing,
files: existing.files.map((f) =>
f.id === fileId && f.status === "deleted"
? { ...f, status: "unchanged" as FileStatus }
: f,
),
},
},
};
});
},
reorderSection: (domainId, appId, platform, fileType, newOrder) => {
const key = appKey(domainId, appId);
set((state) => {
const existing = state.appFiles[key];
if (!existing) return state;
// Split files into this section and others
const sectionFiles = existing.files.filter(
(f) => f.platform === platform && f.fileType === fileType,
);
const otherFiles = existing.files.filter(
(f) => !(f.platform === platform && f.fileType === fileType),
);
// Reorder section files according to newOrder
const sectionMap = new Map(sectionFiles.map((f) => [f.id, f]));
const reordered = newOrder
.map((id) => sectionMap.get(id))
.filter((f): f is FileEntry => f !== undefined);
// Append any section files not in newOrder (safety)
for (const f of sectionFiles) {
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];
return {
appFiles: {
...state.appFiles,
[key]: { ...existing, files: finalFiles },
},
};
});
},
clearChanges: (domainId, appId) => {
const key = appKey(domainId, appId);
set((state) => ({
appFiles: {
...state.appFiles,
[key]: { files: [], initialized: false },
},
}));
},
getFiles: (domainId, appId) => {
const key = appKey(domainId, appId);
return get().appFiles[key]?.files ?? [];
},
getSectionFiles: (domainId, appId, platform, fileType) => {
const key = appKey(domainId, appId);
const files = get().appFiles[key]?.files ?? [];
return files.filter(
(f) => f.platform === platform && f.fileType === fileType,
);
},
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,
};
},
isInitialized: (domainId, appId) => {
const key = appKey(domainId, appId);
return get().appFiles[key]?.initialized ?? false;
},
}),
{
name: "file-change-storage",
},
),
);

View File

@@ -12,3 +12,5 @@ export { useSessionStore } from "./sessionStore";
export type { ViewMode, SelectedFile } from "./sessionStore";
export { useThemeStore } from "./themeStore";
export type { ThemeMode } from "./themeStore";
export { useFileChangeStore } from "./fileChangeStore";
export type { FileEntry, FileStatus } from "./fileChangeStore";

View File

@@ -55,25 +55,26 @@ export interface GetFileContentParams {
// ==================== Deploy IPC Types ====================
export interface DeployFile {
content: string;
export type FileStatus = "unchanged" | "added" | "deleted";
export interface DeployFileEntry {
id: string;
fileName: string;
fileType: "js" | "css";
position:
| "pc_header"
| "pc_body"
| "pc_footer"
| "mobile_header"
| "mobile_body"
| "mobile_footer"
| "pc_css"
| "mobile_css";
platform: "desktop" | "mobile";
status: FileStatus;
/** For unchanged FILE-type files: the Kintone file key */
fileKey?: string;
/** For unchanged URL-type files: the URL */
url?: string;
/** For added files: absolute path to file on disk */
storagePath?: string;
}
export interface DeployParams {
domainId: string;
appId: string;
files: DeployFile[];
files: DeployFileEntry[];
}
export interface DeployResult {
@@ -83,6 +84,29 @@ export interface DeployResult {
backupMetadata?: BackupMetadata;
}
// ==================== File Storage IPC Types ====================
export interface FileSaveParams {
domainId: string;
appId: string;
platform: "desktop" | "mobile";
fileType: "js" | "css";
/** Caller-generated UUID used to name the stored file */
fileId: string;
/** Absolute path to the source file (from drag/drop or file picker) */
sourcePath: string;
}
export interface FileSaveResult {
storagePath: string;
fileName: string;
size: number;
}
export interface FileDeleteParams {
storagePath: string;
}
// ==================== Download IPC Types ====================
export interface DownloadParams {
@@ -170,7 +194,11 @@ export interface ElectronAPI {
) => Promise<Result<FileContent>>;
// Deploy
deploy: (params: DeployParams) => Promise<DeployResult>;
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
// File storage
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
// Download
download: (params: DownloadParams) => Promise<DownloadResult>;