diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 40fe994fd8..91089765d5 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -419,7 +419,8 @@ export const createDefaultWorkspace = (workspaceLocation: WorkspaceLocation): Wo enableDebugging: true, debuggerContext: {} as DebuggerContext, lastDebuggerResult: undefined, - lastNonDetResult: null + lastNonDetResult: null, + files: {} }); const defaultFileName = 'program.js'; diff --git a/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap b/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap index f01a13a176..908a0953fe 100644 --- a/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap +++ b/src/commons/assessment/__tests__/__snapshots__/Assessment.tsx.snap @@ -258,7 +258,7 @@ exports[`Assessment page does not show attempt Button for upcoming assessments f /> - Opens: 18th June, 13:24 + Opens: 18th June, 05:24
- Opened: 18th July, 13:24 + Opened: 18th July, 05:24
- Due: 18th June, 13:24 + Due: 18th June, 05:24
- Opened: 18th June, 13:24 + Opened: 18th June, 05:24
- Due: 18th June, 13:24 + Due: 18th June, 05:24
- Opens: 18th June, 13:24 + Opens: 18th June, 05:24
- Opened: 18th July, 13:24 + Opened: 18th July, 05:24
- Due: 18th June, 13:24 + Due: 18th June, 05:24
- Opened: 18th June, 13:24 + Opened: 18th June, 05:24
- Due: 18th June, 13:24 + Due: 18th June, 05:24
= props => { + const { assessmentId, questionId } = props; const [showOverlay, setShowOverlay] = useState(false); const [isSaving, setIsSaving] = useState(false); const [showResetTemplateOverlay, setShowResetTemplateOverlay] = useState(false); const [sessionId, setSessionId] = useState(''); const { isMobileBreakpoint } = useResponsive(); + // isEditable is a placeholder for now. In the future, it should be set to be + // based on whether it is the actual question being attempted. To enable read-only mode, set isEditable to false. + const [isEditable, setIsEditable] = useState(true); const assessment = useTypedSelector(state => state.session.assessments[props.assessmentId]); const assessmentOverviews = useTypedSelector(state => state.session.assessmentOverviews); @@ -130,12 +148,13 @@ const AssessmentWorkspace: React.FC = props => { }); const { selectedTab, setSelectedTab } = useSideContent( workspaceLocation, - assessment?.questions[props.questionId].grader !== undefined + assessment?.questions[questionId].grader !== undefined ? SideContentType.grading : SideContentType.questionOverview ); const navigate = useNavigate(); + const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); const { courseId } = useTypedSelector(state => state.session); const { @@ -203,12 +222,7 @@ const AssessmentWorkspace: React.FC = props => { handleDisableTokenCounter: () => dispatch(disableTokenCounter(workspaceLocation)) }; }, [dispatch]); - - useEffect(() => { - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, ''); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const currentQuestionFilePath = `/${workspaceLocation}/${questionId + 1}.js`; useEffect(() => { handleTeamOverviewFetch(props.assessmentId); @@ -233,34 +247,27 @@ const AssessmentWorkspace: React.FC = props => { } handleAssessmentFetch(props.assessmentId, assessmentPassword || undefined); - if (props.questionId === 0 && props.notAttempted) { + if (questionId === 0 && props.notAttempted) { setShowOverlay(true); } if (!assessment) { return; } - // ------------- PLEASE NOTE, EVERYTHING BELOW THIS SEEMS TO BE UNUSED ------------- - // checkWorkspaceReset does exactly the same thing. - let questionId = props.questionId; - if (props.questionId >= assessment.questions.length) { - questionId = assessment.questions.length - 1; - } - - const question = assessment.questions[questionId]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - let answer = ''; - if (question.type === QuestionTypes.programming) { - if (question.answer) { - answer = (question as IProgrammingQuestion).answer as string; - } else { - answer = (question as IProgrammingQuestion).solutionTemplate; + useEffect(() => { + if (!isNull(activeEditorTabIndex)) { + const currentFilePath = editorTabs[activeEditorTabIndex].filePath; + if (currentFilePath && currentFilePath === `/${workspaceLocation}/${questionId + 1}.js`) { + setIsEditable(true); + dispatch(updateTabReadOnly(workspaceLocation, activeEditorTabIndex, false)); + return; } } - - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, answer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + setIsEditable(false); + dispatch(updateTabReadOnly(workspaceLocation, activeEditorTabIndex, true)); + }, [activeEditorTabIndex, editorTabs, questionId]); /** * Once there is an update (due to the assessment being fetched), check @@ -297,11 +304,17 @@ const AssessmentWorkspace: React.FC = props => { ================== */ const pushLog = useCallback((newInput: Input) => log(sessionId, newInput), [sessionId]); - const onChangeMethod = (newCode: string, delta: CodeDelta) => { - handleUpdateHasUnsavedChanges?.(true); - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, newCode); + const onEditorValueChange = React.useCallback( + (editorTabIndex: number, newEditorValue: string) => { + if (isEditable) { + handleEditorValueChange(editorTabIndex, newEditorValue); + } + }, + [handleEditorValueChange, isEditable] + ); + const onChangeMethod = (newCode: string, delta: CodeDelta) => { + isEditable ? handleUpdateHasUnsavedChanges?.(true) : handleUpdateHasUnsavedChanges?.(false); const input: Input = { time: Date.now(), type: 'codeDelta', @@ -341,7 +354,6 @@ const AssessmentWorkspace: React.FC = props => { const activeTab = useRef(selectedTab); activeTab.current = selectedTab; const handleEval = useCallback(() => { - // Run testcases when the autograder tab is selected if (activeTab.current === SideContentType.autograder) { handleRunAllTestcases(); } else { @@ -356,6 +368,29 @@ const AssessmentWorkspace: React.FC = props => { pushLog(input); }, [handleEditorEval, handleRunAllTestcases, pushLog]); + /** + * Rewrites the file with our desired file tree + * Sets the currentQuestionFilePath to be the current active editor + */ + const rewriteFilesWithContent = async ( + currentQuestionFilePath: string, + newFileTree: Record + ) => { + if (fileSystem) { + await overwriteFilesInWorkspace(workspaceLocation, fileSystem, newFileTree); + dispatch(removeEditorTab(workspaceLocation, 0)); // remove the default tab which keeps appearing ;c + dispatch( + addEditorTab( + workspaceLocation, + currentQuestionFilePath, + newFileTree[currentQuestionFilePath] ?? '' + ) + ); + dispatch(updateActiveEditorTabIndex(workspaceLocation, 0)); + dispatch(updateTabReadOnly(workspaceLocation, 0, false)); + } + }; + /* ================ Helper Functions ================ */ @@ -363,15 +398,15 @@ const AssessmentWorkspace: React.FC = props => { * Checks if there is a need to reset the workspace, then executes * a dispatch (in the props) if needed. */ - const checkWorkspaceReset = () => { + const checkWorkspaceReset = (isReset = false) => { /* Don't reset workspace if assessment not fetched yet. */ if (assessment === undefined) { return; } /* Reset assessment if it has changed.*/ - const { assessmentId, questionId } = props; - if (storedAssessmentId === assessmentId && storedQuestionId === questionId) { + + if (storedAssessmentId === assessmentId && storedQuestionId === questionId && !isReset) { return; } @@ -385,6 +420,8 @@ const AssessmentWorkspace: React.FC = props => { editorTestcases?: Testcase[]; } = {}; + const optionFiles: Record = {}; + switch (question.type) { case QuestionTypes.programming: const programmingQuestionData: IProgrammingQuestion = question; @@ -394,15 +431,40 @@ const AssessmentWorkspace: React.FC = props => { options.editorTestcases = programmingQuestionData.testcases; // We use || not ?? to match both null and an empty string - options.editorValue = - programmingQuestionData.answer || programmingQuestionData.solutionTemplate; + // Sets the current active tab to the current "question file" and also force re-writes the file system + // The leading slash "/" at the front is VERY IMPORTANT! DO NOT DELETE + + // "otherFiles" refers to all other files that have an "answer" record + const otherFiles: Record = {}; + assessment.questions.forEach((question: Question, index) => { + if (question.type === 'programming') { + optionFiles[`/${workspaceLocation}/${index + 1}.js`] = { + answer: question.answer || question.solutionTemplate, + prepend: question.prepend, + postpend: question.postpend + }; + } + if (question.type === 'programming' && question.answer && index !== questionId) { + otherFiles[`/${workspaceLocation}/${index + 1}.js`] = question.answer; + } + }); + + rewriteFilesWithContent(currentQuestionFilePath, { + [currentQuestionFilePath]: isReset + ? programmingQuestionData.solutionTemplate + : programmingQuestionData.answer || programmingQuestionData.solutionTemplate, + ...otherFiles + }); + // Initialize session once the editorValue is known. + if (!sessionId) { setSessionId( initSession(`${(assessment as any).number}/${props.questionId}`, { chapter: question.library.chapter, externalLibrary: question?.library?.external?.name || 'NONE', - editorValue: options.editorValue + editorValue: + programmingQuestionData.answer || programmingQuestionData.solutionTemplate }) ); } @@ -417,16 +479,16 @@ const AssessmentWorkspace: React.FC = props => { break; } - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. + // Set to first tab first, the main editor tab should be the first tab handleEditorUpdateBreakpoints(0, []); handleUpdateCurrentAssessmentId(assessmentId, questionId); const resetWorkspaceOptions = assertType()({ autogradingResults: options.autogradingResults ?? [], - // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. editorTabs: [{ value: options.editorValue ?? '', highlightedLines: [], breakpoints: [] }], programPrependValue: options.programPrependValue ?? '', programPostpendValue: options.programPostpendValue ?? '', - editorTestcases: options.editorTestcases ?? [] + editorTestcases: options.editorTestcases ?? [], + files: optionFiles as Record }); handleResetWorkspace(resetWorkspaceOptions); handleChangeExecTime( @@ -434,10 +496,6 @@ const AssessmentWorkspace: React.FC = props => { ); handleClearContext(question.library, true); handleUpdateHasUnsavedChanges(false); - if (options.editorValue) { - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange(0, options.editorValue); - } }; /** @@ -454,7 +512,7 @@ const AssessmentWorkspace: React.FC = props => { assessmentOverview !== undefined ? assessmentOverview.maxTeamSize > 1 : false; const isContestVoting = question?.type === QuestionTypes.voting; const handleContestEntryClick = (_submissionId: number, answer: string) => { - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. + // Contest entries should be fixed to the first tab handleEditorValueChange(0, answer); }; @@ -555,6 +613,8 @@ const AssessmentWorkspace: React.FC = props => { body: ( = props => { const onClickSave = () => { if (isSaving) return; setIsSaving(true); + handleSave(question.id, editorTabs[0].value); checkLastModified(); setTimeout(() => { setIsSaving(false); @@ -678,7 +739,7 @@ const AssessmentWorkspace: React.FC = props => { } } } - // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. + // Tab 0 contains the main question tab (always) - and the only 1 they can edit, so this works fine handleSave(question.id, editorTabs[0].value); setTimeout(() => { handleAssessmentFetch(props.assessmentId); @@ -724,7 +785,11 @@ const AssessmentWorkspace: React.FC = props => { const resetButton = question.type !== QuestionTypes.mcq ? ( - + ) : null; const runButton = ( @@ -736,19 +801,21 @@ const AssessmentWorkspace: React.FC = props => { ); // Define the function to check if the Save button should be disabled + // TODO: Seems to break save functionality inside Assessments const shouldDisableSaveButton = (): boolean | undefined => { const isIndividualAssessment: boolean = assessmentOverview?.maxTeamSize === 1; if (isIndividualAssessment) { return false; } - return !teamFormationOverview; + // TODO: return !teamFormationOverview; + return false; }; const saveButton = props.canSave && question.type === QuestionTypes.programming ? ( @@ -765,11 +832,54 @@ const AssessmentWorkspace: React.FC = props => { /> ); + const toggleFolderModeButton = ( + = 2} + toggleFolderMode={async () => { + dispatch(toggleFolderMode(workspaceLocation)); + if (isFolderModeEnabled && fileSystem) { + // Set active tab back to default question if user disables folder mode + let isFound = false; + editorTabs.forEach((tab, index) => { + if (tab.filePath && tab.filePath === currentQuestionFilePath) { + isFound = true; + dispatch(updateActiveEditorTabIndex(workspaceLocation, index)); + } + }); + // Original question tab not found, we need to open it + if (!isFound) { + const fileContents = await handleReadFile(fileSystem, currentQuestionFilePath); + dispatch( + addEditorTab(workspaceLocation, currentQuestionFilePath, fileContents ?? '') + ); + // Set to the end of the editorTabs array (i.e the newly created editorTab) + dispatch(updateActiveEditorTabIndex(workspaceLocation, editorTabs.length)); + } + } + }} + key="folder" + /> + ); + + const editorButtonsMobileBreakpoint = [ + runButton, + saveButton, + resetButton, + toggleFolderModeButton + ]; + editorButtonsMobileBreakpoint.push(chapterSelect); + + const editorButtonsNotMobileBreakpoint = [saveButton, resetButton]; + const flowButtons = [previousButton, questionView, nextButton]; + return { editorButtons: !isMobileBreakpoint - ? [runButton, saveButton, resetButton, chapterSelect] - : [saveButton, resetButton], - flowButtons: [previousButton, questionView, nextButton] + ? editorButtonsMobileBreakpoint + : editorButtonsNotMobileBreakpoint, + flowButtons: flowButtons }; }; @@ -881,38 +991,27 @@ const AssessmentWorkspace: React.FC = props => { onClose={closeOverlay} title="Confirmation: Reset editor?" > - +
- - - - { - closeOverlay(); - // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. - handleEditorValueChange( - 0, - (assessment!.questions[questionId] as IProgrammingQuestion).solutionTemplate - ); - handleUpdateHasUnsavedChanges(true); - }} - options={{ minimal: false, intent: Intent.DANGER }} - /> - - } - /> +
+
+ + + { + closeOverlay(); + checkWorkspaceReset(true); + handleUpdateHasUnsavedChanges(true); + }} + options={{ minimal: false, intent: Intent.DANGER }} + /> + +
); - /* If questionId is out of bounds, set it to the max. */ - const questionId = - props.questionId >= assessment.questions.length - ? assessment.questions.length - 1 - : props.questionId; const question = assessment.questions[questionId]; const editorContainerProps: NormalEditorContainerProps | undefined = question.type === QuestionTypes.programming || question.type === QuestionTypes.voting @@ -930,7 +1029,7 @@ const AssessmentWorkspace: React.FC = props => { externalLibraryName: question.library.external.name || 'NONE', handleDeclarationNavigate: editorContainerHandlers.handleDeclarationNavigate, handleEditorEval: handleEval, - handleEditorValueChange: handleEditorValueChange, + handleEditorValueChange: onEditorValueChange, handleUpdateHasUnsavedChanges: handleUpdateHasUnsavedChanges, handleEditorUpdateBreakpoints: handleEditorUpdateBreakpoints, handlePromptAutocomplete: editorContainerHandlers.handlePromptAutocomplete, @@ -957,7 +1056,24 @@ const AssessmentWorkspace: React.FC = props => { replButtons: replButtons }; const sideBarProps = { - tabs: [] + tabs: [ + ...(isFolderModeEnabled + ? [ + { + label: 'Folder', + body: ( + + ), + iconName: IconNames.FOLDER_CLOSE, + id: SideContentType.folder + } + ] + : []) + ] }; const workspaceProps: WorkspaceProps = { controlBarProps: controlBarProps(questionId), diff --git a/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.tsx.snap b/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.tsx.snap index e0ea3c38bc..a2c9ce21fe 100644 --- a/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.tsx.snap +++ b/src/commons/assessmentWorkspace/__tests__/__snapshots__/AssessmentWorkspace.tsx.snap @@ -99,8 +99,12 @@ exports[`AssessmentWorkspace AssessmentWorkspace page with ContestVoting questio - + + + + + + Folder + + +
+ + + + + Folder + + +
- + + + + + + Folder + + +
- + + + + +
- + + + + + + Folder + + +
on - 18th June, 13:24 + 18th June, 05:24
diff --git a/src/commons/controlBar/ControlBarFileModeButton.tsx b/src/commons/controlBar/ControlBarFileModeButton.tsx new file mode 100644 index 0000000000..4fe32aa27e --- /dev/null +++ b/src/commons/controlBar/ControlBarFileModeButton.tsx @@ -0,0 +1,19 @@ +import { IconNames } from '@blueprintjs/icons'; +import React from 'react'; + +import ControlButton from '../ControlButton'; + +/** + * @prop fileMode an integer for the mode of the file where + * 0 = read-only and 1 = read-write. + */ +type ControlBarFileModeButtonProps = { + fileMode: number | null; +}; + +export const ControlBarFileModeButton: React.FC = ({ fileMode }) => { + if (fileMode === 0) { + return ; + } + return ; +}; diff --git a/src/commons/controlBar/ControlBarResetButton.tsx b/src/commons/controlBar/ControlBarResetButton.tsx index d06cd48d4f..b0f27566ca 100644 --- a/src/commons/controlBar/ControlBarResetButton.tsx +++ b/src/commons/controlBar/ControlBarResetButton.tsx @@ -5,8 +5,11 @@ import ControlButton from '../ControlButton'; type Props = { onClick?(): any; + disabled?: boolean; }; -export const ControlBarResetButton: React.FC = ({ onClick }) => { - return ; +export const ControlBarResetButton: React.FC = ({ onClick, disabled }) => { + return ( + + ); }; diff --git a/src/commons/controlBar/ControlBarRunButton.tsx b/src/commons/controlBar/ControlBarRunButton.tsx index a35062fa88..9cceee6a7d 100644 --- a/src/commons/controlBar/ControlBarRunButton.tsx +++ b/src/commons/controlBar/ControlBarRunButton.tsx @@ -21,6 +21,13 @@ export const ControlBarRunButton: React.FC = props const tooltipContent = props.isEntrypointFileDefined ? '...or press shift-enter in the editor' : 'Open a file to evaluate the program with the file as the entrypoint'; + + /* + ? props.readOnly + ? 'Evaluation is disabled in read-only mode' + : '...or press shift-enter in the editor' + : 'Open a file to evaluate the program with the file as the entrypoint'; + */ return ( void; }; @@ -15,12 +16,15 @@ export const ControlBarToggleFolderModeButton: React.FC = ({ isFolderModeEnabled, isSessionActive, isPersistenceActive, + isSupportedSource, toggleFolderMode }) => { const tooltipContent = isSessionActive ? 'Currently unsupported while a collaborative session is active' : isPersistenceActive ? 'Currently unsupported while a persistence method is active' + : !isSupportedSource + ? 'Folder mode is not available for this version of Source' : `${isFolderModeEnabled ? 'Disable' : 'Enable'} Folder mode`; return ( @@ -31,7 +35,7 @@ export const ControlBarToggleFolderModeButton: React.FC = ({ iconColor: isFolderModeEnabled ? Colors.BLUE4 : undefined }} onClick={toggleFolderMode} - isDisabled={isSessionActive || isPersistenceActive} + isDisabled={isSessionActive || isPersistenceActive || !isSupportedSource} /> ); diff --git a/src/commons/editor/Editor.tsx b/src/commons/editor/Editor.tsx index 6c4e4cc7fa..8a44cfdaf1 100644 --- a/src/commons/editor/Editor.tsx +++ b/src/commons/editor/Editor.tsx @@ -78,6 +78,7 @@ export type EditorTabStateProps = { highlightedLines: HighlightedLines[]; breakpoints: string[]; newCursorPosition?: Position; + readOnly?: boolean; }; type LocalStateProps = { diff --git a/src/commons/editor/EditorContainer.tsx b/src/commons/editor/EditorContainer.tsx index 1f116885b7..4ffddac423 100644 --- a/src/commons/editor/EditorContainer.tsx +++ b/src/commons/editor/EditorContainer.tsx @@ -40,7 +40,14 @@ export const convertEditorTabStateToProps = ( return { editorTabIndex, editorValue: editorTab.value, - ..._.pick(editorTab, 'filePath', 'highlightedLines', 'breakpoints', 'newCursorPosition') + ..._.pick( + editorTab, + 'filePath', + 'highlightedLines', + 'breakpoints', + 'newCursorPosition', + 'readOnly' + ) }; }; @@ -89,6 +96,7 @@ const EditorContainer: React.FC = (props: EditorContainerP
{isFolderModeEnabled && ( void; remove: () => void; + readOnly?: boolean; }; -const EditorTab: React.FC = ({ filePath, isActive, setActive, remove }) => { +const EditorTab: React.FC = ({ filePath, isActive, setActive, remove, readOnly }) => { const onClick = (e: React.MouseEvent) => { // Stop the click event from propagating to the parent component. e.stopPropagation(); @@ -24,6 +25,10 @@ const EditorTab: React.FC = ({ filePath, isActive, setActive, remove }) = })} onClick={setActive} > + {' '} + {readOnly !== undefined && ( + + )} {filePath} diff --git a/src/commons/editor/tabs/EditorTabContainer.tsx b/src/commons/editor/tabs/EditorTabContainer.tsx index b884fbbe73..93b872f5ad 100644 --- a/src/commons/editor/tabs/EditorTabContainer.tsx +++ b/src/commons/editor/tabs/EditorTabContainer.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import { EditorTabStateProps } from '../Editor'; import EditorTab from './EditorTab'; import { getShortestUniqueFilePaths } from './utils'; type Props = { + editorTabs: EditorTabStateProps[]; baseFilePath: string; filePaths: string[]; activeEditorTabIndex: number; @@ -12,6 +14,7 @@ type Props = { }; const EditorTabContainer: React.FC = ({ + editorTabs, baseFilePath, filePaths, activeEditorTabIndex, @@ -36,6 +39,7 @@ const EditorTabContainer: React.FC = ({ isActive={index === activeEditorTabIndex} setActive={() => setActiveEditorTabIndex(index)} remove={() => removeEditorTabByIndex(index)} + readOnly={editorTabs[index].readOnly} /> ))}
diff --git a/src/commons/fileSystem/utils.ts b/src/commons/fileSystem/utils.ts index 36ecd146d5..34085163d4 100644 --- a/src/commons/fileSystem/utils.ts +++ b/src/commons/fileSystem/utils.ts @@ -10,6 +10,22 @@ type File = { contents: string; }; +export const handleReadFile = (fileSystem: FSModule, fullFilePath: string): Promise => { + return new Promise((resolve, reject) => { + fileSystem.readFile(fullFilePath, 'utf-8', (err, fileContents) => { + if (err) { + reject(err); + return; + } + if (fileContents === undefined) { + return; + } + + resolve(fileContents); + }); + }); +}; + /** * Retrieves the files in the specified workspace as a record that maps from * file path to file content. Because BrowserFS lacks an equivalent to Node.js's diff --git a/src/commons/fileSystemView/FileSystemView.tsx b/src/commons/fileSystemView/FileSystemView.tsx index f248b92505..dd6fe65c5b 100644 --- a/src/commons/fileSystemView/FileSystemView.tsx +++ b/src/commons/fileSystemView/FileSystemView.tsx @@ -15,9 +15,10 @@ import FileSystemViewPlaceholderNode from './FileSystemViewPlaceholderNode'; type Props = { workspaceLocation: WorkspaceLocation; basePath: string; + disableEditing?: boolean; // Disables creation/renaming/deleting of files }; -const FileSystemView: React.FC = ({ workspaceLocation, basePath }) => { +const FileSystemView: React.FC = ({ workspaceLocation, basePath, disableEditing }) => { const fileSystem = useTypedSelector(state => state.fileSystem.inBrowserFileSystem); const [isAddingNewFile, setIsAddingNewFile] = React.useState(false); @@ -95,6 +96,7 @@ const FileSystemView: React.FC = ({ workspaceLocation, basePath }) => { return (
= ({ workspaceLocation, basePath }) => { />
)} + void; rename?: () => void; remove?: () => void; + disableEditing?: boolean; }; const FileSystemViewContextMenu: React.FC = ({ @@ -21,7 +22,8 @@ const FileSystemViewContextMenu: React.FC = ({ createNewDirectory, open, rename, - remove + remove, + disableEditing }) => { const [menuProps, toggleMenu] = useMenuState(); const [anchorPoint, setAnchorPoint] = React.useState({ x: 0, y: 0 }); @@ -35,38 +37,36 @@ const FileSystemViewContextMenu: React.FC = ({ return (
{children} - toggleMenu(false)} - > - {createNewFile && ( + {!disableEditing && ( + toggleMenu(false)} + > New File - )} - {createNewDirectory && ( New Directory - )} - {open && ( - - Open - - )} - {rename && ( - - Rename - - )} - {remove && ( - - Delete - - )} - + {open && ( + + Open + + )} + {rename && ( + + Rename + + )} + {remove && ( + + Delete + + )} + + )}
); }; diff --git a/src/commons/fileSystemView/FileSystemViewDirectoryNode.tsx b/src/commons/fileSystemView/FileSystemViewDirectoryNode.tsx index fa59e26c4e..163935d030 100644 --- a/src/commons/fileSystemView/FileSystemViewDirectoryNode.tsx +++ b/src/commons/fileSystemView/FileSystemViewDirectoryNode.tsx @@ -23,6 +23,7 @@ type Props = { directoryName: string; indentationLevel: number; refreshParentDirectory: () => void; + disableEditing?: boolean; }; const FileSystemViewDirectoryNode: React.FC = ({ @@ -31,7 +32,8 @@ const FileSystemViewDirectoryNode: React.FC = ({ basePath, directoryName, indentationLevel, - refreshParentDirectory + refreshParentDirectory, + disableEditing }) => { const fullPath = path.join(basePath, directoryName); @@ -151,6 +153,7 @@ const FileSystemViewDirectoryNode: React.FC = ({ createNewDirectory={handleCreateNewDirectory} rename={handleRenameDirectory} remove={handleRemoveDirectory} + disableEditing={disableEditing} >
diff --git a/src/commons/fileSystemView/FileSystemViewFileNode.tsx b/src/commons/fileSystemView/FileSystemViewFileNode.tsx index 99136f4447..f712765d8d 100644 --- a/src/commons/fileSystemView/FileSystemViewFileNode.tsx +++ b/src/commons/fileSystemView/FileSystemViewFileNode.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { useDispatch } from 'react-redux'; import classes from 'src/styles/FileSystemView.module.scss'; +import { handleReadFile } from '../fileSystem/utils'; import { showSimpleConfirmDialog } from '../utils/DialogHelper'; import { addEditorTab, removeEditorTabForFile } from '../workspace/WorkspaceActions'; import { WorkspaceLocation } from '../workspace/WorkspaceTypes'; @@ -19,6 +20,7 @@ type Props = { basePath: string; fileName: string; indentationLevel: number; + disableEditing?: boolean; refreshDirectory: () => void; }; @@ -28,24 +30,18 @@ const FileSystemViewFileNode: React.FC = ({ basePath, fileName, indentationLevel, - refreshDirectory + refreshDirectory, + disableEditing }) => { const [isEditing, setIsEditing] = React.useState(false); const dispatch = useDispatch(); const fullPath = path.join(basePath, fileName); - const handleOpenFile = () => { - fileSystem.readFile(fullPath, 'utf-8', (err, fileContents) => { - if (err) { - console.error(err); - } - if (fileContents === undefined) { - throw new Error('File contents are undefined.'); - } + const handleOpenFile = async () => { + const fileContents = await handleReadFile(fileSystem, fullPath); - dispatch(addEditorTab(workspaceLocation, fullPath, fileContents)); - }); + dispatch(addEditorTab(workspaceLocation, fullPath, fileContents)); }; const handleRenameFile = () => setIsEditing(true); @@ -92,6 +88,7 @@ const FileSystemViewFileNode: React.FC = ({ open={handleOpenFile} rename={handleRenameFile} remove={handleRemoveFile} + disableEditing={disableEditing} >
diff --git a/src/commons/fileSystemView/FileSystemViewList.tsx b/src/commons/fileSystemView/FileSystemViewList.tsx index b49462853a..258d4fff8d 100644 --- a/src/commons/fileSystemView/FileSystemViewList.tsx +++ b/src/commons/fileSystemView/FileSystemViewList.tsx @@ -14,13 +14,15 @@ type Props = { fileSystem: FSModule; basePath: string; indentationLevel: number; + disableEditing?: boolean; }; const FileSystemViewList: React.FC = ({ workspaceLocation, fileSystem, basePath, - indentationLevel + indentationLevel, + disableEditing }) => { const [dirNames, setDirNames] = React.useState(undefined); const [fileNames, setFileNames] = React.useState(undefined); @@ -80,6 +82,7 @@ const FileSystemViewList: React.FC = ({ {dirNames.map(dirName => { return ( = ({ {fileNames.map(fileName => { return ( { const [ + workspaceFiles, prepend, activeEditorTabIndex, editorTabs, @@ -31,6 +33,7 @@ export function* evalEditor( fileSystem, remoteExecutionSession ]: [ + Record, string, number | null, EditorTabState[], @@ -39,6 +42,7 @@ export function* evalEditor( FSModule, DeviceSession | undefined ] = yield select((state: OverallState) => [ + state.workspaces[workspaceLocation].files, state.workspaces[workspaceLocation].programPrependValue, state.workspaces[workspaceLocation].activeEditorTabIndex, state.workspaces[workspaceLocation].editorTabs, @@ -53,15 +57,23 @@ export function* evalEditor( } const defaultFilePath = `${WORKSPACE_BASE_PATHS[workspaceLocation]}/program.js`; + let entrypointFilePath = editorTabs[activeEditorTabIndex].filePath ?? defaultFilePath; let files: Record; if (isFolderModeEnabled) { files = yield call(retrieveFilesInWorkspaceAsRecord, workspaceLocation, fileSystem); + if ((workspaceLocation === 'assessment' || workspaceLocation === 'grading') && isGraderTab) { + const questionNumber = yield select( + (state: OverallState) => state.workspaces[workspaceLocation].currentQuestion + ); + if (typeof questionNumber !== undefined) { + entrypointFilePath = `${WORKSPACE_BASE_PATHS[workspaceLocation]}/${questionNumber + 1}.js`; + } + } } else { files = { [defaultFilePath]: editorTabs[activeEditorTabIndex].value }; } - const entrypointFilePath = editorTabs[activeEditorTabIndex].filePath ?? defaultFilePath; yield put(actions.addEvent([EventType.RUN_CODE])); @@ -90,12 +102,25 @@ export function* evalEditor( ); } + // Append the prepend and postpend to the files, except for entrypointFile which should only have + // the postpend appended, since the prepend should be evaluated silently with a privileged context + for (const [filePath, fileContents] of Object.entries(workspaceFiles)) { + if (filePath === entrypointFilePath) { + files[filePath] = files[filePath] + fileContents.postpend; + } else { + files[filePath] = fileContents.prepend + files[filePath] + fileContents.postpend; + } + } + + const prependVal = + entrypointFilePath in workspaceFiles ? workspaceFiles[entrypointFilePath].prepend : prepend; + // console.log('prependVal', prependVal) // Evaluate the prepend silently with a privileged context, if it exists - if (prepend.length) { + if (prependVal.length) { const elevatedContext = makeElevatedContext(context); const prependFilePath = '/prepend.js'; const prependFiles = { - [prependFilePath]: prepend + [prependFilePath]: prependVal }; yield call( evalCode, diff --git a/src/commons/sagas/WorkspaceSaga/helpers/runTestCase.ts b/src/commons/sagas/WorkspaceSaga/helpers/runTestCase.ts index 6a16395238..e6614b8cd8 100644 --- a/src/commons/sagas/WorkspaceSaga/helpers/runTestCase.ts +++ b/src/commons/sagas/WorkspaceSaga/helpers/runTestCase.ts @@ -1,6 +1,8 @@ import { Context } from 'js-slang'; import { random } from 'lodash'; import { call, put, select, StrictEffect } from 'redux-saga/effects'; +import { retrieveFilesInWorkspaceAsRecord } from 'src/commons/fileSystem/utils'; +import { WORKSPACE_BASE_PATHS } from 'src/pages/fileSystem/createInBrowserFileSystem'; import { OverallState } from '../../../application/ApplicationTypes'; import { TestcaseType } from '../../../assessment/AssessmentTypes'; @@ -17,16 +19,35 @@ export function* runTestCase( workspaceLocation: WorkspaceLocation, index: number ): Generator { - const [prepend, value, postpend, testcase]: [string, string, string, string] = yield select( - (state: OverallState) => { - const prepend = state.workspaces[workspaceLocation].programPrependValue; - const postpend = state.workspaces[workspaceLocation].programPostpendValue; - // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. - const value = state.workspaces[workspaceLocation].editorTabs[0].value; - const testcase = state.workspaces[workspaceLocation].editorTestcases[index].program; - return [prepend, value, postpend, testcase] as [string, string, string, string]; - } - ); + const [files, prepend, value, postpend, testcase, isFolderModeEnabled]: [ + Record, + string, + string, + string, + string, + boolean + ] = yield select((state: OverallState) => { + const files = state.workspaces[workspaceLocation].files; + const activeEditorTabIndex = state.workspaces[workspaceLocation].activeEditorTabIndex; + + const isFolderModeEnabled = state.workspaces[workspaceLocation].isFolderModeEnabled; + const prepend = state.workspaces[workspaceLocation].programPrependValue; + const postpend = state.workspaces[workspaceLocation].programPostpendValue; + // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. + const value = + activeEditorTabIndex !== null + ? state.workspaces[workspaceLocation].editorTabs[activeEditorTabIndex].value + : state.workspaces[workspaceLocation].editorTabs[0].value; + const testcase = state.workspaces[workspaceLocation].editorTestcases[index].program; + return [files, prepend, value, postpend, testcase, isFolderModeEnabled] as [ + Record, + string, + string, + string, + string, + boolean + ]; + }); const type: TestcaseType = yield select( (state: OverallState) => state.workspaces[workspaceLocation].editorTestcases[index].type ); @@ -68,14 +89,39 @@ export function* runTestCase( // Then execute student program silently in the original workspace context const blockKey = String(random(1048576, 68719476736)); yield* blockExtraMethods(elevatedContext, context, execTime, workspaceLocation, blockKey); - const valueFilePath = '/value.js'; - const valueFiles = { - [valueFilePath]: value + let valueFileEntryPath = '/value.js'; + let valueFiles: Record = { + [valueFileEntryPath]: value }; + + // Populate valueFiles with the entire fileSystem if folder mode is enabled and is an assessment + // Always sets the entry path as the current question + if ( + isFolderModeEnabled && + (workspaceLocation === 'assessment' || workspaceLocation === 'grading') + ) { + const questionNumber = yield select( + (state: OverallState) => state.workspaces[workspaceLocation].currentQuestion + ); + if (typeof questionNumber !== undefined) { + valueFileEntryPath = `${WORKSPACE_BASE_PATHS[workspaceLocation]}/${questionNumber + 1}.js`; + } + const fileSystem = yield select((state: OverallState) => state.fileSystem.inBrowserFileSystem); + valueFiles = yield call(retrieveFilesInWorkspaceAsRecord, workspaceLocation, fileSystem); + } + + // Append the prepend and postpend to the files, except for entrypointFile is unchanced, + // since the prepend and postpend should be evaluated silently with a privileged context + for (const [filePath, fileContents] of Object.entries(files)) { + if (filePath !== valueFileEntryPath) { + valueFiles[filePath] = fileContents.prepend + valueFiles[filePath] + fileContents.postpend; + } + } + yield call( evalCode, valueFiles, - valueFilePath, + valueFileEntryPath, context, execTime, workspaceLocation, diff --git a/src/commons/sagas/WorkspaceSaga/index.ts b/src/commons/sagas/WorkspaceSaga/index.ts index 57ccf7f0fe..36f80bad50 100644 --- a/src/commons/sagas/WorkspaceSaga/index.ts +++ b/src/commons/sagas/WorkspaceSaga/index.ts @@ -189,9 +189,12 @@ export default function* WorkspaceSaga(): SagaIterator { const code: string = yield select((state: OverallState) => { const prependCode = state.workspaces[workspaceLocation].programPrependValue; - // TODO: Hardcoded to make use of the first editor tab. Rewrite after editor tabs are added. - const editorCode = state.workspaces[workspaceLocation].editorTabs[0].value; - return [prependCode, editorCode] as [string, string]; + const activeEditorTab = state.workspaces[workspaceLocation].activeEditorTabIndex; + const currentFileCode = + activeEditorTab !== null + ? state.workspaces[workspaceLocation].editorTabs[activeEditorTab].value + : state.workspaces[workspaceLocation].editorTabs[0].value; + return [prependCode, currentFileCode] as [string, string]; }); const [prepend, editorValue] = code; @@ -539,7 +542,7 @@ export default function* WorkspaceSaga(): SagaIterator { function* (action: ReturnType) { const { workspaceLocation } = action.payload; - yield call(evalEditor, workspaceLocation); + yield call(evalEditor, workspaceLocation, true); const testcases: Testcase[] = yield select( (state: OverallState) => state.workspaces[workspaceLocation].editorTestcases diff --git a/src/commons/sideContent/__tests__/SideContentAutograder.tsx b/src/commons/sideContent/__tests__/SideContentAutograder.tsx index 3dc37a1417..53cdb4d658 100644 --- a/src/commons/sideContent/__tests__/SideContentAutograder.tsx +++ b/src/commons/sideContent/__tests__/SideContentAutograder.tsx @@ -83,6 +83,7 @@ test('Autograder renders placeholders correctly when testcases and results are e const props: SideContentAutograderProps = { autogradingResults: [], testcases: [], + currentFileBeingRan: '', workspaceLocation: 'assessment', handleTestcaseEval: (testcaseId: number) => {} }; @@ -107,6 +108,7 @@ test('Autograder renders placeholders correctly when testcases and results are e test('Autograder renders public testcases with different statuses correctly', async () => { const props: SideContentAutograderProps = { autogradingResults: [], + currentFileBeingRan: 'test1.js', testcases: mockPublicTestcases, workspaceLocation: 'assessment', handleTestcaseEval: (testcaseId: number) => {} @@ -152,6 +154,7 @@ test('Autograder renders public testcases with different statuses correctly', as test('Autograder renders opaque testcases with different statuses correctly in AssessmentWorkspace', async () => { const props: SideContentAutograderProps = { autogradingResults: [], + currentFileBeingRan: 'test1.js', testcases: mockOpaqueTestcases, workspaceLocation: 'assessment', handleTestcaseEval: (testcaseId: number) => {} @@ -181,6 +184,7 @@ test('Autograder renders opaque testcases with different statuses correctly in A test('Autograder renders opaque testcases with different statuses correctly in GradingWorkspace', async () => { const props: SideContentAutograderProps = { autogradingResults: [], + currentFileBeingRan: 'test1.js', testcases: mockOpaqueTestcases, workspaceLocation: 'grading', handleTestcaseEval: (testcaseId: number) => {} @@ -212,6 +216,7 @@ test('Autograder renders opaque testcases with different statuses correctly in G test('Autograder renders secret testcases with different statuses correctly', async () => { const props: SideContentAutograderProps = { autogradingResults: [], + currentFileBeingRan: 'test1.js', testcases: mockSecretTestcases, workspaceLocation: 'grading', handleTestcaseEval: (testcaseId: number) => {} @@ -251,6 +256,7 @@ test('Autograder renders secret testcases with different statuses correctly', as test('Autograder renders autograder results with different statuses correctly', async () => { const props: SideContentAutograderProps = { autogradingResults: mockAutogradingResults, + currentFileBeingRan: 'test1.js', testcases: [], workspaceLocation: 'assessment', handleTestcaseEval: (testcaseId: number) => {} diff --git a/src/commons/sideContent/content/SideContentAutograder.tsx b/src/commons/sideContent/content/SideContentAutograder.tsx index 2379c24137..6a6917c490 100644 --- a/src/commons/sideContent/content/SideContentAutograder.tsx +++ b/src/commons/sideContent/content/SideContentAutograder.tsx @@ -17,6 +17,8 @@ type DispatchProps = { type StateProps = { autogradingResults: AutogradingResult[]; testcases: Testcase[]; + currentFileBeingRan: string; + isFolderModeEnabled?: boolean; }; type OwnProps = { @@ -31,7 +33,14 @@ const SideContentAutograder: React.FC = props => { const [showsTestcases, setTestcasesShown] = React.useState(true); const [showsResults, setResultsShown] = React.useState(true); - const { testcases, autogradingResults, handleTestcaseEval, workspaceLocation } = props; + const { + testcases, + autogradingResults, + handleTestcaseEval, + workspaceLocation, + currentFileBeingRan, + isFolderModeEnabled + } = props; const testcaseCards = React.useMemo( () => @@ -81,6 +90,12 @@ const SideContentAutograder: React.FC = props => { return (
+ {isFolderModeEnabled && ( + + Entry point file for Testcases:{' '} + {currentFileBeingRan} + + )}