feat(ui): refactor AppDetail layout and add download with save dialog

- Remove Tabs component, merge JS/CSS sections into single scrollable list
- Add sub-page navigation for code viewing with back button
- Put app name and buttons on same row to save vertical space
- Reduce DomainForm spacing (marginMD -> marginSM)
- Use FileCode/FileText icons to indicate JS/CSS file types
- Add save dialog for single file download with loading state
- Add IPC handlers: showSaveDialog, saveFileContent
- Fix duplicate file extension issue in download filenames
- Add i18n keys: backToList, downloadSuccess, downloadFailed, downloadAllSuccess
This commit is contained in:
2026-03-16 00:07:49 +08:00
parent 345f0b344c
commit 43820127f4
13 changed files with 377 additions and 136 deletions

View File

@@ -4,7 +4,7 @@
* Based on REQUIREMENTS.md:228-268 * Based on REQUIREMENTS.md:228-268
*/ */
import { ipcMain } from "electron"; import { ipcMain, dialog } from "electron";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { import {
saveDomain, saveDomain,
@@ -573,6 +573,39 @@ export function registerIpcHandlers(): void {
registerGetLocale(); registerGetLocale();
registerSetLocale(); registerSetLocale();
// Dialog
registerShowSaveDialog();
registerSaveFileContent();
console.log("IPC handlers registered"); console.log("IPC handlers registered");
} }
// ==================== Dialog IPC Handlers ====================
/**
* Show save dialog
*/
function registerShowSaveDialog(): void {
handle<{ defaultPath?: string }, string | null>("showSaveDialog", async (params) => {
const result = await dialog.showSaveDialog({
defaultPath: params.defaultPath,
filters: [
{ name: "JavaScript", extensions: ["js"] },
{ name: "CSS", extensions: ["css"] },
{ name: "All Files", extensions: ["*"] },
],
});
return result.filePath || null;
});
}
/**
* Save file content to disk
*/
function registerSaveFileContent(): void {
handle<{ filePath: string; content: string }, void>("saveFileContent", async (params) => {
const fs = await import("fs");
const buffer = Buffer.from(params.content, "base64");
await fs.promises.writeFile(params.filePath, buffer);
});
}

View File

@@ -14,6 +14,8 @@ import type {
GetVersionsParams, GetVersionsParams,
RollbackParams, RollbackParams,
SetLocaleParams, SetLocaleParams,
ShowSaveDialogParams,
SaveFileContentParams,
} from "@shared/types/ipc"; } from "@shared/types/ipc";
import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { import type {
@@ -68,4 +70,7 @@ export interface SelfAPI {
getLocale: () => Promise<Result<LocaleCode>>; getLocale: () => Promise<Result<LocaleCode>>;
setLocale: (params: SetLocaleParams) => Promise<Result<void>>; setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
// ==================== Dialog ====================
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
} }

View File

@@ -34,6 +34,10 @@ const api: SelfAPI = {
getLocale: () => ipcRenderer.invoke("getLocale"), getLocale: () => ipcRenderer.invoke("getLocale"),
setLocale: (params) => ipcRenderer.invoke("setLocale", params), setLocale: (params) => ipcRenderer.invoke("setLocale", params),
// Dialog
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
}; };
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -11,9 +11,10 @@ import {
ConfigProvider, ConfigProvider,
App as AntApp, App as AntApp,
Space, Space,
Modal
} from "antd"; } from "antd";
import { Button, Tooltip, Modal } from "@lobehub/ui"; import { Button, Tooltip } from "@lobehub/ui";
import { import {
Cloud, Cloud,
@@ -327,6 +328,7 @@ const App: React.FC = () => {
onCancel={() => setSettingsOpen(false)} onCancel={() => setSettingsOpen(false)}
footer={null} footer={null}
width={480} width={480}
mask={{ closable: false }}
> >
<Settings /> <Settings />
</Modal> </Modal>

View File

@@ -5,20 +5,18 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Spin, Tag, Space, message } from "antd";
Descriptions,
Tabs,
Spin,
Tag,
Space,
} from "antd";
import { Button, Empty } from "@lobehub/ui"; import { Button, Empty } from "@lobehub/ui";
import { import {
LayoutGrid, LayoutGrid,
Download, Download,
History, History,
Code, Code,
FileCode,
FileText, FileText,
Monitor,
Smartphone,
ArrowLeft,
} from "lucide-react"; } from "lucide-react";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useAppStore } from "@renderer/stores"; import { useAppStore } from "@renderer/stores";
@@ -26,6 +24,7 @@ import { useDomainStore } from "@renderer/stores";
import { CodeViewer } from "../CodeViewer"; import { CodeViewer } from "../CodeViewer";
import { FileConfigResponse, isFileResource } from "@shared/types/kintone"; import { FileConfigResponse, isFileResource } from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay"; import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
container: css` container: css`
height: 100%; height: 100%;
@@ -34,6 +33,9 @@ const useStyles = createStyles(({ token, css }) => ({
background: ${token.colorBgContainer}; background: ${token.colorBgContainer};
`, `,
header: css` header: css`
display: flex;
justify-content: space-between;
align-items: center;
padding: ${token.paddingMD}px ${token.paddingLG}px; padding: ${token.paddingMD}px ${token.paddingLG}px;
border-bottom: 1px solid ${token.colorBorderSecondary}; border-bottom: 1px solid ${token.colorBorderSecondary};
`, `,
@@ -41,7 +43,6 @@ const useStyles = createStyles(({ token, css }) => ({
display: flex; display: flex;
align-items: center; align-items: center;
gap: ${token.paddingSM}px; gap: ${token.paddingSM}px;
margin-bottom: ${token.marginSM}px;
`, `,
appName: css` appName: css`
font-size: ${token.fontSizeHeading5}px; font-size: ${token.fontSizeHeading5}px;
@@ -59,39 +60,84 @@ const useStyles = createStyles(({ token, css }) => ({
align-items: center; align-items: center;
height: 300px; height: 300px;
`, `,
// Table-like file list styles
fileTable: css`
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
overflow: hidden;
`,
fileItem: css` fileItem: css`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: ${token.paddingSM}px ${token.paddingMD}px; padding: ${token.paddingSM}px ${token.paddingMD}px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
margin-bottom: ${token.marginSM}px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: background 0.2s;
border-bottom: 1px solid ${token.colorBorderSecondary};
&:last-child {
border-bottom: none;
}
&:hover { &:hover {
border-color: ${token.colorPrimary}; background: ${token.colorBgTextHover};
background: ${token.colorPrimaryBg};
} }
`, `,
fileInfo: css` fileInfo: css`
display: flex; display: flex;
align-items: center; align-items: center;
gap: ${token.paddingSM}px; 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` fileName: css`
font-weight: 500; font-weight: 500;
`, overflow: hidden;
fileType: css` text-overflow: ellipsis;
font-size: ${token.fontSizeSM}px; white-space: nowrap;
color: ${token.colorTextSecondary};
`, `,
emptySection: css` emptySection: css`
padding: ${token.paddingLG}px; padding: ${token.paddingLG}px;
text-align: center; text-align: center;
color: ${token.colorTextSecondary}; color: ${token.colorTextSecondary};
`, `,
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: 0;
margin-bottom: ${token.marginMD}px;
display: flex;
align-items: center;
justify-content: flex-start;
`,
codeView: css`
height: 100%;
display: flex;
flex-direction: column;
`,
})); }));
const AppDetail: React.FC = () => { const AppDetail: React.FC = () => {
@@ -101,6 +147,26 @@ const AppDetail: React.FC = () => {
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
useAppStore(); useAppStore();
// View mode: 'list' shows file sections, 'code' shows code viewer
const [viewMode, setViewMode] = React.useState<"list" | "code">("list");
const [selectedFile, setSelectedFile] = React.useState<{
type: "js" | "css";
fileKey: string;
name: string;
} | null>(null);
// Download state: track which file is being downloaded
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(
null,
);
const [downloadingAll, setDownloadingAll] = React.useState(false);
// Reset view mode when app changes
React.useEffect(() => {
setViewMode("list");
setSelectedFile(null);
}, [selectedAppId]);
// Load app detail when selected // Load app detail when selected
React.useEffect(() => { React.useEffect(() => {
if (currentDomain && selectedAppId) { if (currentDomain && selectedAppId) {
@@ -127,13 +193,6 @@ const AppDetail: React.FC = () => {
} }
}; };
const [activeTab, setActiveTab] = React.useState("info");
const [selectedFile, setSelectedFile] = React.useState<{
type: "js" | "css";
fileKey?: string;
name: string;
} | null>(null);
if (!currentDomain || !selectedAppId) { if (!currentDomain || !selectedAppId) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -168,20 +227,139 @@ const AppDetail: React.FC = () => {
); );
} }
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) return;
setDownloadingAll(true);
try {
const result = await window.api.download({
domainId: currentDomain.id,
appId: selectedAppId,
});
if (result.success) {
message.success(t("downloadAllSuccess", { path: result.path }));
} 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: FileCode for JS, FileText for CSS
const getFileTypeIcon = (type: "js" | "css") => {
return type === "js" ? (
<FileCode size={16} style={{ color: "#f7df1e" }} />
) : (
<FileText size={16} style={{ color: "#264de4" }} />
);
};
const renderFileList = ( const renderFileList = (
files: (FileConfigResponse)[] | undefined, files: FileConfigResponse[] | undefined,
type: "js" | "css", type: "js" | "css",
) => { ) => {
if (!files || files.length === 0) { if (!files || files.length === 0) {
return <div className={styles.emptySection}>{t("noConfig")}</div>; return (
<div className={styles.fileTable}>
<div className={styles.emptySection} style={{ padding: "16px" }}>
{t("noConfig")}
</div>
</div>
);
} }
return ( return (
<div> <div className={styles.fileTable}>
{files.map((file, index) => { {files.map((file, index) => {
const fileName = getDisplayName(file, type, index); const fileName = getDisplayName(file, type, index);
const fileKey = getFileKey(file); const fileKey = getFileKey(file);
const canView = isFileResource(file); const canView = isFileResource(file);
const isDownloading = fileKey === downloadingKey;
return ( return (
<div <div
@@ -189,20 +367,13 @@ const AppDetail: React.FC = () => {
className={styles.fileItem} className={styles.fileItem}
onClick={() => { onClick={() => {
if (fileKey) { if (fileKey) {
setSelectedFile({ type, fileKey, name: fileName }); handleFileClick(type, fileKey, fileName);
setActiveTab("code");
} }
}} }}
> >
<div className={styles.fileInfo}> <div className={styles.fileInfo}>
<FileText size={16} /> <span className={styles.fileIcon}>{getFileTypeIcon(type)}</span>
<div> <span className={styles.fileName}>{fileName}</span>
<div className={styles.fileName}>{fileName}</div>
<div className={styles.fileType}>
{type.toUpperCase()} ·{" "}
{t("fileUpload")}
</div>
</div>
</div> </div>
{canView && ( {canView && (
<Space> <Space>
@@ -212,13 +383,25 @@ const AppDetail: React.FC = () => {
icon={<Code size={16} />} icon={<Code size={16} />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedFile({ type, fileKey, name: fileName }); if (fileKey) {
setActiveTab("code"); handleFileClick(type, fileKey, fileName);
}
}} }}
> >
{t("view")} {t("view")}
</Button> </Button>
<Button type="text" size="small" icon={<Download size={16} />}> <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" })} {t("download", { ns: "common" })}
</Button> </Button>
</Space> </Space>
@@ -230,6 +413,23 @@ const AppDetail: React.FC = () => {
); );
}; };
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>
);
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
@@ -239,96 +439,67 @@ const AppDetail: React.FC = () => {
<Tag color="blue">{currentApp.appId}</Tag> <Tag color="blue">{currentApp.appId}</Tag>
</div> </div>
<Space> <Space>
<Button icon={<History size={16} />}>{t("versionHistory", { ns: "common" })}</Button> <Button icon={<History size={16} />}>
<Button type="primary" icon={<Download size={16} />}> {t("versionHistory", { ns: "common" })}
</Button>
<Button
type="primary"
icon={<Download size={16} />}
loading={downloadingAll}
onClick={handleDownloadAll}
>
{t("downloadAll")} {t("downloadAll")}
</Button> </Button>
</Space> </Space>
</div> </div>
<div className={styles.content}> <div className={styles.content}>
<Tabs {viewMode === "list" ? (
activeKey={activeTab} <>
onChange={setActiveTab} {renderFileSection(
items={[ t("pcJs"),
{ <Monitor size={14} />,
key: "info", currentApp.customization?.desktop?.js,
label: t("basicInfo"), "js",
children: ( )}
<Descriptions column={2} bordered size="small"> {renderFileSection(
<Descriptions.Item label={t("appId")}> t("pcCss"),
{currentApp.appId} <Monitor size={14} />,
</Descriptions.Item> currentApp.customization?.desktop?.css,
<Descriptions.Item label={t("appCode")}> "css",
{currentApp.code || "-"} )}
</Descriptions.Item> {renderFileSection(
<Descriptions.Item label={t("createdAt")}> t("mobileJs"),
{currentApp.createdAt} <Smartphone size={14} />,
</Descriptions.Item> currentApp.customization?.mobile?.js,
<Descriptions.Item label={t("creator")}> "js",
{currentApp.creator?.name} )}
</Descriptions.Item> {renderFileSection(
<Descriptions.Item label={t("modifiedAt")}> t("mobileCss"),
{currentApp.modifiedAt} <Smartphone size={14} />,
</Descriptions.Item> currentApp.customization?.mobile?.css,
<Descriptions.Item label={t("modifier")}> "css",
{currentApp.modifier?.name} )}
</Descriptions.Item> </>
<Descriptions.Item label={t("spaceId")} span={2}> ) : (
{currentApp.spaceId || "-"} <div className={styles.codeView}>
</Descriptions.Item> <Button
</Descriptions> type="text"
), icon={<ArrowLeft size={16} />}
}, onClick={handleBackToList}
{ className={styles.backButton}
key: "pc-js", >
label: t("pcJs"), {t("backToList")}
children: renderFileList( </Button>
currentApp.customization?.desktop?.js, {selectedFile && (
"js", <CodeViewer
), fileKey={selectedFile.fileKey}
}, fileName={selectedFile.name}
{ fileType={selectedFile.type}
key: "pc-css", />
label: t("pcCss"), )}
children: renderFileList( </div>
currentApp.customization?.desktop?.css, )}
"css",
),
},
{
key: "mobile-js",
label: t("mobileJs"),
children: renderFileList(
currentApp.customization?.mobile?.js,
"js",
),
},
{
key: "mobile-css",
label: t("mobileCss"),
children: renderFileList(
currentApp.customization?.mobile?.css,
"css",
),
},
{
key: "code",
label: t("codeView"),
children: selectedFile && selectedFile.fileKey ? (
<CodeViewer
fileKey={selectedFile.fileKey}
fileName={selectedFile.name}
fileType={selectedFile.type}
/>
) : (
<div className={styles.emptySection}>
{t("selectFileToView")}
</div>
),
},
]}
/>
</div> </div>
</div> </div>
); );

View File

@@ -5,8 +5,8 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Form, Modal } from "@lobehub/ui"; import { Button, Form } from "@lobehub/ui";
import { Input, message } from "antd"; import { Input, message, Modal } from "antd";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc"; import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
@@ -14,7 +14,7 @@ import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
form: css` form: css`
.ant-form-item { .ant-form-item {
margin-bottom: ${token.marginMD}px; margin-bottom: ${token.marginSM}px;
} }
`, `,
passwordHint: css` passwordHint: css`

View File

@@ -34,5 +34,6 @@
"unpin": "Unpin", "unpin": "Unpin",
"pinApp": "Pin App", "pinApp": "Pin App",
"selectDomainFirst": "Please select a Domain first", "selectDomainFirst": "Please select a Domain first",
"loadAppsFailed": "Failed to load apps" "loadAppsFailed": "Failed to load apps",
"backToList": "Back to List"
} }

View File

@@ -32,5 +32,8 @@
"selectAll": "Select all", "selectAll": "Select all",
"deployFiles": "Deploy Files", "deployFiles": "Deploy Files",
"versionHistory": "Version History", "versionHistory": "Version History",
"settings": "Settings" "settings": "Settings",
"downloadSuccess": "Download successful",
"downloadFailed": "Download failed",
"downloadAllSuccess": "Downloaded to: {{path}}"
} }

View File

@@ -34,5 +34,6 @@
"unpin": "ピン留め解除", "unpin": "ピン留め解除",
"pinApp": "アプリをピン留め", "pinApp": "アプリをピン留め",
"selectDomainFirst": "最初にドメインを選択してください", "selectDomainFirst": "最初にドメインを選択してください",
"loadAppsFailed": "アプリの読み込みに失敗しました" "loadAppsFailed": "アプリの読み込みに失敗しました",
"backToList": "リストに戻る"
} }

View File

@@ -32,5 +32,8 @@
"selectAll": "すべて選択", "selectAll": "すべて選択",
"deployFiles": "ファイルをデプロイ", "deployFiles": "ファイルをデプロイ",
"versionHistory": "バージョン履歴", "versionHistory": "バージョン履歴",
"settings": "設定" "settings": "設定",
"downloadSuccess": "ダウンロード成功",
"downloadFailed": "ダウンロード失敗",
"downloadAllSuccess": "ダウンロード先: {{path}}"
} }

View File

@@ -34,5 +34,6 @@
"unpin": "取消置顶", "unpin": "取消置顶",
"pinApp": "置顶应用", "pinApp": "置顶应用",
"selectDomainFirst": "请先选择一个 Domain", "selectDomainFirst": "请先选择一个 Domain",
"loadAppsFailed": "加载应用失败" "loadAppsFailed": "加载应用失败",
"backToList": "返回列表"
} }

View File

@@ -32,5 +32,8 @@
"selectAll": "全选", "selectAll": "全选",
"deployFiles": "部署文件", "deployFiles": "部署文件",
"versionHistory": "版本历史", "versionHistory": "版本历史",
"settings": "设置" "settings": "设置",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"downloadAllSuccess": "已下载到: {{path}}"
} }

View File

@@ -116,6 +116,16 @@ export interface SetLocaleParams {
locale: import("./locale").LocaleCode; locale: import("./locale").LocaleCode;
} }
// ==================== Dialog IPC Types ====================
export interface ShowSaveDialogParams {
defaultPath?: string;
}
export interface SaveFileContentParams {
filePath: string;
content: string; // Base64 encoded
}
// ==================== IPC API Interface ==================== // ==================== IPC API Interface ====================
export interface ElectronAPI { export interface ElectronAPI {
@@ -146,4 +156,8 @@ export interface ElectronAPI {
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>; getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
deleteVersion: (id: string) => Promise<Result<void>>; deleteVersion: (id: string) => Promise<Result<void>>;
rollback: (params: RollbackParams) => Promise<DeployResult>; rollback: (params: RollbackParams) => Promise<DeployResult>;
// Dialog
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
} }