Skip to content

Commit 8889e4d

Browse files
agriyakhetarpalgithub-actions[bot]krassowski
authored
Do not trigger the save reminder or leave confirmation dialogue on empty and View Only notebooks (#202)
* Add a helper to identify empty notebooks * Refactor `generateDefaultNotebookName` into utils * No leave confirmation for view-only/empty notebooks * Send save reminder toast on non-empty notebooks * Add tests * Fix lint * Update Playwright Snapshots * Partially revert "Update Playwright Snapshots" This reverts commit 724ba17. * Fix again * Again, reset the reminder timer on manual save * Distinguish b/w first and subsequent manual saves * Fix order of saves and manual save counter up * Don't trigger notifications on View Only notebooks * Rename counter, add manual save marker, mandatory `onFire` Co-Authored-By: Michał Krassowski <[email protected]> * Make `onManualSave` non-optional --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Michał Krassowski <[email protected]>
1 parent a736a48 commit 8889e4d

File tree

8 files changed

+233
-66
lines changed

8 files changed

+233
-66
lines changed

src/index.ts

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Commands } from './commands';
1919
// import { competitions } from './pages/competitions';
2020
import { notebookPlugin } from './pages/notebook';
2121
// import { helpPlugin } from './pages/help';
22-
import { generateDefaultNotebookName } from './notebook-name';
22+
import { generateDefaultNotebookName, isNotebookEmpty } from './notebook-utils';
2323
import {
2424
IViewOnlyNotebookTracker,
2525
viewOnlyNotebookFactoryPlugin,
@@ -87,14 +87,22 @@ async function showShareDialog(sharingService: SharingService, notebookContent:
8787
async function handleNotebookSharing(
8888
notebookPanel: NotebookPanel | ViewOnlyNotebookPanel,
8989
sharingService: SharingService,
90-
manual: boolean
90+
manual: boolean,
91+
onManualSave: () => void
9192
) {
9293
const notebookContent = notebookPanel.context.model.toJSON() as INotebookContent;
9394

9495
const isViewOnly = notebookContent.metadata?.isSharedNotebook === true;
9596
const sharedId = notebookContent.metadata?.sharedId as string | undefined;
9697
const defaultName = generateDefaultNotebookName();
9798

99+
// Mark that the user has performed at least one manual save in this session.
100+
// We do this early in the manual flow for clarity; the local save already happened
101+
// in the command handlers and this flag only affects reminder wording.
102+
if (manual && !isViewOnly) {
103+
onManualSave();
104+
}
105+
98106
try {
99107
if (isViewOnly) {
100108
// Skip CKHub sync for view-only notebooks
@@ -137,22 +145,6 @@ async function handleNotebookSharing(
137145
}
138146
}
139147

140-
/**
141-
* Helper to start the save reminder timer. Clears any existing timer
142-
* and sets a new one to show the notification after 5 minutes.
143-
*/
144-
function startSaveReminder(currentTimeout: number | null): number {
145-
if (currentTimeout) {
146-
window.clearTimeout(currentTimeout);
147-
}
148-
return window.setTimeout(() => {
149-
Notification.info(
150-
"It's been 5 minutes since you've been working on this notebook. Make sure to save the link to your notebook to edit your work later.",
151-
{ autoClose: 8000 }
152-
);
153-
}, 300 * 1000); // once after 5 minutes
154-
}
155-
156148
/**
157149
* JUPYTEREVERYWHERE EXTENSION
158150
*/
@@ -185,7 +177,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
185177
// Skip auto-sync if it's a manual share.
186178
return;
187179
}
188-
await handleNotebookSharing(widget, sharingService, false);
180+
await handleNotebookSharing(widget, sharingService, false, () => {});
189181
}
190182
});
191183
});
@@ -296,9 +288,21 @@ const plugin: JupyterFrontEndPlugin<void> = {
296288
}
297289
});
298290

