-
-
Notifications
You must be signed in to change notification settings - Fork 31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implementing Collaborative Timeline Slider with Undo/Redo Functionality #338
Implementing Collaborative Timeline Slider with Undo/Redo Functionality #338
Conversation
Thanks for working on this @Meriem-BenIsmail ! Peek.2024-08-20.14-53.mp4Is there something I'm doing wrong? |
That's weird I tried to recreate your vid and it works for me Thanks for the feedback though, I will have a look at what might be causing the problem. |
add8143
to
05df889
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be super exciting to have, it would be a revolution compared to the existing .ipynb_checkpoints
mechanism!
I've tried to review that as if this PR was ready for review, I hope it is not too early to give fairly detailed feedback on the implementation.
import { | ||
FileBrowser, | ||
IDefaultFileBrowser, | ||
IFileBrowserFactory | ||
} from '@jupyterlab/filebrowser'; | ||
import { IStatusBar } from '@jupyterlab/statusbar'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am worried about the widget taking too much space in the statusbar; I think this is fine it as-is in this PR but I would consider moving it to a sidebar.
@@ -91,7 +94,7 @@ export const ynotebook: JupyterFrontEndPlugin<void> = { | |||
optional: [ISettingRegistry], | |||
activate: ( | |||
app: JupyterFrontEnd, | |||
drive: ICollaborativeDrive, | |||
drive: YDrive, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this change required? If not, I would suggest keeping the more general interface here to keep specific plugins compatible with different implementation of collaborative drive.
Of course a change in typing would not make them incompatible, but it would make it easy to later accidentally access properties/method which only occur in YDrive
but are absent in ICollaborativeDrive
(whereas the only guaranteed that the plugin system gives us is that we will receive an implementation of ICollaborativeDrive
)
drive: YDrive, | |
drive: ICollaborativeDrive, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Meriem-BenIsmail What do you think about @krassowski's comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I agree I changed it back to ICollaborativeDrive
and then added a type guard as he suggested.
requires: [IStatusBar], | ||
optional: [ICollaborativeDrive], | ||
activate: async ( | ||
app: JupyterFrontEnd, | ||
statusBar: IStatusBar, | ||
drive: YDrive | null | ||
): Promise<void> => { | ||
try { | ||
if (!drive) { | ||
console.warn('Collaborative drive not available'); | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- I think
ICollaborativeDrive
should be required here, as otherwise this plugin does nothing. This way users disabling theICollaborativeDrive
plugin in Plugin Manager will be notified that this plugin will also be disabled. - We cannot assume that we will always get an
YDrive
class instance. It can be a differentICollaborativeDrive
implementation. Hence, where you do requireYDrive
I would suggest the following typing and checks:
requires: [IStatusBar], | |
optional: [ICollaborativeDrive], | |
activate: async ( | |
app: JupyterFrontEnd, | |
statusBar: IStatusBar, | |
drive: YDrive | null | |
): Promise<void> => { | |
try { | |
if (!drive) { | |
console.warn('Collaborative drive not available'); | |
return; | |
} | |
requires: [IStatusBar, ICollaborativeDrive], | |
activate: async ( | |
app: JupyterFrontEnd, | |
statusBar: IStatusBar, | |
drive: YDrive | ICollaborativeDrive | |
): Promise<void> => { | |
try { | |
if (!isYDrive(drive)) { | |
console.warn('Collaborative drive not available'); | |
return; | |
} |
Where isYDrive
could be defined as a type guard like:
function isYDrive(drive: YDrive | ICollaborativeDrive): drive is YDrive {
return 'updateTimelineForNotebook' in drive; // or something smarter
}
} | ||
|
||
let sliderItem: Widget | null = null; | ||
const updateTimelineForDocument = async (document: any) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is any
here? It should be replaced with a proper type :)
@@ -144,7 +210,7 @@ export const defaultFileBrowser: JupyterFrontEndPlugin<IDefaultFileBrowser> = { | |||
], | |||
activate: async ( | |||
app: JupyterFrontEnd, | |||
drive: ICollaborativeDrive, | |||
drive: YDrive, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as before, I would suggest keeping it as ICollaborativeDrive
if this change is not strictly required:
drive: YDrive, | |
drive: ICollaborativeDrive, |
packages/docprovider/src/ydrive.ts
Outdated
return this._timelineWidget || null; | ||
} | ||
|
||
async updateTimelineForNotebook(notebookPath: string): Promise<void> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like it supports not only notebook but any documents, right? Later below I propose to rewrite this procedure with side effects into a more generic function, let's call it getProviderFor(path: string): Promise<WebSocketProvider>
packages/docprovider/src/ydrive.ts
Outdated
this._timelineWidget = new TimelineWidget( | ||
notebookPath, | ||
provider, | ||
options.contentType, | ||
options.format | ||
); | ||
} else { | ||
this.updateTimelineForNotebook(options.path); | ||
this._timelineWidget.update(); | ||
} | ||
const elt = document.getElementById('slider-status-bar'); | ||
if (elt && !this._timelineWidget.isAttached) { | ||
Widget.attach(this._timelineWidget, elt); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Conceptually, it is not a responsibility of the drive to know anything about widgets. Usually we would create a new "model" (like timelineModel
) to interface between one object knowing about multiple things and the widgets, but here a simpler solution might be possible.
Practically, querying by ID is a code smell.
Here is a proposal:
- let's move instationation and attachement of
TimelineWidget
to the@jupyter/docprovider-extension:statusBarTimeline
plugin (where it will be a breeze because you have the status bar element there widget reference) - replace
updateTimelineForNotebook
method onYDrive
withgetProviderForPath(path: string): Promise<IForkProvider>
where:and markinterface IForkProvider { connectToFork: (arguments) => Promise<someType>; contentType: string; format: string; }
WebSocketProvider
as implementingIForkProvider
interface - if absolutely needed add a signal on
YDrive
such asmodelCreated: Signal<YDrive, IModelCreatedArgs>
so that you can callgetProviderForPath()
immediately once this_onCreate()
is called, where:but maybe you could just use document tracker instead.interface IModelCreatedArgs { options: Contents.ISharedFactoryOptions; sharedModel: YDocument<DocumentChange>; }
This should enable you to update the widget without having to built it into the drive (as you only need to get hold of IForkProvider
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe getProviderForPath
→ getForkProvider
?
get sharedModel(): YDocument<DocumentChange> { | ||
return this._sharedModel; | ||
} | ||
setPath(path: string) { | ||
this._path = path; | ||
} | ||
setSharedModel(sharedModel: YDocument<DocumentChange>) { | ||
this._sharedModel = sharedModel; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get sharedModel(): YDocument<DocumentChange> { | |
return this._sharedModel; | |
} | |
setPath(path: string) { | |
this._path = path; | |
} | |
setSharedModel(sharedModel: YDocument<DocumentChange>) { | |
this._sharedModel = sharedModel; | |
} |
Is this used anywhere? It would be a significant expansion to the public API, let's keep it small to make it managable to maintain.
async def get(self, path: str) -> None: | ||
|
||
file_id_manager = self.settings["file_id_manager"] | ||
file_id = file_id_manager.get_id("/".join(self.request.path.split("/")[4:])) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks odd. The code removes four parts of the path - why? I think that elsewhere we just access the path as-is. I would guess that it was meant to remove four letters RTC:
if present but this is just a guess.
gap: 6px; | ||
} | ||
|
||
.restore-btn button { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bare elements (without e.g. a class name) on the right-most side are bad for performance because browsers match CSS selectors from right to left. A browser would need to check rest of the rule against all buttons on the page which might be many.
dependency conflicts fixed timestamp order fork handler added connect fork read only notebook connected (fork) fork document in frontend fork in the frontend + apply updates added fork indication to the page added fork indication to the page distinction between fork and original document added to slider adjustement in fork handler file add css slider context menu option for opening file (fork) + timeline added notebook condition added file menu option to open timeline for notebook alternative: add file menu option to open right widget added timeline slider to status bar slider added to status bar status bar timeline slider with logic to switch between notebooks added error handling in the backend handler clean console add isActive to the registerStatusItem fix: internal server error fixed in updateTimelineforNotebook function fork cnd added to timeline status bar plugin undo manager refactoring fork logic for 1 notebook. fork logic implemented for multiple notebooks opened at the same time ydoc.share fork works with multiple notebooks + freezing notebook when sliding through timeline notebook id testing cad undo manager tests clearDoc method remove is timelineFork open rebase build error resolved using undo manager in the backend undo/redo steps added freeze document updated to the new version of pycrdt, fixed problem with undostack rebase restore version btn undo manager agnostic for different type of documents restoring version restoring version restore btn : fix style delete unused files delete unused files jupytercad jupyter cad jupytercad integration + rebase main conflicts conflicts fixed console error: empty response. icon visible only when data is not null moving fetch timeline in slider component: on click of the history button get format & content type from query params get format & content type from query params: fix updating contenttype and format when switching between documents remove sharedmodel from update content support for documents inside folders/subfolders. clean drive.ts move test files in folder delete unused dependency return comments deleted by accident fixes in jupyter-server-ydoc delete test documents add test-folder to gitignore styling restore button styling restore button pre commit hooks fixed pre commit issues fixed pre commit issues add license header to new files pre commit hooks python test: added jupytercad_core to CI/CD workflow python test debug python test debug collaborative timeline slider added in the status bar dependency conflicts fixed timestamp order fork handler added connect fork read only notebook connected (fork) fork document in frontend fork in the frontend + apply updates added fork indication to the page added fork indication to the page distinction between fork and original document added to slider adjustement in fork handler file add css slider context menu option for opening file (fork) + timeline added notebook condition added file menu option to open timeline for notebook alternative: add file menu option to open right widget added timeline slider to status bar slider added to status bar status bar timeline slider with logic to switch between notebooks added error handling in the backend handler clean console add isActive to the registerStatusItem fix: internal server error fixed in updateTimelineforNotebook function fork cnd added to timeline status bar plugin undo manager refactoring fork logic for 1 notebook. fork logic implemented for multiple notebooks opened at the same time ydoc.share fork works with multiple notebooks + freezing notebook when sliding through timeline notebook id testing cad undo manager tests clearDoc method remove is timelineFork open rebase build error resolved using undo manager in the backend undo/redo steps added freeze document updated to the new version of pycrdt, fixed problem with undostack rebase restore version btn undo manager agnostic for different type of documents restoring version restoring version restore btn : fix style delete unused files delete unused files jupytercad jupyter cad jupytercad integration + rebase main conflicts conflicts fixed console error: empty response. icon visible only when data is not null moving fetch timeline in slider component: on click of the history button get format & content type from query params get format & content type from query params: fix updating contenttype and format when switching between documents remove sharedmodel from update content support for documents inside folders/subfolders. clean drive.ts move test files in folder delete unused dependency return comments deleted by accident fixes in jupyter-server-ydoc delete test documents add test-folder to gitignore styling restore button styling restore button pre commit hooks fixed pre commit issues python test debug: test.yaml python test debug: test.yaml python test debug: test.yaml changed order of dependencies in test.yml removed jupytercad to test dependencies version removed jupytercad to test dependencies version pre commit changed the way document types are imported in the backend fixed yarn.lock after rebase
4fbab40
to
169cc00
Compare
I am still unable to get this PR working. Here is what I did: micromamba create -n jupyter-collaboration
micromamba activate jupyter-collaboration
micromamba install pip nodejs
pip install -e ".[dev]"
pip install -e projects/jupyter-collaboration-ui -e projects/jupyter-docprovider -e projects/jupyter-server-ydoc
pip install jupyterlab
jupyter labextension develop --overwrite projects/jupyter-collaboration-ui
jupyter labextension develop --overwrite projects/jupyter-docprovider @Meriem-BenIsmail can you confirm that it works on your side in a fresh environment? |
I just tested it in a fresh environment following the same commands as you did here. It seems to work from me. |
Ah it seems that it only works the first time a notebook is opened, but not after closing and reopening it: Peek.2024-08-27.11-12.mp4 |
Oh I see I haven't noticed this. It works also when you reload the page btw. |
It seems that the changes don't appear at the same place on the slider depending on whether you're undoing or redoing: Peek.2024-08-27.16-40.mp4 |
I think that has to do with the fact that the undo manager doesn't track empty updates, it only tracks relevant modifications to the document. But when fetching the updates/timestamps of a document from the ystore, I see that some of the updates aren't modifications to the file itself I'm not sure about the reason. |
What about setting the slider's step number to the initial undo stack's length? |
Will I also get the timestamps to display them in the frontend from the undostack that way ? |
I think so yes, for instance when you apply all the updates to the fork, you can check if the document's undo stack grows after each applied update, and build a timestamp list that corresponds to each stack item this way. |
There is something weird happening when after a document was restored to a previous state:
Peek.2024-08-28.15-46.mp4 |
You don't reconnect to the root document after restoring? |
I could do that but I would still need to reload the document if I want to use the timeline again. |
The document would reload by itself, just by reconnecting to the root. |
but i still need to refetch the new timeline so i would need to reload the browser and i don't think that happens automatically if i reconnect. |
Refreshing the browser is not an option but you can surely update the timeline. |
Thanks for keeping up the great work @Meriem-BenIsmail, this is starting to look really nice! I like the user experience. if action == "undo" and len(undo_manager.undo_stack) > 1:
undo_manager.undo() I suppose this has something to do with the fact that JupyterLab doesn't like empty notebooks (see this issue)? For text files however, this means that we cannot go back to to an empty file: Peek.2024-08-29.09-16.mp4 |
that would require a special handling of notebooks. Do you have an idea if this issue only appears in notebooks ? |
Potentially yes, but it can be considered as a bug if a document doesn't allow an "empty state", so let's keep it like that for now, and revisit the issue if/when jupyterlab/jupyterlab#16718 is fixed. |
You need to require |
I think this is looking good. I'll give more time for review, and merge tomorrow otherwise. |
@krassowski were all your requested changes applied? |
Thanks again @Meriem-BenIsmail! |
Sorry for not getting back here earlier, got sick and did not remember to comment back - most of the things were addressed so good call to merge! |
Description:
This pull request introduces a comprehensive timeline and undo/redo functionality to the Jupyter Collaboration project. The primary goal of this feature is to enhance the collaborative experience by allowing users to track changes, restore previous versions of documents, and seamlessly undo or redo actions.
Key Features:
TimelineSliderComponent
) that provides an interactive slider to navigate through the history of document changes.restore
mode.Usage:
Note: The timeline is tested with various document types, including notebooks and files in different directory structures and also YJCad files of JupyterCAD.
Here is a demo of the functionality