Skip to content
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

Merged

Conversation

Meriem-BenIsmail
Copy link
Contributor

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:

  1. Timeline Slider Component:
    • Added a component (TimelineSliderComponent) that provides an interactive slider to navigate through the history of document changes.
    • The slider fetches and displays timestamps of changes, allowing users to select specific versions of the document and to restore them if needed.
  2. Document Forking and Restoration:
    • Implemented functions to fork the current state of the document and create new versions.
    • Users can restore the document to any previous state using the restore mode.
  3. Undo/Redo Management:
    • Integrated UndoManager to handle undo and redo operations for document edits.
    • Users can perform multiple undo/redo actions to revert or reapply changes.
  4. API Enhancements:
    • Extended the existing API to support timeline fetching and document restoration.
    • Introduced new endpoints for creating document forks and managing undo/redo operations.
  5. Notifications:
    • Implemented user notifications to provide feedback on actions such as successful document restoration or errors.

Usage:

  1. Accessing the Timeline:
    • Click on the history icon in the status bar to open the timeline slider.
    • Use the slider to navigate through different versions of the document.
  2. Restoring a Version:
    • Select a desired timestamp from the slider and click the "Restore version" button to revert the document to the selected state.
    • A notification will confirm the successful restoration or display an error if the operation fails.
  3. Undo/Redo Actions:
    • Use the timeline slider to perform undo/redo operations by moving back and forth between timestamps.

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
timeline-rtc-feature

Copy link
Contributor

Binder 👈 Launch a Binder on branch Meriem-BenIsmail/jupyter-collaboration/collaborative-timeline-feature

@davidbrochart davidbrochart added the enhancement New feature or request label Aug 20, 2024
@Meriem-BenIsmail Meriem-BenIsmail marked this pull request as ready for review August 20, 2024 08:01
@davidbrochart
Copy link
Collaborator

Thanks for working on this @Meriem-BenIsmail !
I just tried your branch, and I'm not able to go back in time:

Peek.2024-08-20.14-53.mp4

Is there something I'm doing wrong?

@Meriem-BenIsmail
Copy link
Contributor Author

Meriem-BenIsmail commented Aug 20, 2024

Thanks for working on this @Meriem-BenIsmail ! I just tried your branch, and I'm not able to go back in time:

Peek.2024-08-20.14-53.mp4
Is there something I'm doing wrong?

That's weird I tried to recreate your vid and it works for me
prb

Thanks for the feedback though, I will have a look at what might be causing the problem.
Note: when the timeline is opened and we start using the slider, the document displayed is a fork so any changes made after using the slider aren't saved. This is done to prevent any unwanted modifications when going through the history of the document.

@Meriem-BenIsmail Meriem-BenIsmail changed the title Implementing Collaborative Timeline Slider with Undo/Redo Functionality for Jupyter Collaboration Implementing Collaborative Timeline Slider with Undo/Redo Functionality Aug 23, 2024
@krassowski krassowski self-requested a review August 23, 2024 20:33
Copy link
Member

@krassowski krassowski left a 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';
Copy link
Member

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,
Copy link
Member

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)

Suggested change
drive: YDrive,
drive: ICollaborativeDrive,

Copy link
Collaborator

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?

Copy link
Contributor Author

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.

Comment on lines 140 to 151
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;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I think ICollaborativeDrive should be required here, as otherwise this plugin does nothing. This way users disabling the ICollaborativeDrive plugin in Plugin Manager will be notified that this plugin will also be disabled.
  2. We cannot assume that we will always get an YDrive class instance. It can be a different ICollaborativeDrive implementation. Hence, where you do require YDrive I would suggest the following typing and checks:
Suggested change
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) => {
Copy link
Member

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,
Copy link
Member

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:

Suggested change
drive: YDrive,
drive: ICollaborativeDrive,

return this._timelineWidget || null;
}

async updateTimelineForNotebook(notebookPath: string): Promise<void> {
Copy link
Member

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>

Comment on lines 280 to 292
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);
Copy link
Member