291+
// Track user time, and show a reminder to save the notebook once after
292+
// five minutes of editing (i.e., once it becomes non-empty and dirty)
293+
// using a toast notification.
294+
let saveReminderTimeout: number | null = null;
295+
let isSaveReminderScheduled = false; // a 5-minute timer is scheduled, but it hasn't fired yet
296+
let hasShownSaveReminder = false; // we've already shown the toast once for this notebook
297+
let hasManuallySaved = false; // whether the user has manually saved at least once in this session
298+
299299
/**
300300
* Add custom Share notebook command
301301
*/
302+
const markManualSave = () => {
303+
hasManuallySaved = true;
304+
};
305+
302306
commands.addCommand(Commands.shareNotebookCommand, {
303307
label: 'Share Notebook',
304308
execute: async () => {
@@ -318,7 +322,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
318322
// Save the notebook before we share it.
319323
await notebookPanel.context.save();
320324

321-
await handleNotebookSharing(notebookPanel, sharingService, true);
325+
await handleNotebookSharing(notebookPanel, sharingService, true, markManualSave);
322326
} catch (error) {
323327
console.error('Error in share command:', error);
324328
}
@@ -344,7 +348,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
344348
}
345349
manuallySharing.add(panel);
346350
await panel.context.save();
347-
await handleNotebookSharing(panel, sharingService, true);
351+
await handleNotebookSharing(panel, sharingService, true, markManualSave);
348352
}
349353
});
350354

@@ -476,25 +480,100 @@ const plugin: JupyterFrontEndPlugin<void> = {
476480
}
477481
}
478482
});
479-
// Track user time, and show a reminder to save the notebook after
480-
// five minutes using a toast notification.
481-
// Then reset the timer when the notebook is saved manually.
482-
let saveReminderTimeout: number | null = null;
483+
484+
/**
485+
* Helper to start the save reminder timer. Clears any existing timer
486+
* and sets a new one to show the notification after 5 minutes.
487+
*/
488+
function startSaveReminder(currentTimeout: number | null, onFire: () => void): number {
489+
if (currentTimeout) {
490+
window.clearTimeout(currentTimeout);
491+
}
492+
return window.setTimeout(() => {
493+
const message = hasManuallySaved
494+
? "It's been 5 minutes since you last saved this notebook. Make sure to save the link to your notebook to edit your work later."
495+
: "It's been 5 minutes since you've been working on this notebook. Make sure to save the link to your notebook to edit your work later.";
496+
497+
Notification.info(message, { autoClose: 8000 });
498+
onFire();
499+
}, 300 * 1000); // once after 5 minutes
500+
}
483501

