From 417597db591b0206ed54a0a37f445f0a6ae0e71c Mon Sep 17 00:00:00 2001 From: MuRong Date: Wed, 23 Sep 2026 06:51:07 +0800 Subject: [PATCH] feat(export): show completion notices with file location actions --- packages/app/src/App.test.tsx | 79 +++++++++++++++++++++++ packages/app/src/App.tsx | 66 +++++++++++++------ packages/shared/src/i18n/locales/de.ts | 4 ++ packages/shared/src/i18n/locales/en.ts | 4 ++ packages/shared/src/i18n/locales/es.ts | 4 ++ packages/shared/src/i18n/locales/fr.ts | 4 ++ packages/shared/src/i18n/locales/it.ts | 4 ++ packages/shared/src/i18n/locales/ja.ts | 4 ++ packages/shared/src/i18n/locales/ko.ts | 4 ++ packages/shared/src/i18n/locales/pt-BR.ts | 4 ++ packages/shared/src/i18n/locales/ru.ts | 4 ++ packages/shared/src/i18n/locales/types.ts | 4 ++ packages/shared/src/i18n/locales/zh-CN.ts | 4 ++ packages/shared/src/i18n/locales/zh-TW.ts | 4 ++ 14 files changed, 174 insertions(+), 19 deletions(-) diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 9de0ee82..f27127f0 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -58,6 +58,7 @@ import { mockedNotifyAppExportSettingsChanged, mockedNotifyAppLanguageChanged, mockedNotifyAppThemeChanged, + mockedOpenNativeContainingFolder, mockedOpenNativeMarkdownFileInNewWindow, mockedOpenNativeLocalImages, mockedOpenNativeLocalFiles, @@ -9427,6 +9428,84 @@ describe("Markra workspace", () => { await waitFor(() => expect(mockedInstallNativeApplicationMenu.mock.calls.at(-1)?.[3]).toEqual([])); }); + const exportCases = [ + ["exportHtml", mockedSaveNativeHtmlFile, "html"], + ["exportPdf", mockedSaveNativePdfFile, "pdf"], + ["exportMarkdown", mockedSaveNativeMarkdownBundleFile, "md"], + ["exportDocx", mockedSaveNativePandocFile, "docx"], + ["exportEpub", mockedSaveNativePandocFile, "epub"], + ["exportLatex", mockedSaveNativePandocFile, "tex"] + ] as const; + + async function prepareExportFeedback() { + const runtime = createDefaultAppRuntime(); + configureAppRuntime({ ...runtime, features: { ...runtime.features, markdownBundle: true } }); + mockOpenMarkdownFile({ content: "# Example export", name: "example.md", path: mockNativePath }); + renderApp(); + await waitFor(() => expect(mockedInstallNativeApplicationMenu).toHaveBeenCalledTimes(1)); + const handlers = mockedInstallNativeApplicationMenu.mock.calls[0]?.[0] as NativeMenuHandlers; + await act(async () => { await handlers.openDocument?.(); }); + return handlers; + } + + it.each(exportCases)("shows export feedback and reveals the saved file for %s", async (command, save, extension) => { + const file = { name: `renamed.${extension}`, path: `/mock-exports/renamed.${extension}` }; + save.mockResolvedValue(file); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers[command]?.(); }); + const button = await screen.findByRole("button", { name: "Show in folder" }); + expect(button.closest(".app-toast")).toHaveClass("app-toast-notice"); + expect(button.closest(".app-toast")).toHaveTextContent(file.name); + expect(mockedOpenNativeContainingFolder).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(mockedOpenNativeContainingFolder).toHaveBeenCalledWith(file.path)); + }); + + it.each(exportCases)("keeps cancelled export feedback silent for %s", async (command, save, _extension) => { + save.mockResolvedValue(null); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers[command]?.(); }); + await waitFor(() => expect(save).toHaveBeenCalled()); + expect(document.querySelector(".app-toast")).not.toBeInTheDocument(); + }); + + it.each(exportCases)("shows failed export feedback for %s", async (command, save, _extension) => { + save.mockRejectedValue(new Error("Synthetic export failure")); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers[command]?.(); }); + await waitFor(() => expect(document.querySelector(".app-toast")).toHaveTextContent(/Could not export/)); + expect(screen.queryByRole("button", { name: "Show in folder" })).not.toBeInTheDocument(); + }); + + it("waits for the export to finish before showing success feedback", async () => { + let finishExport!: (file: { name: string; path: string }) => unknown; + mockedSaveNativeHtmlFile.mockReturnValue(new Promise((resolve) => { finishExport = resolve; })); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers.exportHtml?.(); }); + await waitFor(() => expect(mockedSaveNativeHtmlFile).toHaveBeenCalledTimes(1)); + expect(document.querySelector(".app-toast")).not.toBeInTheDocument(); + await act(async () => { finishExport({ name: "finished.html", path: "/mock-exports/finished.html" }); }); + expect(await screen.findByRole("button", { name: "Show in folder" })).toBeInTheDocument(); + expect(mockedSaveNativeHtmlFile).toHaveBeenCalledTimes(1); + }); + + it("reports a folder-opening failure without reporting the export as failed", async () => { + mockedSaveNativeHtmlFile.mockResolvedValue({ name: "example.html", path: "/mock-exports/example.html" }); + mockedOpenNativeContainingFolder.mockRejectedValue(new Error("Synthetic folder failure")); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers.exportHtml?.(); }); + fireEvent.click(await screen.findByRole("button", { name: "Show in folder" })); + expect(await screen.findByText("Could not show the exported file in its folder.")).toBeInTheDocument(); + }); + + it.each(["web-print://example.pdf", "web-download://example.html"])("does not claim a local export completed for %s", async (path) => { + mockedSaveNativeHtmlFile.mockResolvedValue({ name: "example.html", path }); + const handlers = await prepareExportFeedback(); + await act(async () => { await handlers.exportHtml?.(); }); + await waitFor(() => expect(mockedSaveNativeHtmlFile).toHaveBeenCalled()); + expect(document.querySelector(".app-toast")).not.toBeInTheDocument(); + }); + it("exports the current markdown document as standalone HTML from the native menu", async () => { mockedGetStoredExportSettings.mockResolvedValue({ ...defaultExportSettings, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 1b1f132c..5fac9325 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -3881,6 +3881,39 @@ function WorkspaceApp() { title: context.name }); }, [exportFeatureEnabled, readCurrentMarkdownForDocument]); + const notifyExportSaved = useCallback((file: { name: string; path: string } | null) => { + if (!file) return; + // Browser download/print requests do not confirm that a local file was saved. + if (file.path.startsWith("web-download://") || file.path.startsWith("web-print://")) return; + + showAppToast({ + action: { + label: translate("app.exportShowInFolder"), + onClick: () => { + openNativeContainingFolder(file.path).catch(() => { + showAppToast({ + message: translate("app.exportRevealFailed"), + status: "error", + surface: "notice" + }); + }); + } + }, + description: file.name, + duration: 10000, + id: `document-export:${file.path}`, + message: translate("app.exportSucceeded"), + status: "success", + surface: "notice" + }); + }, [translate]); + const notifyExportFailed = useCallback(() => { + showAppToast({ + message: translate("app.exportFailed"), + status: "error", + surface: "notice" + }); + }, [translate]); const handleRenderedExport = useCallback((exported: RenderedMarkdownExport) => { if (!exportFeatureEnabled || exportSnapshot?.id !== exported.id) return; @@ -3900,23 +3933,14 @@ function WorkspaceApp() { }); const suggestedName = exportDocumentFileName(exported.title, exported.kind); - if (exported.kind === "html") { - saveNativeHtmlFile({ - contents, - suggestedName - }).catch(() => {}).finally(() => { + const saveFile = exported.kind === "html" ? saveNativeHtmlFile : saveNativePdfFile; + saveFile({ contents, suggestedName }) + .then(notifyExportSaved) + .catch(notifyExportFailed) + .finally(() => { clearExportSnapshot(exported.id); }); - return; - } - - saveNativePdfFile({ - contents, - suggestedName - }).catch(() => {}).finally(() => { - clearExportSnapshot(exported.id); - }); - }, [appLanguage.language, clearExportSnapshot, exportFeatureEnabled, exportSettings.settings, exportSnapshot?.id]); + }, [appLanguage.language, clearExportSnapshot, exportFeatureEnabled, exportSettings.settings, exportSnapshot?.id, notifyExportFailed, notifyExportSaved]); const exportHtmlDocument = useCallback(() => beginDocumentExport("html"), [beginDocumentExport]); const exportPdfDocument = useCallback(() => beginDocumentExport("pdf"), [beginDocumentExport]); const exportMarkdownDocument = useCallback(() => { @@ -3946,16 +3970,18 @@ function WorkspaceApp() { references, rootPath: fileTree.sourcePath ?? context.path, suggestedName: exportDocumentFileName(context.name, "markdown") - }).catch(() => { + }).then(notifyExportSaved).catch(() => { showAppToast({ message: translate("app.markdownExportFailed"), - status: "error" + status: "error", + surface: "notice" }); }); }, [ editorPreferences.preferences.clipboardImageFolder, fileTree.sourcePath, markdownBundleFeatureEnabled, + notifyExportSaved, readCurrentMarkdownForDocument, translate ]); @@ -3972,11 +3998,12 @@ function WorkspaceApp() { pandocArgs: exportSettings.settings.pandocArgs, pandocPath: exportSettings.settings.pandocPath, suggestedName: exportDocumentFileName(context.name, format) - }).catch((error: unknown) => { + }).then(notifyExportSaved).catch((error: unknown) => { if (!isPandocSetupError(error)) { showAppToast({ message: translate("app.pandocExportFailed"), - status: "error" + status: "error", + surface: "notice" }); return; } @@ -3996,6 +4023,7 @@ function WorkspaceApp() { exportSettings.settings.pandocArgs, exportSettings.settings.pandocPath, pandocFeatureEnabled, + notifyExportSaved, readCurrentMarkdownForDocument, translate ]); diff --git a/packages/shared/src/i18n/locales/de.ts b/packages/shared/src/i18n/locales/de.ts index 40af36d3..b41891bc 100644 --- a/packages/shared/src/i18n/locales/de.ts +++ b/packages/shared/src/i18n/locales/de.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Pandoc installieren", "app.setPandocPath": "Pandoc-Pfad festlegen", "app.cancelPandocSetup": "Abbrechen", + "app.exportSucceeded": "Export abgeschlossen", + "app.exportShowInFolder": "Im Ordner anzeigen", + "app.exportFailed": "Das Dokument konnte nicht exportiert werden.", + "app.exportRevealFailed": "Die exportierte Datei konnte nicht im Ordner angezeigt werden.", "app.pandocExportFailed": "Export mit Pandoc fehlgeschlagen.", "app.markdownExportFailed": "Markdown mit Anhängen konnte nicht exportiert werden.", "app.markdownExportRequiresSavedDocument": "Speichern Sie das Dokument, bevor Sie Markdown mit Anhängen exportieren.", diff --git a/packages/shared/src/i18n/locales/en.ts b/packages/shared/src/i18n/locales/en.ts index 51a662aa..0e7dc7f0 100644 --- a/packages/shared/src/i18n/locales/en.ts +++ b/packages/shared/src/i18n/locales/en.ts @@ -777,6 +777,10 @@ const messages: BaseLocaleMessages = { "app.installPandoc": "Install Pandoc", "app.setPandocPath": "Set Pandoc path", "app.cancelPandocSetup": "Cancel", + "app.exportSucceeded": "Export complete", + "app.exportShowInFolder": "Show in folder", + "app.exportFailed": "Could not export the document.", + "app.exportRevealFailed": "Could not show the exported file in its folder.", "app.pandocExportFailed": "Could not export with Pandoc.", "app.markdownExportFailed": "Could not export Markdown with attachments.", "app.markdownExportRequiresSavedDocument": "Save the document before exporting Markdown with attachments.", diff --git a/packages/shared/src/i18n/locales/es.ts b/packages/shared/src/i18n/locales/es.ts index 4e5a34a4..cc23bc64 100644 --- a/packages/shared/src/i18n/locales/es.ts +++ b/packages/shared/src/i18n/locales/es.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Instalar Pandoc", "app.setPandocPath": "Definir ruta de Pandoc", "app.cancelPandocSetup": "Cancelar", + "app.exportSucceeded": "Exportación completada", + "app.exportShowInFolder": "Mostrar en la carpeta", + "app.exportFailed": "No se pudo exportar el documento.", + "app.exportRevealFailed": "No se pudo mostrar el archivo exportado en su carpeta.", "app.pandocExportFailed": "No se pudo exportar con Pandoc.", "app.markdownExportFailed": "No se pudo exportar Markdown con archivos adjuntos.", "app.markdownExportRequiresSavedDocument": "Guarda el documento antes de exportar Markdown con archivos adjuntos.", diff --git a/packages/shared/src/i18n/locales/fr.ts b/packages/shared/src/i18n/locales/fr.ts index ee0acd06..5a5b0269 100644 --- a/packages/shared/src/i18n/locales/fr.ts +++ b/packages/shared/src/i18n/locales/fr.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Installer Pandoc", "app.setPandocPath": "Définir le chemin Pandoc", "app.cancelPandocSetup": "Annuler", + "app.exportSucceeded": "Export terminé", + "app.exportShowInFolder": "Afficher dans le dossier", + "app.exportFailed": "Impossible d’exporter le document.", + "app.exportRevealFailed": "Impossible d’afficher le fichier exporté dans son dossier.", "app.pandocExportFailed": "Impossible d’exporter avec Pandoc.", "app.markdownExportFailed": "Impossible d’exporter Markdown avec les pièces jointes.", "app.markdownExportRequiresSavedDocument": "Enregistrez le document avant d’exporter Markdown avec les pièces jointes.", diff --git a/packages/shared/src/i18n/locales/it.ts b/packages/shared/src/i18n/locales/it.ts index b2924df6..9c3f6374 100644 --- a/packages/shared/src/i18n/locales/it.ts +++ b/packages/shared/src/i18n/locales/it.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Installa Pandoc", "app.setPandocPath": "Imposta percorso Pandoc", "app.cancelPandocSetup": "Annulla", + "app.exportSucceeded": "Esportazione completata", + "app.exportShowInFolder": "Mostra nella cartella", + "app.exportFailed": "Impossibile esportare il documento.", + "app.exportRevealFailed": "Impossibile mostrare il file esportato nella sua cartella.", "app.pandocExportFailed": "Impossibile esportare con Pandoc.", "app.markdownExportFailed": "Impossibile esportare Markdown con allegati.", "app.markdownExportRequiresSavedDocument": "Salva il documento prima di esportare Markdown con allegati.", diff --git a/packages/shared/src/i18n/locales/ja.ts b/packages/shared/src/i18n/locales/ja.ts index 17ef6341..14f7b896 100644 --- a/packages/shared/src/i18n/locales/ja.ts +++ b/packages/shared/src/i18n/locales/ja.ts @@ -431,6 +431,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Pandoc をインストール", "app.setPandocPath": "Pandoc パスを設定", "app.cancelPandocSetup": "キャンセル", + "app.exportSucceeded": "書き出し完了", + "app.exportShowInFolder": "フォルダーに表示", + "app.exportFailed": "ドキュメントを書き出せませんでした。", + "app.exportRevealFailed": "書き出したファイルをフォルダーに表示できませんでした。", "app.pandocExportFailed": "Pandoc で書き出せませんでした。", "app.markdownExportFailed": "添付ファイル付き Markdown を書き出せませんでした。", "app.markdownExportRequiresSavedDocument": "添付ファイル付き Markdown を書き出す前に文書を保存してください。", diff --git a/packages/shared/src/i18n/locales/ko.ts b/packages/shared/src/i18n/locales/ko.ts index 54f3af0f..d6eceeed 100644 --- a/packages/shared/src/i18n/locales/ko.ts +++ b/packages/shared/src/i18n/locales/ko.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Pandoc 설치", "app.setPandocPath": "Pandoc 경로 설정", "app.cancelPandocSetup": "취소", + "app.exportSucceeded": "내보내기 완료", + "app.exportShowInFolder": "폴더에서 보기", + "app.exportFailed": "문서를 내보낼 수 없습니다.", + "app.exportRevealFailed": "내보낸 파일을 폴더에서 표시할 수 없습니다.", "app.pandocExportFailed": "Pandoc으로 내보낼 수 없습니다.", "app.markdownExportFailed": "첨부 파일과 함께 Markdown을 내보낼 수 없습니다.", "app.markdownExportRequiresSavedDocument": "첨부 파일과 함께 Markdown을 내보내기 전에 문서를 저장하세요.", diff --git a/packages/shared/src/i18n/locales/pt-BR.ts b/packages/shared/src/i18n/locales/pt-BR.ts index 2a06d22f..e22a939c 100644 --- a/packages/shared/src/i18n/locales/pt-BR.ts +++ b/packages/shared/src/i18n/locales/pt-BR.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Instalar Pandoc", "app.setPandocPath": "Definir caminho do Pandoc", "app.cancelPandocSetup": "Cancelar", + "app.exportSucceeded": "Exportação concluída", + "app.exportShowInFolder": "Mostrar na pasta", + "app.exportFailed": "Não foi possível exportar o documento.", + "app.exportRevealFailed": "Não foi possível mostrar o arquivo exportado na pasta.", "app.pandocExportFailed": "Não foi possível exportar com Pandoc.", "app.markdownExportFailed": "Não foi possível exportar Markdown com anexos.", "app.markdownExportRequiresSavedDocument": "Salve o documento antes de exportar Markdown com anexos.", diff --git a/packages/shared/src/i18n/locales/ru.ts b/packages/shared/src/i18n/locales/ru.ts index e74c7556..8ba030c9 100644 --- a/packages/shared/src/i18n/locales/ru.ts +++ b/packages/shared/src/i18n/locales/ru.ts @@ -422,6 +422,10 @@ const messages: LocaleMessages = { "app.installPandoc": "Установить Pandoc", "app.setPandocPath": "Задать путь к Pandoc", "app.cancelPandocSetup": "Отмена", + "app.exportSucceeded": "Экспорт завершён", + "app.exportShowInFolder": "Показать в папке", + "app.exportFailed": "Не удалось экспортировать документ.", + "app.exportRevealFailed": "Не удалось показать экспортированный файл в папке.", "app.pandocExportFailed": "Не удалось экспортировать через Pandoc.", "app.markdownExportFailed": "Не удалось экспортировать Markdown с вложениями.", "app.markdownExportRequiresSavedDocument": "Сохраните документ перед экспортом Markdown с вложениями.", diff --git a/packages/shared/src/i18n/locales/types.ts b/packages/shared/src/i18n/locales/types.ts index 825b786e..44f864e6 100644 --- a/packages/shared/src/i18n/locales/types.ts +++ b/packages/shared/src/i18n/locales/types.ts @@ -788,6 +788,10 @@ export type I18nKey = | "app.installPandoc" | "app.setPandocPath" | "app.cancelPandocSetup" + | "app.exportSucceeded" + | "app.exportShowInFolder" + | "app.exportFailed" + | "app.exportRevealFailed" | "app.pandocExportFailed" | "app.markdownExportFailed" | "app.markdownExportRequiresSavedDocument" diff --git a/packages/shared/src/i18n/locales/zh-CN.ts b/packages/shared/src/i18n/locales/zh-CN.ts index 42aa506d..cf41d200 100644 --- a/packages/shared/src/i18n/locales/zh-CN.ts +++ b/packages/shared/src/i18n/locales/zh-CN.ts @@ -777,6 +777,10 @@ const messages: LocaleMessages = { "app.installPandoc": "安装 Pandoc", "app.setPandocPath": "设置 Pandoc 路径", "app.cancelPandocSetup": "取消", + "app.exportSucceeded": "导出完成", + "app.exportShowInFolder": "在文件夹中显示", + "app.exportFailed": "无法导出文档。", + "app.exportRevealFailed": "无法在文件夹中显示已导出的文件。", "app.pandocExportFailed": "无法使用 Pandoc 导出。", "app.markdownExportFailed": "无法导出带附件的 Markdown。", "app.markdownExportRequiresSavedDocument": "请先保存文档,再导出带附件的 Markdown。", diff --git a/packages/shared/src/i18n/locales/zh-TW.ts b/packages/shared/src/i18n/locales/zh-TW.ts index fe8d5870..6f8280a0 100644 --- a/packages/shared/src/i18n/locales/zh-TW.ts +++ b/packages/shared/src/i18n/locales/zh-TW.ts @@ -431,6 +431,10 @@ const messages: LocaleMessages = { "app.installPandoc": "安裝 Pandoc", "app.setPandocPath": "設定 Pandoc 路徑", "app.cancelPandocSetup": "取消", + "app.exportSucceeded": "匯出完成", + "app.exportShowInFolder": "在資料夾中顯示", + "app.exportFailed": "無法匯出文件。", + "app.exportRevealFailed": "無法在資料夾中顯示已匯出的檔案。", "app.pandocExportFailed": "無法使用 Pandoc 匯出。", "app.markdownExportFailed": "無法匯出含附件的 Markdown。", "app.markdownExportRequiresSavedDocument": "請先儲存文件,再匯出含附件的 Markdown。",