feat(desktop): add system save dialog for markdown export (#11852)

- Add ShowSaveDialogParams/Result types to electron-client-ipc
- Implement handleShowSaveDialog in LocalFileCtr using Electron dialog API
- Add showSaveDialog method to localFileService
- Create desktopExportService for Desktop-specific export logic
- Use system file picker instead of hardcoded downloads path
- Show toast with actions (open file / show in folder) after export
- Add i18n keys for export dialog and actions
This commit is contained in:
Innei
2026-01-26 17:44:20 +08:00
committed by GitHub
parent fb42614e73
commit 8896c06b7f
8 changed files with 151 additions and 12 deletions

View File

@@ -15,11 +15,13 @@ import {
OpenLocalFileParams,
OpenLocalFolderParams,
RenameLocalFileResult,
ShowSaveDialogParams,
ShowSaveDialogResult,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
import { createPatch } from 'diff';
import { shell } from 'electron';
import { dialog, shell } from 'electron';
import fg from 'fast-glob';
import { Stats, constants } from 'node:fs';
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
@@ -78,6 +80,28 @@ export default class LocalFileCtr extends ControllerModule {
}
}
@IpcMethod()
async handleShowSaveDialog({
defaultPath,
filters,
title,
}: ShowSaveDialogParams): Promise<ShowSaveDialogResult> {
logger.debug('Showing save dialog:', { defaultPath, filters, title });
const result = await dialog.showSaveDialog({
defaultPath,
filters,
title,
});
logger.debug('Save dialog result:', { canceled: result.canceled, filePath: result.filePath });
return {
canceled: result.canceled,
filePath: result.filePath,
};
}
@IpcMethod()
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });

View File

@@ -84,6 +84,9 @@
"pageEditor.empty.importNotion": "Import from Notion",
"pageEditor.empty.title": "Select a page to get started",
"pageEditor.empty.uploadFiles": "Upload Files",
"pageEditor.exportActions.openFile": "Open",
"pageEditor.exportActions.showInFolder": "Show in Folder",
"pageEditor.exportDialogTitle": "Export Page",
"pageEditor.exportError": "Failed to export the page",
"pageEditor.exportSuccess": "Page exported successfully",
"pageEditor.linkCopied": "Link copied",

View File

@@ -84,6 +84,9 @@
"pageEditor.empty.importNotion": "从 Notion 导入",
"pageEditor.empty.title": "选择一个文稿以开始",
"pageEditor.empty.uploadFiles": "上传文件",
"pageEditor.exportActions.openFile": "打开",
"pageEditor.exportActions.showInFolder": "在文件夹中显示",
"pageEditor.exportDialogTitle": "导出页面",
"pageEditor.exportError": "页面导出失败",
"pageEditor.exportSuccess": "页面导出成功",
"pageEditor.linkCopied": "链接已复制",

View File

@@ -225,3 +225,30 @@ export interface EditLocalFileResult {
replacements: number;
success: boolean;
}
// Save Dialog types
export interface ShowSaveDialogParams {
/**
* Default file name
*/
defaultPath?: string;
/**
* File type filters
*/
filters?: { extensions: string[]; name: string }[];
/**
* Dialog title
*/
title?: string;
}
export interface ShowSaveDialogResult {
/**
* Whether the dialog was cancelled
*/
canceled: boolean;
/**
* The selected file path (undefined if cancelled)
*/
filePath?: string;
}

View File

@@ -1,3 +1,4 @@
import { isDesktop } from '@lobechat/const';
import { type DropdownItem, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { cssVar, useResponsive } from 'antd-style';
@@ -51,7 +52,7 @@ export const useMenu = (): { menuItems: any[] } => {
}
};
const handleExportMarkdown = () => {
const handleExportMarkdown = async () => {
const state = storeApi.getState();
const { editor, title } = state;
@@ -59,16 +60,26 @@ export const useMenu = (): { menuItems: any[] } => {
try {
const markdown = (editor.getDocument('markdown') as unknown as string) || '';
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'Untitled'}.md`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
message.success(t('pageEditor.exportSuccess'));
const fileName = `${title || 'Untitled'}.md`;
if (isDesktop) {
const { desktopExportService } = await import('@/services/electron/desktopExportService');
await desktopExportService.exportMarkdown({
content: markdown,
fileName,
});
} else {
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
message.success(t('pageEditor.exportSuccess'));
}
} catch (error) {
console.error('Failed to export markdown:', error);
message.error(t('pageEditor.exportError'));

View File

@@ -93,6 +93,9 @@ export default {
'pageEditor.empty.importNotion': 'Import from Notion',
'pageEditor.empty.title': 'Select a page to get started',
'pageEditor.empty.uploadFiles': 'Upload Files',
'pageEditor.exportActions.openFile': 'Open',
'pageEditor.exportActions.showInFolder': 'Show in Folder',
'pageEditor.exportDialogTitle': 'Export Page',
'pageEditor.exportError': 'Failed to export the page',
'pageEditor.exportSuccess': 'Page exported successfully',
'pageEditor.linkCopied': 'Link copied',

View File

@@ -0,0 +1,61 @@
import { toast } from '@lobehub/ui';
import i18next from 'i18next';
import { localFileService } from './localFileService';
export interface DesktopExportOptions {
content: string;
fileName: string;
}
export interface DesktopExportResult {
canceled: boolean;
filePath?: string;
}
class DesktopExportService {
async exportMarkdown(options: DesktopExportOptions): Promise<DesktopExportResult> {
const { content, fileName } = options;
const result = await localFileService.showSaveDialog({
defaultPath: fileName,
filters: [{ extensions: ['md'], name: 'Markdown' }],
title: i18next.t('pageEditor.exportDialogTitle', { ns: 'file' }),
});
if (result.canceled || !result.filePath) {
return { canceled: true };
}
await localFileService.writeFile({
content,
path: result.filePath,
});
this.showExportSuccessToast(result.filePath);
return { canceled: false, filePath: result.filePath };
}
private showExportSuccessToast(filePath: string) {
const t = i18next.t.bind(i18next);
toast.success({
actions: [
{
label: t('pageEditor.exportActions.showInFolder', { ns: 'file' }),
onClick: () => localFileService.openFileFolder(filePath),
variant: 'text',
},
{
label: t('pageEditor.exportActions.openFile', { ns: 'file' }),
onClick: () => localFileService.openLocalFile({ path: filePath }),
variant: 'primary',
},
],
title: t('pageEditor.exportSuccess', { ns: 'file' }),
});
}
}
export const desktopExportService = new DesktopExportService();

View File

@@ -22,6 +22,8 @@ import {
type RenameLocalFileParams,
type RunCommandParams,
type RunCommandResult,
type ShowSaveDialogParams,
type ShowSaveDialogResult,
type WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
@@ -91,6 +93,11 @@ class LocalFileService {
return ensureElectronIpc().localSystem.handleGlobFiles(params);
}
// Dialog
async showSaveDialog(params: ShowSaveDialogParams): Promise<ShowSaveDialogResult> {
return ensureElectronIpc().localSystem.handleShowSaveDialog(params);
}
// Helper methods
async openLocalFileOrFolder(path: string, isDirectory: boolean) {
if (isDirectory) {