484502
tracker.widgetAdded.connect((_, panel) => {
485503
if (saveReminderTimeout) {
486504
window.clearTimeout(saveReminderTimeout);
505+
saveReminderTimeout = null;
487506
}
507+
isSaveReminderScheduled = false;
508+
hasShownSaveReminder = false;
509+
510+
const maybeScheduleSaveReminder = () => {
511+
if (hasShownSaveReminder) {
512+
return;
513+
}
514+
515+
const content = panel.context.model.toJSON() as INotebookContent;
516+
// Skip for view-only notebooks
517+
if (panel.context.model.readOnly || content.metadata?.isSharedNotebook === true) {
518+
return;
519+
}
520+
// Schedule after the notebook becomes non-empty
521+
if (isNotebookEmpty(content)) {
522+
return;
523+
}
524+
if (isSaveReminderScheduled) {
525+
return;
526+
}
527+
528+
isSaveReminderScheduled = true;
529+
saveReminderTimeout = startSaveReminder(saveReminderTimeout, () => {
530+
hasShownSaveReminder = true;
531+
isSaveReminderScheduled = false;
532+
});
533+
};
488534

489-
panel.context.ready.then(() => {
490-
saveReminderTimeout = startSaveReminder(saveReminderTimeout);
535+
// After the model is ready, check immediately and on any content change.
536+
void panel.context.ready.then(() => {
537+
// We cover the case where the notebook loads already non-empty, say,
538+
// if the user uploads a notebook into the application.
539+
maybeScheduleSaveReminder();
540+
panel.context.model.contentChanged.connect(() => {
541+
maybeScheduleSaveReminder(); // schedule when first content appears
542+
});
491543

544+
// Reset the reminder timer whenever the user saves manually.
545+
// We clear any pending timer and wait for the next edit (dirty state)
546+
// to schedule a fresh 5-minute reminder.
492547
panel.context.saveState.connect((_, state) => {
493548
if (state === 'completed') {
494-
saveReminderTimeout = startSaveReminder(saveReminderTimeout);
549+
if (saveReminderTimeout) {
550+
window.clearTimeout(saveReminderTimeout);
551+
saveReminderTimeout = null;
552+
}
553+
isSaveReminderScheduled = false;
554+
hasShownSaveReminder = false;
555+
// Note: we do not reschedule here; it will be scheduled on the next content change
556+
// once the notebook becomes dirty again.
495557
}
496558
});
497559
});
560+
561+
// If a view-only notebook is opened or becomes active, ensure no reminder can fire.
562+
readonlyTracker.widgetAdded.connect(() => {
563+
if (saveReminderTimeout) {
564+
window.clearTimeout(saveReminderTimeout);
565+
saveReminderTimeout = null;
566+
}
567+
isSaveReminderScheduled = false;
568+
hasShownSaveReminder = false;
569+
});
570+
571+
panel.disposed.connect(() => {
572+
if (saveReminderTimeout) {
573+
window.clearTimeout(saveReminderTimeout);
574+
saveReminderTimeout = null;
575+
}
576+
});
498577
});
499578
}
500579
};

src/notebook-name.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

src/notebook-utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { INotebookContent, MultilineString } from '@jupyterlab/nbformat';
2+
3+
const toText = (src?: MultilineString): string => (Array.isArray(src) ? src.join('') : (src ?? ''));
4+
5+
/**
6+
* Iterates over all cells of a notebook and returns true the notebook has no meaningful
7+
* content. We consider a notebook "non-empty" if at least one cell has a populated
8+
* non-whitespace source.
9+
* @param nb - the notebook to check if it's empty
10+
* @returns - a boolean indicating whether the notebook is empty or not.
11+
*/
12+
export function isNotebookEmpty(nb?: Partial<INotebookContent>): boolean {
13+
const cells = nb?.cells ?? [];
14+
if (cells.length === 0) {
15+
return true;
16+
}
17+
18+
for (const cell of cells) {
19+
if (/\S/.test(toText(cell?.source as MultilineString | undefined))) {
20+
return false;
21+
}
22+
}
23+
return true;
24+
}
25+
26+
/**
27+
* Generates a default notebook name based on the current date and time.
28+
*
29+
* @returns A string representing the default notebook name, with
30+
* the format: "Notebook_YYYY-MM-DD_HH-MM-SS"
31+
*/
32+
export function generateDefaultNotebookName(): string {
33+
const now = new Date();
34+
35+
const pad = (n: number) => n.toString().padStart(2, '0');
36+
37+
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
38+
const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
39+
40+
return `Notebook_${date}_${time}`;
41+
}

src/sidebar.ts

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,21 @@ import { EverywhereIcons } from './icons';
77
import { LEAVE_CONFIRMATION_TITLE, LeaveConfirmation } from './ui-components/LeaveConfirmation';
88
import { Commands } from './commands';
99

