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
+
+
+
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)}
+ >
- )}
- {createNewDirectory && (
- )}
- {open && (
-
- )}
- {rename && (
-
- )}
- {remove && (
-
- )}
-
+ {open && (
+
+ )}
+ {rename && (
+
+ )}
+ {remove && (
+
+ )}
+
+ )}
);
};
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}
+
+ )}