@krassowski krassowski Aug 23, 2024

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 on YDrive with getProviderForPath(path: string): Promise<IForkProvider> where:
    interface IForkProvider {
      connectToFork: (arguments) => Promise<someType>;
      contentType: string;
      format: string;
    }
    and mark WebSocketProvider as implementing IForkProvider interface
  • if absolutely needed add a signal on YDrive such as modelCreated: Signal<YDrive, IModelCreatedArgs> so that you can call getProviderForPath() immediately once this _onCreate() is called, where:
    interface IModelCreatedArgs {
       options: Contents.ISharedFactoryOptions;
       sharedModel: YDocument<DocumentChange>;
    }
    but maybe you could just use document tracker instead.

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)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe getProviderForPathgetForkProvider?

Comment on lines 101 to 106
get sharedModel(): YDocument<DocumentChange> {
return this._sharedModel;
}
setPath(path: string) {
this._path = path;
}
setSharedModel(sharedModel: YDocument<DocumentChange>) {
this._sharedModel = sharedModel;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:]))
Copy link
Member

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 {
Copy link
Member

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
@davidbrochart
Copy link
Collaborator

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?
@krassowski did it work on your side?

@Meriem-BenIsmail
Copy link
Contributor Author

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? @krassowski did it work on your side?

I just tested it in a fresh environment following the same commands as you did here. It seems to work from me.
Are you having any error (console or at runtime) that may help me figure out what exactly is going wrong ?

@davidbrochart
Copy link
Collaborator

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

@Meriem-BenIsmail
Copy link
Contributor Author

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.
I will look into this.

@davidbrochart
Copy link
Collaborator

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

@Meriem-BenIsmail
Copy link
Contributor Author

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.

@davidbrochart
Copy link
Collaborator

What about setting the slider's step number to the initial undo stack's length?

@Meriem-BenIsmail
Copy link
Contributor Author

Meriem-BenIsmail commented Aug 28, 2024

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 ?

@davidbrochart
Copy link
Collaborator

davidbrochart commented Aug 28, 2024

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.

@davidbrochart
Copy link
Collaborator

There is something weird happening when after a document was restored to a previous state:

  • when restored, the dirty indicator should be cleared, meaning that the file has been saved.
  • after reopening the file, the timeline seems to be broken:
Peek.2024-08-28.15-46.mp4

@Meriem-BenIsmail
Copy link
Contributor Author

There is something weird happening when after a document was restored to a previous state:

  • when restored, the dirty indicator should be cleared, meaning that the file has been saved.
  • after reopening the file, the timeline seems to be broken:

Peek.2024-08-28.15-46.mp4

I think the dirty indicator is not cleared because even if we could restore the document state, the displayed version is a still a fork so maybe that's the cause.
Also I tried to recreate your case here but I don't get the same behavior, could you walk me through the problem exactly ?
prb

@davidbrochart
Copy link
Collaborator

I think the dirty indicator is not cleared because even if we could restore the document state, the displayed version is a still a fork so maybe that's the cause.

You don't reconnect to the root document after restoring?

@Meriem-BenIsmail
Copy link
Contributor Author

I think the dirty indicator is not cleared because even if we could restore the document state, the displayed version is a still a fork so maybe that's the cause.

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.

@davidbrochart
Copy link
Collaborator

The document would reload by itself, just by reconnecting to the root.

@Meriem-BenIsmail
Copy link
Contributor Author

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.

@davidbrochart
Copy link
Collaborator

Refreshing the browser is not an option but you can surely update the timeline.

@davidbrochart
Copy link
Collaborator

Thanks for keeping up the great work @Meriem-BenIsmail, this is starting to look really nice! I like the user experience.
One thing though is that I see you don't allow to undo the very first change here:

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

@Meriem-BenIsmail
Copy link
Contributor Author

Thanks for keeping up the great work @Meriem-BenIsmail, this is starting to look really nice! I like the user experience. One thing though is that I see you don't allow to undo the very first change here:

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 ?

@davidbrochart
Copy link
Collaborator

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.

@davidbrochart
Copy link
Collaborator

You need to require pycrdt-websocket>=0.14.2,<0.15.0 here.

@davidbrochart
Copy link
Collaborator

I think this is looking good. I'll give more time for review, and merge tomorrow otherwise.

@davidbrochart
Copy link
Collaborator

@krassowski were all your requested changes applied?

@davidbrochart davidbrochart merged commit 1492678 into jupyterlab:main Sep 4, 2024
20 checks passed
@davidbrochart
Copy link
Collaborator

Thanks again @Meriem-BenIsmail!

@krassowski
Copy link
Member

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants