Browse Source

Show toast when Python script saves output files to project

GitOrigin-RevId: 9ca5201645953f86c3ac8e83f545dfbcdac2b35c
Domagoj Kriskovic 2 months ago
parent
commit
803ba735ca

+ 3 - 0
services/web/frontend/extracted-translations.json

@@ -2380,6 +2380,8 @@
   "write_faster_smarter_with_research_ready_ai": "",
   "write_faster_smarter_with_research_ready_ai": "",
   "x_changes_in": "",
   "x_changes_in": "",
   "x_changes_in_plural": "",
   "x_changes_in_plural": "",
+  "x_files_saved_to_your_project": "",
+  "x_files_saved_to_your_project_plural": "",
   "x_libraries_accessed_in_this_project": "",
   "x_libraries_accessed_in_this_project": "",
   "x_people_invited": "",
   "x_people_invited": "",
   "x_people_invited_plural": "",
   "x_people_invited_plural": "",
@@ -2389,6 +2391,7 @@
   "x_price_per_month": "",
   "x_price_per_month": "",
   "x_price_per_user": "",
   "x_price_per_user": "",
   "x_price_per_year": "",
   "x_price_per_year": "",
+  "x_saved_to_your_project": "",
   "year": "",
   "year": "",
   "yearly": "",
   "yearly": "",
   "yes_move_me_to_personal_plan": "",
   "yes_move_me_to_personal_plan": "",

+ 9 - 1
services/web/frontend/js/features/ide-react/components/editor/python/python-output-pane.tsx

@@ -1,5 +1,6 @@
 import { useMemo, useSyncExternalStore } from 'react'
 import { useMemo, useSyncExternalStore } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
 import OLButton from '@/shared/components/ol/ol-button'
 import OLButton from '@/shared/components/ol/ol-button'
 import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
 import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
@@ -33,7 +34,14 @@ export default function PythonOutputPane() {
     <div className="ide-redesign-python-output-pane">
     <div className="ide-redesign-python-output-pane">
       <OLButtonToolbar className="ide-redesign-python-output-pane-toolbar">
       <OLButtonToolbar className="ide-redesign-python-output-pane-toolbar">
         <div className="ide-redesign-python-output-pane-toolbar-left">
         <div className="ide-redesign-python-output-pane-toolbar-left">
-          <div className="ide-redesign-python-output-pane-run-button-wrapper">
+          <div
+            className={classNames(
+              'ide-redesign-python-output-pane-run-button-wrapper',
+              {
+                'compile-button-group-running': status === 'running',
+              }
+            )}
+          >
             <OLButton
             <OLButton
               onClick={() => {
               onClick={() => {
                 if (status === 'running') {
                 if (status === 'running') {

+ 48 - 0
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 (
+      <span>
+        {t('x_saved_to_your_project', {
+          fileName: stripProjectPrefix(paths[0]),
+        })}
+      </span>
+    )
+  }
+  return (
+    <span>{t('x_files_saved_to_your_project', { count: paths.length })}</span>
+  )
+}
+
+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: (
+        <PythonFilesSavedToast paths={isStringArray(paths) ? paths : []} />
+      ),
+      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 },
+    })
+  )
+}

+ 17 - 0
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 { debugConsole } from '@/utils/debugging'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { PyodideWorkerClient } from './pyodide-worker-client'
 import { PyodideWorkerClient } from './pyodide-worker-client'
+import { showPythonFilesSavedToast } from './python-output-toasts'
 import type { OutputStream } from './pyodide-worker-messages'
 import type { OutputStream } from './pyodide-worker-messages'
 import type {
 import type {
   BatchUploadItem,
   BatchUploadItem,
@@ -15,6 +16,11 @@ import type {
 export type FileUploader = (items: BatchUploadItem[]) => Promise<UploadResult[]>
 export type FileUploader = (items: BatchUploadItem[]) => Promise<UploadResult[]>
 
 
 const MAX_OUTPUT_LINES = 100
 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 =
 export type ExecutionStatus =
   | 'loading'
   | 'loading'
@@ -145,6 +151,17 @@ export class PythonRunner {
               filesWrittenExtensions: collectExtensions(event.outputs),
               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' })
             this.updateState({ status: 'finished' })
           }
           }
         }
         }

+ 2 - 0
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 clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
 import importDocumentFeedbackToastGenerators from '@/features/project-list/components/new-project-button/import-document-feedback-toast'
 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 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 {
 const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
   import: { default: GlobalToastGeneratorEntry[] }
   import: { default: GlobalToastGeneratorEntry[] }
@@ -31,6 +32,7 @@ const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
   ...clipboardToastGenerators,
   ...clipboardToastGenerators,
   ...importDocumentFeedbackToastGenerators,
   ...importDocumentFeedbackToastGenerators,
   ...exportDocumentToastGenerators,
   ...exportDocumentToastGenerators,
+  ...pythonOutputToastGenerators,
 ]
 ]
 const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
 const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
   GENERATOR_LIST.map(({ key, generator }) => [key, generator])
   GENERATOR_LIST.map(({ key, generator }) => [key, generator])

+ 4 - 0
services/web/frontend/stylesheets/pages/editor/ide-redesign.scss

@@ -90,6 +90,10 @@
   border-radius: var(--ds-border-radius-300);
   border-radius: var(--ds-border-radius-300);
   margin-left: var(--spacing-02);
   margin-left: var(--spacing-02);
 
 
+  &.compile-button-group-running {
+    background-color: var(--bg-danger-01);
+  }
+
   .btn-primary:hover {
   .btn-primary:hover {
     z-index: auto;
     z-index: auto;
   }
   }

+ 3 - 0
services/web/locales/en.json

@@ -3039,6 +3039,8 @@
   "writefull": "Writefull",
   "writefull": "Writefull",
   "x_changes_in": "__count__ change in",
   "x_changes_in": "__count__ change in",
   "x_changes_in_plural": "__count__ changes 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_libraries_accessed_in_this_project": "__provider__ libraries accessed in this project",
   "x_people_invited": "__count__ person invited",
   "x_people_invited": "__count__ person invited",
   "x_people_invited_plural": "__count__ people invited",
   "x_people_invited_plural": "__count__ people invited",
@@ -3048,6 +3050,7 @@
   "x_price_per_month": "__price__ per month",
   "x_price_per_month": "__price__ per month",
   "x_price_per_user": "__price__ per user",
   "x_price_per_user": "__price__ per user",
   "x_price_per_year": "__price__ per year",
   "x_price_per_year": "__price__ per year",
+  "x_saved_to_your_project": "__fileName__ saved to your project",
   "year": "year",
   "year": "year",
   "yearly": "Yearly",
   "yearly": "Yearly",
   "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
   "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",

+ 131 - 0
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 () {
   describe('output', function () {
     it('accumulates output lines for the matching file', async function () {
     it('accumulates output lines for the matching file', async function () {
       const runner = createRunner()
       const runner = createRunner()