UNPKG

7.59 kBPlain TextView Raw
1/*
2 * Copyright (c) Jupyter Development Team.
3 * Distributed under the terms of the Modified BSD License.
4 */
5
6import { PageConfig } from '@jupyterlab/coreutils';
7import { Contents, ServerConnection } from '@jupyterlab/services';
8import { IStateDB } from '@jupyterlab/statedb';
9import { Debouncer } from '@lumino/polling';
10import { ISignal, Signal } from '@lumino/signaling';
11import { IRecentsManager, RecentDocument } from './tokens';
12
13/**
14 * Manager for recently opened and closed documents.
15 */
16export class RecentsManager implements IRecentsManager {
17 constructor(options: RecentsManager.IOptions) {
18 this._saveDebouncer = new Debouncer(this._save.bind(this), 500);
19 this._stateDB = options.stateDB;
20 this._contentsManager = options.contents;
21 this.updateRootDir();
22
23 this._loadRecents().catch(r => {
24 console.error(`Failed to load recent list from state:\n${r}`);
25 });
26 }
27
28 /**
29 * Whether the manager is disposed or not.
30 */
31 get isDisposed(): boolean {
32 return this._isDisposed;
33 }
34
35 /**
36 * List of recently opened items
37 */
38 get recentlyOpened(): RecentDocument[] {
39 const recents = this._recents.opened || [];
40 return recents.filter(r => r.root === this._serverRoot);
41 }
42
43 /**
44 * List of recently opened items
45 */
46 get recentlyClosed(): RecentDocument[] {
47 const recents = this._recents.closed || [];
48 return recents.filter(r => r.root === this._serverRoot);
49 }
50
51 /**
52 * Signal emitted when the recent list changes.
53 */
54 get changed(): ISignal<IRecentsManager, void> {
55 return this._recentsChanged;
56 }
57
58 /**
59 * Maximal number of recent items to list.
60 */
61 get maximalRecentsLength(): number {
62 return this._maxRecentsLength;
63 }
64 set maximalRecentsLength(value: number) {
65 this._maxRecentsLength = Math.round(Math.max(1, value));
66 let changed = false;
67 for (const type of ['opened', 'closed']) {
68 if (this._recents[type].length > this._maxRecentsLength) {
69 this._recents[type].length = this._maxRecentsLength;
70 changed = true;
71 }
72 }
73 if (changed) {
74 this._recentsChanged.emit(undefined);
75 }
76 }
77
78 /**
79 * Dispose recent manager resources
80 */
81 dispose(): void {
82 if (this.isDisposed) {
83 return;
84 }
85 this._isDisposed = true;
86 Signal.clearData(this);
87 this._saveDebouncer.dispose();
88 }
89
90 /**
91 * Add a new path to the recent list.
92 */
93 addRecent(
94 document: Omit<RecentDocument, 'root'>,
95 event: 'opened' | 'closed'
96 ): void {
97 const recent: RecentDocument = {
98 ...document,
99 root: this._serverRoot
100 };
101 const recents = this._recents[event];
102 // Check if it's already present; if so remove it
103 const existingIndex = recents.findIndex(r => r.path === document.path);
104 if (existingIndex >= 0) {
105 recents.splice(existingIndex, 1);
106 }
107 // Add to the front of the list
108 recents.unshift(recent);
109
110 this._setRecents(recents, event);
111 this._recentsChanged.emit(undefined);
112 }
113
114 /**
115 * Clear the recents list
116 */
117 clearRecents(): void {
118 this._setRecents([], 'opened');
119 this._setRecents([], 'closed');
120 this._recentsChanged.emit(undefined);
121 }
122
123 /**
124 * Remove the document from recents list.
125 */
126 removeRecent(document: RecentDocument, event: 'opened' | 'closed'): void {
127 this._removeRecent(document.path, [event]);
128 }
129
130 /**
131 * Check if the recent item is valid, remove if it from both lists if it is not.
132 */
133 async validate(recent: RecentDocument): Promise<boolean> {
134 const valid = await this._isValid(recent);
135 if (!valid) {
136 this._removeRecent(recent.path);
137 }
138 return valid;
139 }
140
141 /**
142 * Set server root dir.
143 *
144 * Note: protected to allow unit-testing.
145 */
146 protected updateRootDir() {
147 this._serverRoot = PageConfig.getOption('serverRoot');
148 }
149
150 /**
151 * Remove a path from both lists (opened and closed).
152 */
153 private _removeRecent(path: string, lists = ['opened', 'closed']): void {
154 let changed = false;
155 for (const type of lists) {
156 const recents = this._recents[type];
157 const newRecents = recents.filter(r => path !== r.path);
158 if (recents.length !== newRecents.length) {
159 this._setRecents(newRecents, type as 'opened' | 'closed');
160 changed = true;
161 }
162 }
163 if (changed) {
164 this._recentsChanged.emit(undefined);
165 }
166 }
167
168 /**
169 * Check if the path of a given recent document exists.
170 */
171 private async _isValid(recent: RecentDocument): Promise<boolean> {
172 try {
173 await this._contentsManager.get(recent.path, { content: false });
174 } catch (e) {
175 if ((e as ServerConnection.ResponseError).response?.status === 404) {
176 return false;
177 }
178 }
179 return true;
180 }
181
182 /**
183 * Set the recent list
184 * @param recents The new recent list
185 */
186 private _setRecents(
187 recents: RecentDocument[],
188 type: 'opened' | 'closed'
189 ): void {
190 this._recents[type] = recents
191 .slice(0, this.maximalRecentsLength)
192 .sort((a, b) => {
193 if (a.root === b.root) {
194 return 0;
195 } else {
196 return a.root !== this._serverRoot ? 1 : -1;
197 }
198 });
199 this._saveDebouncer.invoke().catch(console.warn);
200 }
201
202 /**
203 * Load the recent items from the state.
204 */
205 private async _loadRecents(): Promise<void> {
206 const recents = ((await this._stateDB.fetch(
207 Private.stateDBKey
208 )) as Private.RecentsDatabase) || {
209 opened: [],
210 closed: []
211 };
212 const allRecents = [...recents.opened, ...recents.closed];
213 const invalidPaths = new Set(await this._getInvalidPaths(allRecents));
214
215 for (const type of ['opened', 'closed']) {
216 this._setRecents(
217 recents[type].filter(r => !invalidPaths.has(r.path)),
218 type as 'opened' | 'closed'
219 );
220 }
221 this._recentsChanged.emit(undefined);
222 }
223
224 /**
225 * Get the list of invalid path in recents.
226 */
227 private async _getInvalidPaths(recents: RecentDocument[]): Promise<string[]> {
228 const invalidPathsOrNulls = await Promise.all(
229 recents.map(async r => {
230 if (await this._isValid(r)) {
231 return null;
232 } else {
233 return r.path;
234 }
235 })
236 );
237 return invalidPathsOrNulls.filter(x => typeof x === 'string') as string[];
238 }
239
240 /**
241 * Save the recent items to the state.
242 */
243 private async _save(): Promise<void> {
244 try {
245 await this._stateDB.save(Private.stateDBKey, this._recents);
246 } catch (e) {
247 console.log('Saving recents failed', e);
248 }
249 }
250
251 private _recentsChanged = new Signal<this, void>(this);
252 private _serverRoot: string;
253 private _stateDB: IStateDB;
254 private _contentsManager: Contents.IManager;
255 private _recents: Private.RecentsDatabase = {
256 opened: [],
257 closed: []
258 };
259 private _saveDebouncer: Debouncer<void>;
260 private _isDisposed = false;
261 private _maxRecentsLength = 10;
262}
263
264/**
265 * Namespace for RecentsManager statics.
266 */
267export namespace RecentsManager {
268 /**
269 * Initialization options for RecentsManager.
270 */
271 export interface IOptions {
272 /**
273 * State database used to store the recent documents.
274 */
275 stateDB: IStateDB;
276 /**
277 * Contents manager used for path validation.
278 */
279 contents: Contents.IManager;
280 }
281}
282
283namespace Private {
284 /**
285 * Key reserved in the state database.
286 */
287 export const stateDBKey = 'docmanager:recents';
288 /**
289 * The data structure for the state database value.
290 */
291 export type RecentsDatabase = {
292 [key: string]: RecentDocument[];
293 opened: RecentDocument[];
294 closed: RecentDocument[];
295 };
296}