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()