From 803ba735caa5867183385745a1b8b4fd01b0f981 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Tue, 19 May 2026 16:33:51 +0200 Subject: [PATCH] Show toast when Python script saves output files to project GitOrigin-RevId: 9ca5201645953f86c3ac8e83f545dfbcdac2b35c --- .../web/frontend/extracted-translations.json | 3 + .../editor/python/python-output-pane.tsx | 10 +- .../editor/python/python-output-toasts.tsx | 48 +++++++ .../components/editor/python/python-runner.ts | 17 +++ .../ide-react/components/global-toasts.tsx | 2 + .../pages/editor/ide-redesign.scss | 4 + services/web/locales/en.json | 3 + .../unit/editor/python-runner.spec.ts | 131 ++++++++++++++++++ 8 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 05c256ca1d..74577ec123 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2380,6 +2380,8 @@ "write_faster_smarter_with_research_ready_ai": "", "x_changes_in": "", "x_changes_in_plural": "", + "x_files_saved_to_your_project": "", + "x_files_saved_to_your_project_plural": "", "x_libraries_accessed_in_this_project": "", "x_people_invited": "", "x_people_invited_plural": "", @@ -2389,6 +2391,7 @@ "x_price_per_month": "", "x_price_per_user": "", "x_price_per_year": "", + "x_saved_to_your_project": "", "year": "", "yearly": "", "yes_move_me_to_personal_plan": "", diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx index 36264e3ba5..7c77ff9a82 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx @@ -1,5 +1,6 @@ import { useMemo, useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' +import classNames from 'classnames' import OLButton from '@/shared/components/ol/ol-button' import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' import MaterialIcon from '@/shared/components/material-icon' @@ -33,7 +34,14 @@ export default function PythonOutputPane() {
-
+
{ if (status === 'running') { diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx new file mode 100644 index 0000000000..c8b766041f --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-output-toasts.tsx @@ -0,0 +1,48 @@ +import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts' +import { useTranslation } from 'react-i18next' + +const stripProjectPrefix = (path: string) => path.replace(/^\/project\/?/, '') + +const PythonFilesSavedToast = ({ paths }: { paths: string[] }) => { + const { t } = useTranslation() + if (paths.length === 1) { + return ( + + {t('x_saved_to_your_project', { + fileName: stripProjectPrefix(paths[0]), + })} + + ) + } + return ( + {t('x_files_saved_to_your_project', { count: paths.length })} + ) +} + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every(v => typeof v === 'string') + +const generators: GlobalToastGeneratorEntry[] = [ + { + key: 'python:files-saved', + generator: ({ paths }) => ({ + content: ( + + ), + type: 'success', + autoHide: true, + delay: 5000, + isDismissible: true, + }), + }, +] + +export default generators + +export const showPythonFilesSavedToast = (paths: string[]) => { + window.dispatchEvent( + new CustomEvent('ide:show-toast', { + detail: { key: 'python:files-saved', paths }, + }) + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts b/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts index f1fab4f0a9..81fe7c042b 100644 --- a/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts +++ b/services/web/frontend/js/features/ide-react/components/editor/python/python-runner.ts @@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid' import { debugConsole } from '@/utils/debugging' import { sendMB } from '@/infrastructure/event-tracking' import { PyodideWorkerClient } from './pyodide-worker-client' +import { showPythonFilesSavedToast } from './python-output-toasts' import type { OutputStream } from './pyodide-worker-messages' import type { BatchUploadItem, @@ -15,6 +16,11 @@ import type { export type FileUploader = (items: BatchUploadItem[]) => Promise const MAX_OUTPUT_LINES = 100 +const PROJECT_FS_PREFIX = '/project/' + +function stripProjectFsPrefix(p: string): string { + return p.startsWith(PROJECT_FS_PREFIX) ? p.slice(PROJECT_FS_PREFIX.length) : p +} export type ExecutionStatus = | 'loading' @@ -145,6 +151,17 @@ export class PythonRunner { filesWrittenExtensions: collectExtensions(event.outputs), }) + // event.outputs are full worker paths (/project/foo.txt) while + // event.failedUploads are relativePaths (foo.txt); strip the + // prefix before comparing. + const failed = new Set(event.failedUploads) + const uploadedPaths = event.outputs + .map(stripProjectFsPrefix) + .filter(p => !failed.has(p)) + if (uploadedPaths.length > 0) { + showPythonFilesSavedToast(uploadedPaths) + } + this.updateState({ status: 'finished' }) } } diff --git a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx index 8e799d2a18..5d37e6a04a 100644 --- a/services/web/frontend/js/features/ide-react/components/global-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/global-toasts.tsx @@ -8,6 +8,7 @@ import { OLToastContainer } from '@/shared/components/ol/ol-toast-container' import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts' import importDocumentFeedbackToastGenerators from '@/features/project-list/components/new-project-button/import-document-feedback-toast' import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts' +import pythonOutputToastGenerators from '@/features/ide-react/components/editor/python/python-output-toasts' const moduleGeneratorsImport = importOverleafModules('toastGenerators') as { import: { default: GlobalToastGeneratorEntry[] } @@ -31,6 +32,7 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [ ...clipboardToastGenerators, ...importDocumentFeedbackToastGenerators, ...exportDocumentToastGenerators, + ...pythonOutputToastGenerators, ] const GENERATOR_MAP: Map = new Map( GENERATOR_LIST.map(({ key, generator }) => [key, generator]) diff --git a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss index 805cdc6552..ec531352a5 100644 --- a/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss +++ b/services/web/frontend/stylesheets/pages/editor/ide-redesign.scss @@ -90,6 +90,10 @@ border-radius: var(--ds-border-radius-300); margin-left: var(--spacing-02); + &.compile-button-group-running { + background-color: var(--bg-danger-01); + } + .btn-primary:hover { z-index: auto; } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 6e12bcd44e..d7ce401058 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -3039,6 +3039,8 @@ "writefull": "Writefull", "x_changes_in": "__count__ change in", "x_changes_in_plural": "__count__ changes in", + "x_files_saved_to_your_project": "__count__ file saved to your project", + "x_files_saved_to_your_project_plural": "__count__ files saved to your project", "x_libraries_accessed_in_this_project": "__provider__ libraries accessed in this project", "x_people_invited": "__count__ person invited", "x_people_invited_plural": "__count__ people invited", @@ -3048,6 +3050,7 @@ "x_price_per_month": "__price__ per month", "x_price_per_user": "__price__ per user", "x_price_per_year": "__price__ per year", + "x_saved_to_your_project": "__fileName__ saved to your project", "year": "year", "yearly": "Yearly", "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan", diff --git a/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts b/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts index 4fe411c5d1..26e8fd0bb3 100644 --- a/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts +++ b/services/web/test/frontend/features/ide-react/unit/editor/python-runner.spec.ts @@ -203,6 +203,137 @@ describe('PythonRunner', function () { }) }) + describe('files-saved toast', function () { + let toastEvents: CustomEvent[] + let toastListener: (event: Event) => void + + beforeEach(function () { + toastEvents = [] + toastListener = event => { + toastEvents.push(event as CustomEvent) + } + window.addEventListener('ide:show-toast', toastListener) + }) + + afterEach(function () { + window.removeEventListener('ide:show-toast', toastListener) + }) + + it('dispatches a files-saved toast with successfully uploaded paths', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + success: true, + outputs: ['/project/foo.txt', '/project/bar.csv'], + outputFiles: [], + imports: [], + failedUploads: [], + }) + + await waitForState(runner, s => s.status === 'finished') + + expect(toastEvents).to.have.length(1) + expect(toastEvents[0].detail).to.deep.equal({ + key: 'python:files-saved', + paths: ['foo.txt', 'bar.csv'], + }) + }) + + it('excludes failed uploads from the toast', async function () { + const fileUploader = sinon.stub().resolves([ + { status: 'success', name: 'foo.txt', relativePath: 'foo.txt' }, + { + status: 'error', + name: 'bar.csv', + relativePath: 'bar.csv', + error: 'boom', + }, + ]) + const runner = createRunner({ fileUploader }) + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + success: true, + outputs: ['/project/foo.txt', '/project/bar.csv'], + outputFiles: [ + { relativePath: 'foo.txt', content: new Uint8Array() }, + { relativePath: 'bar.csv', content: new Uint8Array() }, + ], + imports: [], + }) + + await waitForState(runner, s => s.status === 'finished') + + expect(toastEvents).to.have.length(1) + expect(toastEvents[0].detail).to.deep.equal({ + key: 'python:files-saved', + paths: ['foo.txt'], + }) + }) + + it('does not dispatch a toast when no outputs were written', async function () { + const runner = createRunner() + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + success: true, + outputs: [], + outputFiles: [], + imports: [], + failedUploads: [], + }) + + await waitForState(runner, s => s.status === 'finished') + + expect(toastEvents).to.have.length(0) + }) + + it('does not dispatch a toast when every output failed to upload', async function () { + const fileUploader = sinon.stub().resolves([ + { + status: 'error', + name: 'foo.txt', + relativePath: 'foo.txt', + error: 'boom', + }, + ]) + const runner = createRunner({ fileUploader }) + const worker = initAndLoad(runner) + + await runner.run() + const runMsg = worker.postedMessages.find(m => m.type === 'run-code') + worker.emitMessage({ + type: 'run-code-result', + fileId: FILE_ID, + executionId: runMsg.executionId, + success: true, + outputs: ['/project/foo.txt'], + outputFiles: [{ relativePath: 'foo.txt', content: new Uint8Array() }], + imports: [], + }) + + await waitForState(runner, s => s.status === 'finished') + + expect(toastEvents).to.have.length(0) + }) + }) + describe('output', function () { it('accumulates output lines for the matching file', async function () { const runner = createRunner()