Show toast when Python script saves output files to project
GitOrigin-RevId: 9ca5201645953f86c3ac8e83f545dfbcdac2b35c
This commit is contained in:
committed by
Copybot
parent
014ac37704
commit
803ba735ca
@@ -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": "",
|
||||
|
||||
+9
-1
@@ -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() {
|
||||
<div className="ide-redesign-python-output-pane">
|
||||
<OLButtonToolbar className="ide-redesign-python-output-pane-toolbar">
|
||||
<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
|
||||
onClick={() => {
|
||||
if (status === 'running') {
|
||||
|
||||
+48
@@ -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 },
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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<UploadResult[]>
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, GlobalToastGenerator> = new Map(
|
||||
GENERATOR_LIST.map(({ key, generator }) => [key, generator])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user