1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 | import { PageConfig } from '@jupyterlab/coreutils';
|
7 | import { Contents, ServerConnection } from '@jupyterlab/services';
|
8 | import { IStateDB } from '@jupyterlab/statedb';
|
9 | import { Debouncer } from '@lumino/polling';
|
10 | import { ISignal, Signal } from '@lumino/signaling';
|
11 | import { IRecentsManager, RecentDocument } from './tokens';
|
12 |
|
13 |
|
14 |
|
15 |
|
16 | export 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 |
|
30 |
|
31 | get isDisposed(): boolean {
|
32 | return this._isDisposed;
|
33 | }
|
34 |
|
35 | |
36 |
|
37 |
|
38 | get recentlyOpened(): RecentDocument[] {
|
39 | const recents = this._recents.opened || [];
|
40 | return recents.filter(r => r.root === this._serverRoot);
|
41 | }
|
42 |
|
43 | |
44 |
|
45 |
|
46 | get recentlyClosed(): RecentDocument[] {
|
47 | const recents = this._recents.closed || [];
|
48 | return recents.filter(r => r.root === this._serverRoot);
|
49 | }
|
50 |
|
51 | |
52 |
|
53 |
|
54 | get changed(): ISignal<IRecentsManager, void> {
|
55 | return this._recentsChanged;
|
56 | }
|
57 |
|
58 | |
59 |
|
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 |
|
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 |
|
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 |
|
103 | const existingIndex = recents.findIndex(r => r.path === document.path);
|
104 | if (existingIndex >= 0) {
|
105 | recents.splice(existingIndex, 1);
|
106 | }
|
107 |
|
108 | recents.unshift(recent);
|
109 |
|
110 | this._setRecents(recents, event);
|
111 | this._recentsChanged.emit(undefined);
|
112 | }
|
113 |
|
114 | |
115 |
|
116 |
|
117 | clearRecents(): void {
|
118 | this._setRecents([], 'opened');
|
119 | this._setRecents([], 'closed');
|
120 | this._recentsChanged.emit(undefined);
|
121 | }
|
122 |
|
123 | |
124 |
|
125 |
|
126 | removeRecent(document: RecentDocument, event: 'opened' | 'closed'): void {
|
127 | this._removeRecent(document.path, [event]);
|
128 | }
|
129 |
|
130 | |
131 |
|
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 |
|
143 |
|
144 |
|
145 |
|
146 | protected updateRootDir() {
|
147 | this._serverRoot = PageConfig.getOption('serverRoot');
|
148 | }
|
149 |
|
150 | |
151 |
|
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 |
|
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 |
|
184 |
|
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 |
|
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 |
|
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 |
|
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 |
|
266 |
|
267 | export namespace RecentsManager {
|
268 | |
269 |
|
270 |
|
271 | export interface IOptions {
|
272 | |
273 |
|
274 |
|
275 | stateDB: IStateDB;
|
276 | |
277 |
|
278 |
|
279 | contents: Contents.IManager;
|
280 | }
|
281 | }
|
282 |
|
283 | namespace Private {
|
284 | |
285 |
|
286 |
|
287 | export const stateDBKey = 'docmanager:recents';
|
288 | |
289 |
|
290 |
|
291 | export type RecentsDatabase = {
|
292 | [key: string]: RecentDocument[];
|
293 | opened: RecentDocument[];
|
294 | closed: RecentDocument[];
|
295 | };
|
296 | }
|