10+
import { INotebookTracker } from '@jupyterlab/notebook';
11+
import { INotebookContent } from '@jupyterlab/nbformat';
12+
import { IViewOnlyNotebookTracker } from './view-only';
13+
import { isNotebookEmpty } from './notebook-utils';
14+
1015
export const customSidebar: JupyterFrontEndPlugin<void> = {
1116
id: 'jupytereverywhere:sidebar',
1217
autoStart: true,
13-
requires: [ILabShell],
14-
activate: (app: JupyterFrontEnd, shell: ILabShell) => {
18+
requires: [ILabShell, INotebookTracker, IViewOnlyNotebookTracker],
19+
activate: (
20+
app: JupyterFrontEnd,
21+
shell: ILabShell,
22+
tracker: INotebookTracker,
23+
readonlyTracker: IViewOnlyNotebookTracker
24+
) => {
1525
// Overwrite behaviour of the sidebar panel
1626
const leftHandler = shell['_leftHandler'];
1727
const sidebar: TabBar<Widget> = leftHandler._sideBar;
@@ -31,7 +41,7 @@ export const customSidebar: JupyterFrontEndPlugin<void> = {
3141
if (newWidget && newWidget instanceof SidebarIcon) {
3242
const cancel = newWidget.execute();
3343
if (cancel) {
34-
console.log('Attempting to revert to:', oldWidget.label);
44+
console.log('Attempting to revert to:', oldWidget?.label);
3545
if (args.previousTitle) {
3646
const previousIndex = sidebar.titles.indexOf(oldWidget);
3747
if (previousIndex >= 0) {
@@ -54,26 +64,51 @@ export const customSidebar: JupyterFrontEndPlugin<void> = {
5464
icon: EverywhereIcons.logo,
5565
execute: () => {
5666
void (async () => {
57-
const result = await showDialog({
58-
title: LEAVE_CONFIRMATION_TITLE,
59-
body: new LeaveConfirmation(),
60-
buttons: [
61-
Dialog.cancelButton({ label: 'Cancel' }),
62-
Dialog.okButton({ label: 'Yes' })
63-
],
64-
defaultButton: 0
65-
});
67+
const readOnlyNotebookPanel = readonlyTracker.currentWidget;
68+
const notebookPanel = tracker.currentWidget;
6669

67-
if (result.button.label === 'Yes') {
68-
try {
69-
await app.commands.execute(Commands.shareNotebookCommand);
70-
} catch (error) {
71-
console.error(
72-
'Failed to share notebook before leaving to the Landing page:',
73-
error
74-
);
75-
}
70+
// If a view-only notebook is open: skip dialog and go home,
71+
// as we cannot save the notebook anyway + we can assume the
72+
// user retrieved the URL from somewhere (either someone else's
73+
// or their own notebook) to paste or share in the first place
74+
if (readOnlyNotebookPanel) {
7675
window.location.href = '/index.html';
76+
return;
77+
}
78+
79+
// If we have a new notebook, decide based on emptiness.
80+
if (notebookPanel) {
81+
const content = notebookPanel.context.model.toJSON() as INotebookContent;
82+
const empty = isNotebookEmpty(content);
83+
84+
if (empty) {
85+
window.location.href = '/index.html';
86+
return;
87+
}
88+
89+
// Non-empty regular notebook -> confirm and optionally save/share
90+
const result = await showDialog({
91+
title: LEAVE_CONFIRMATION_TITLE,
92+
body: new LeaveConfirmation(),
93+
buttons: [
94+
Dialog.cancelButton({ label: 'Cancel' }),
95+
Dialog.okButton({ label: 'Yes' })
96+
],
97+
defaultButton: 0
98+
});
99+
100+
if (result.button.label === 'Yes') {
101+
try {
102+
await app.commands.execute(Commands.shareNotebookCommand);
103+
} catch (error) {
104+
console.error(
105+
'Failed to share notebook before leaving to the Landing page:',
106+
error
107+
);
108+
}
109+
window.location.href = '/index.html';
110+
}
111+
return;
77112
}
78113
})();
79114

src/ui-components/share-dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ReactWidget } from '@jupyterlab/apputils';
22
import React from 'react';
33

4-
import { generateDefaultNotebookName } from '../notebook-name';
4+
import { generateDefaultNotebookName } from '../notebook-utils';
55

66
/**
77
* Share dialog data interface.

0 commit comments

Comments
 (0)