diff --git a/grader_labextension/handlers/version_control.py b/grader_labextension/handlers/version_control.py index 6edcac5..6d58568 100644 --- a/grader_labextension/handlers/version_control.py +++ b/grader_labextension/handlers/version_control.py @@ -93,6 +93,46 @@ async def put(self, lecture_id: int, assignment_id: int): self.write("OK") +@register_handler( + path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/remote-file-status\/(?P\w*)\/?" +) +class GitRemoteFileStatusHandler(ExtensionBaseHandler): + """ + Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/remote-file-status/{repo}. + """ + + @cache(max_age=15) + async def get(self, lecture_id: int, assignment_id: int, repo: str): + if repo not in {"assignment", "source", "release"}: + self.log.error(HTTPStatus.NOT_FOUND) + raise HTTPError( + HTTPStatus.NOT_FOUND, reason=f"Repository {repo} does not exist" + ) + lecture = await self.get_lecture(lecture_id) + assignment = await self.get_assignment(lecture_id, assignment_id) + file_path = self.get_query_argument('file') # Retrieve the file path from the query parameters + git_service = GitService( + server_root_dir=self.root_dir, + lecture_code=lecture["code"], + assignment_id=assignment["id"], + repo_type=repo, + config=self.config, + force_user_repo=True if repo == "release" else False, + ) + try: + if not git_service.is_git(): + git_service.init() + git_service.set_author(author=self.user_name) + git_service.set_remote(f"grader_{repo}") + git_service.fetch_all() + status = git_service.check_remote_file_status(file_path) + self.log.info(f"File {file_path} status: {status}") + except GitError as e: + self.log.error(e) + raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=str(e)) + self.write(status.name) + + @register_handler( path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/remote-status\/(?P\w*)\/?" ) diff --git a/grader_labextension/services/git.py b/grader_labextension/services/git.py index f53e808..74c5e87 100644 --- a/grader_labextension/services/git.py +++ b/grader_labextension/services/git.py @@ -38,6 +38,10 @@ class RemoteStatus(enum.Enum): push_needed = 3 divergent = 4 +class RemoteFileStatus(enum.Enum): + up_to_date = 1 + push_needed = 2 + divergent = 3 class GitService(Configurable): git_access_token = Unicode(os.environ.get("JUPYTERHUB_API_TOKEN"), allow_none=False).tag(config=True) @@ -319,6 +323,20 @@ def git_status(self, hidden_files: bool = False) -> Tuple[List[str], List[str], elif k == "D": deleted.append(v) return untracked, added, modified, deleted + + def check_remote_file_status(self, file_path: str) -> RemoteFileStatus: + file_status_list = self._run_command(f"git status --porcelain {file_path}", cwd=self.path, capture_output=True).split(maxsplit=1) + # Extract the status character from the list + if file_status_list: + file_status = file_status_list[0] + else: + # If the list is empty, the file is up-to-date + return RemoteFileStatus.up_to_date + # Convert the file status to the corresponding enum value + if file_status in {"??", "M", "A", "D"}: + return RemoteFileStatus.push_needed + else: + return RemoteFileStatus.divergent def local_branch_exists(self, branch: str) -> bool: ret_code = self._run_command(f"git rev-parse --quiet --verify {branch}", cwd=self.path, check=False).returncode diff --git a/package.json b/package.json index c9ced25..b06a9e8 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@jupyterlab/mainmenu": "^4.0.11", "@jupyterlab/notebook": "^4.0.2", "@jupyterlab/services": "^7.0.0", + "@jupyterlab/git": "^0.50.0", "@jupyterlab/settingregistry": "^4.0.0", "@jupyterlab/terminal": "^4.0.2", "@lumino/widgets": "^2.1.1", diff --git a/src/components/coursemanage/files/files.tsx b/src/components/coursemanage/files/files.tsx index 8c0653e..1429c6b 100644 --- a/src/components/coursemanage/files/files.tsx +++ b/src/components/coursemanage/files/files.tsx @@ -188,7 +188,7 @@ export const Files = (props: IFilesProps) => { * @param commitMessage the commit message */ const handlePushAssignment = async (commitMessage: string, selectedFiles: string[]) => { - console.log("Files to commit: " + selectedFiles); + // console.log("Files to commit: " + selectedFiles); showDialog( 'Push Assignment', `Do you want to push ${assignment.name}? This updates the state of the assignment on the server with your local state.`, @@ -363,6 +363,8 @@ export const Files = (props: IFilesProps) => { diff --git a/src/components/util/dialog.tsx b/src/components/util/dialog.tsx index 3526032..2e45bbc 100644 --- a/src/components/util/dialog.tsx +++ b/src/components/util/dialog.tsx @@ -33,7 +33,10 @@ import { TooltipProps, tooltipClasses, Typography, - Snackbar + Snackbar, + Modal, + Alert, + AlertTitle } from '@mui/material'; import { Assignment } from '../../model/assignment'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; @@ -55,6 +58,7 @@ import { updateMenus } from '../../menu'; import { GraderLoadingButton } from './loading-button'; import { FilesList } from './file-list'; import { extractRelativePaths, getFiles, lectureBasePath } from '../../services/file.service'; +import InfoIcon from '@mui/icons-material/Info'; const gradingBehaviourHelp = `Specifies the behaviour when a students submits an assignment.\n No Automatic Grading: No action is taken on submit.\n @@ -496,6 +500,49 @@ export const CreateDialog = (props: ICreateDialogProps) => { ); }; +const InfoModal = () => { + const [open, setOpen] = React.useState(false); + const handleOpen = () => { + setOpen(true); + }; + const handleClose = () => { + setOpen(false); + }; + return ( + + + + + + +

Manual Grading Information

+ + Info + If you have made changes to multiple files in your source directory and wish to push only specific + files to the remote repository, you can toggle the 'Select files to commit' button. This allows you to + choose the files you want to push. Your students will then be able to view only the changes in files you have selected. + If you do not use this option, all changed files from the source repository will be pushed, and students will see all the changes + + +
+
+
+ ); +}; + + export interface ICommitDialogProps { handleCommit: (msg: string, selectedFiles?: string[]) => void; children: React.ReactNode; @@ -552,14 +599,22 @@ export const CommitDialog = (props: ICommitDialogProps) => { fullWidth={true} maxWidth={'sm'} > - Commit Files + + Commit Files + + {filesListVisible && ( - + )} boolean; missingFiles?: File[]; openFile: (path: string) => void; @@ -28,6 +37,8 @@ interface IFileItemProps { const FileItem = ({ file, + lecture, + assignment, inContained, openFile, allowFiles, @@ -35,11 +46,77 @@ const FileItem = ({ checkboxes, onFileSelectChange }: IFileItemProps) => { + const inMissing = (filePath: string) => { return missingFiles.some(missingFile => missingFile.path === filePath); }; const [isSelected, setIsSelected] = React.useState(true); + const [fileRemoteStatus, setFileRemoteStatus] = React.useState( + null as 'up_to_date' | 'push_needed' | 'divergent' + ); + + React.useEffect(() => { + getRemoteFileStatus( + lecture, + assignment, + RepoType.SOURCE, + getRelativePath(file.path, 'source'), + true + ).then(status => { + setFileRemoteStatus( + status as 'up_to_date' | 'push_needed' | 'divergent' + ); + }); + }, [assignment, lecture]); + + const getFleRemoteStatusText = ( + status: 'up_to_date' | 'push_needed' | 'divergent' + ) => { + if (status === 'up_to_date') { + return 'The local files is up to date with the file from remote repository.' + } else if (status === 'push_needed') { + return 'You have made changes to this file locally, a push is needed.'; + } else { + return 'The local and remote file are divergent.'; + } + }; + + const getStatusChip = ( + status: 'up_to_date' | 'push_needed' | 'divergent' + ) => { + if (status === 'up_to_date') { + return ( + } + /> + ); + } else if (status === 'push_needed') { + return ( + } + /> + ); + } else { + return ( + } + /> + ); + } + }; const toggleSelection = () => { setIsSelected(prevState => { @@ -55,7 +132,6 @@ const FileItem = ({ const missingFileHelp = 'This file should be part of your assignment! Did you delete it?'; - return ( {checkboxes && ( @@ -77,6 +153,11 @@ const FileItem = ({ primary={{file.name}} secondary={ + {checkboxes && ( + + {getStatusChip(fileRemoteStatus)} + + )} {inMissing(file.path) && ( diff --git a/src/components/util/file-list.tsx b/src/components/util/file-list.tsx index dc6d5c0..928bdf9 100644 --- a/src/components/util/file-list.tsx +++ b/src/components/util/file-list.tsx @@ -88,6 +88,8 @@ export const FilesList = (props: IFileListProps) => { { boolean; openFile: (path: string) => void; allowFiles?: boolean; @@ -29,6 +33,8 @@ interface IFolderItemProps { const FolderItem = ({ folder, + lecture, + assigment, missingFiles, inContained, openFile, @@ -88,6 +94,8 @@ const FolderItem = ({ (HTTPMethod.GET, url, null, reload); } + +export function getRemoteFileStatus( + lecture: Lecture, + assignment: Assignment, + repo: RepoType, + filePath: string, + reload = false +): Promise { + const url = `/lectures/${lecture.id}/assignments/${assignment.id}/remote-file-status/${repo}/?file=${encodeURIComponent(filePath)}`; + return request(HTTPMethod.GET, url, null, reload); +}