-
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathLoomEditorProvider.ts
494 lines (421 loc) · 14.1 KB
/
LoomEditorProvider.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
import {
ExtensionContext,
CustomTextEditorProvider,
TextDocument,
WebviewPanel,
Disposable,
window,
workspace,
WorkspaceEdit,
Range,
Position,
} from "vscode";
import rimraf from "rimraf";
import { YarnNode, createNodeText } from "loom-common/YarnNode";
import {
parseYarnFile,
buildLinksFromNodes,
renameLinksFromNode,
} from "loom-common/YarnParser";
import { setNodes } from "loom-common/EditorActions";
import LoomWebviewPanel from "./LoomWebviewPanel";
import {
listenForMessages,
listenForConfigurationChanges,
} from "./LoomMessageListener";
import {
getTemporaryFolderPath,
unwatchTemporaryFilesForDocument,
createdTemporaryFiles,
} from "./TemporaryFiles";
/**
* This is a custom text editor provider that will open up `.yarn` files in the Yarn Editor.
*/
export default class LoomEditorProvider implements CustomTextEditorProvider {
/**
* This is used to trigger calling this on certain file types.
* See package.json where this is used.
*/
private static readonly viewType = "yarnLoom.editor";
/** Register a LoomEditor provider in the extension context. */
public static register(context: ExtensionContext): Disposable {
const provider = new LoomEditorProvider(context);
const providerRegistration = window.registerCustomEditorProvider(
LoomEditorProvider.viewType,
provider,
{
webviewOptions: {
// this makes it so that when the tab loses context, it doesn't re-create it
retainContextWhenHidden: true,
},
}
);
return providerRegistration;
}
/** Context that extension is running under */
context: ExtensionContext;
/** Text document that's open */
document?: TextDocument;
/** Webview panel displaying editor */
webviewPanel?: WebviewPanel;
/** List of yarn nodes in the current document */
nodes: YarnNode[];
constructor(context: ExtensionContext) {
this.context = context;
this.nodes = [];
}
/**
* This is from the CustomTextEditorProvider interface and will be called
* whenever we're opening a .yarn (or other supported) file with the extension
*/
public resolveCustomTextEditor(
document: TextDocument,
webviewPanel: WebviewPanel
) {
webviewPanel.webview.options = {
enableScripts: true,
};
this.webviewPanel = webviewPanel;
this.document = document;
this.nodes = parseYarnFile(document.getText());
// track when the document that we're editing is closed
// and delete any temporary files
workspace.onDidCloseTextDocument((e) => {
if (e.uri === document.uri) {
this.onDocumentClosed(document);
}
});
// track when the document that's opened changes
// this is so that we can re-update the editor
// this is what actually triggers updates in the editor; every change to a node (editing, renaming, etc.)
// changes the backing document which triggers this and updates the editor
workspace.onDidChangeTextDocument((e) => {
if (e.document.uri === document.uri) {
this.nodes = parseYarnFile(e.document.getText());
webviewPanel.webview.postMessage(setNodes(this.nodes));
}
});
// start message listener(s)
listenForMessages(webviewPanel, this);
listenForConfigurationChanges();
// actually create the webview
LoomWebviewPanel(webviewPanel, this.context.extensionPath);
// and send all of our nodes over to it
webviewPanel.webview.postMessage(setNodes(this.nodes));
}
/**
* Called when the given document is closed in the workspace
* @param document The document getting closed
*/
onDocumentClosed = (document: TextDocument) => {
// first, close any file watchers we have open for this document
unwatchTemporaryFilesForDocument(document);
// delete the temporary folder that we created to edit nodes
// this folder isn't guaranteed to actually exist
const temporaryFolderPath = getTemporaryFolderPath(document);
rimraf(temporaryFolderPath, (e) => {
if (e) {
console.error(
`Error cleaning up temporary directory ${temporaryFolderPath} when closing ${document.uri.toString()}`,
e
);
}
});
};
/**
* Given a node title, will return a range of lines that that node occupies in the backing text document.
* @param nodeTitle Title of node to get range in document for
*/
getRangeForNode = (nodeTitle: string): Range => {
if (!this.document) {
throw new Error(
`Tried to update node ${nodeTitle} but we don't have a document!`
);
}
const lines = this.document.getText().split("\n");
let nodeStartingLineNumber: number = -1;
let nodeEndingLineNumber: number = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === `title: ${nodeTitle.trim()}`) {
nodeStartingLineNumber = i;
}
// if we have the starting line number, the next `===` we run into means it's the end of the node
if (nodeStartingLineNumber !== -1 && line === "===") {
nodeEndingLineNumber = i;
break;
}
}
// 3 because nodes end with `===`
return new Range(nodeStartingLineNumber, 0, nodeEndingLineNumber, 3);
};
/**
* Update a single node in the document
* @param originalTitle Original title of node, in case it was changed during the edit
* @param node Node to update
*/
updateNode = (originalTitle: string, node: YarnNode) => {
if (!this.webviewPanel) {
throw new Error(
`Tried to update node ${originalTitle} but we don't have a webview!`
);
}
if (!this.document) {
throw new Error(
`Tried to update node ${originalTitle} but we don't have a document!`
);
}
const originalNodeIndex = this.nodes.findIndex(
(originalNode) => originalNode.title === originalTitle
);
// update the one node we're updating and leave the rest alone
this.nodes = [
...this.nodes.slice(0, originalNodeIndex),
...[node],
...this.nodes.slice(originalNodeIndex + 1),
];
// re-build the links in case they changed
// this will return any new nodes that were auto-created from added links
const addedNodes = buildLinksFromNodes(this.nodes, true);
// and finally, apply the actual edit to the text document
const edit = new WorkspaceEdit();
// change the node we're actually updating
edit.replace(
this.document.uri,
this.getRangeForNode(originalTitle),
createNodeText(node)
);
// add in all the new nodes
addedNodes.forEach((addedNode) =>
this.createNodeInDocument(addedNode, edit)
);
workspace.applyEdit(edit);
};
/**
* Update just the body of a node by its title
* @param title Title of node to update
* @param body New body to set for node
*/
updateNodeBody = (title: string, body: string) => {
const node = this.nodes.find((node) => node.title === title);
if (!node) {
throw new Error(
`Tried to update body for ${title} but no node with that title was found.`
);
}
this.updateNode(title, {
...node,
body,
});
};
/**
* Renames a node and changes all links going to that node to point to the new node title.
* @param oldTitle Old title of node
* @param newTitle New title of node
*/
renameNode = (oldTitle: string, newTitle: string) => {
if (!this.webviewPanel) {
throw new Error(
`Tried to rename node ${oldTitle} to ${newTitle} but we don't have a webview!`
);
}
if (!this.document) {
throw new Error(
`Tried to rename node ${oldTitle} to ${newTitle} but we don't have a document!`
);
}
const originalNodeIndex = this.nodes.findIndex(
(originalNode) => originalNode.title === oldTitle
);
// this is the node we're actually renaming
const node = {
...this.nodes[originalNodeIndex],
title: newTitle,
};
// update the one node we're updating and leave the rest alone
this.nodes = [
...this.nodes.slice(0, originalNodeIndex),
...[node],
...this.nodes.slice(originalNodeIndex + 1),
];
// this will change _all_ links going to this node and return a list of changed nodes
const changedNodes = renameLinksFromNode(this.nodes, oldTitle, newTitle);
// and finally, apply the actual edit to the text document
const edit = new WorkspaceEdit();
edit.replace(
this.document.uri,
this.getRangeForNode(oldTitle),
createNodeText(node)
);
// also apply an edit for each changed node
for (let i = 0; i < changedNodes.length; i++) {
edit.replace(
this.document!.uri,
this.getRangeForNode(changedNodes[i].title),
createNodeText(changedNodes[i])
);
}
workspace.applyEdit(edit);
// update references to this node in any open temporary files
// it's worth noting that if the user undoes this rename (via Ctrl+Z) then the link between
// the node and the open file will be broken (there's no real good way to detect when this happens 😢)
createdTemporaryFiles.forEach((tmpFile) => {
if (tmpFile.node.title === oldTitle) {
tmpFile.node.title = newTitle;
}
});
};
/**
* Add the given tags to the specified node
* @param nodeTitle Title of node to add tags to
* @param tags Tags to add to node (separated by spaced)
*/
addTagsToNode = (nodeTitle: string, tags: string) => {
if (!this.document) {
throw new Error(
`Tried to add tags to ${nodeTitle} but we don't have a document!`
);
}
const originalNodeIndex = this.nodes.findIndex(
(originalNode) => originalNode.title === nodeTitle
);
// this is the node we're actually renaming
const node = {
...this.nodes[originalNodeIndex],
};
const existingTags = node.tags.split(" ");
const newTags = tags.split(" ");
// create a set, then join it with spaces... this is to guarantee unique tag names
node.tags = Array.from(new Set([...existingTags, ...newTags]))
.join(" ")
.trim();
// update the one node we're updating and leave the rest alone
this.nodes = [
...this.nodes.slice(0, originalNodeIndex),
...[node],
...this.nodes.slice(originalNodeIndex + 1),
];
// and finally, apply the actual edit to the text document
const edit = new WorkspaceEdit();
edit.replace(
this.document.uri,
this.getRangeForNode(nodeTitle),
createNodeText(node)
);
workspace.applyEdit(edit);
};
/**
* Add/remove the given tags to the specified node
* @param nodeTitle Title of node to add tags to
* @param tags Tags to add to node (separated by spaced)
*/
toggleTagsOnNode = (nodeTitle: string, tags: string) => {
if (!this.document) {
throw new Error(
`Tried to add tags to ${nodeTitle} but we don't have a document!`
);
}
const originalNodeIndex = this.nodes.findIndex(
(originalNode) => originalNode.title === nodeTitle
);
// this is the node we're actually renaming
const node = {
...this.nodes[originalNodeIndex],
};
const existingTags = node.tags.split(" ");
const tagsToToggle = tags.split(" ");
// create a set, then join it with spaces... this is to guarantee unique tag names
node.tags = existingTags
// filter out any existing tags
.filter((tag) => !tagsToToggle.includes(tag))
// add in any tags we didn't have
.concat(tagsToToggle.filter((tag) => !existingTags.includes(tag)))
.join(" ")
.trim();
// update the one node we're updating and leave the rest alone
this.nodes = [
...this.nodes.slice(0, originalNodeIndex),
...[node],
...this.nodes.slice(originalNodeIndex + 1),
];
// and finally, apply the actual edit to the text document
const edit = new WorkspaceEdit();
edit.replace(
this.document.uri,
this.getRangeForNode(nodeTitle),
createNodeText(node)
);
workspace.applyEdit(edit);
};
/**
* Delete a node with the given title
* @param nodeTitle Title of node to delete
*/
deleteNode = (nodeTitle: string) => {
if (!this.webviewPanel) {
throw new Error(
`Tried to delete node ${nodeTitle} but we don't have a webview!`
);
}
if (!this.document) {
throw new Error(
`Tried to delete node ${nodeTitle} but we don't have a document!`
);
}
const originalNodeIndex = this.nodes.findIndex(
(originalNode) => originalNode.title === nodeTitle
);
// update the one node we're updating and leave the rest alone
this.nodes = [
...this.nodes.slice(0, originalNodeIndex),
...this.nodes.slice(originalNodeIndex + 1),
];
// re-build the links in case they changed
buildLinksFromNodes(this.nodes, false);
// and finally, apply the actual edit to the text document
const edit = new WorkspaceEdit();
edit.delete(this.document.uri, this.getRangeForNode(nodeTitle));
workspace.applyEdit(edit);
};
/**
* Add a new node to the text document.
*
* @param title Title of node to add. Must not be an existing title.
*/
addNewNode = (title: string) => {
if (!this.webviewPanel) {
throw new Error(
`Tried to add node ${title} but we don't have a webview!`
);
}
const node: YarnNode = {
title,
body: "\n",
tags: "",
};
this.nodes.push(node);
const edit = new WorkspaceEdit();
this.createNodeInDocument(node, edit);
workspace.applyEdit(edit);
};
/**
* Add a new node to the backing text document.
* Note: This just adds the new node to the given WorkspaceEdit but does *not* actually apply it!
* `workspace.applyEdit` must be called to actually apply the edit
*
* @param node Node to insert into document
* @param edit Edit to apply insert to
*/
createNodeInDocument = (node: YarnNode, edit: WorkspaceEdit) => {
if (!this.document) {
throw new Error(
`Tried to create node ${node.title} but we don't have a document!`
);
}
edit.insert(
this.document.uri,
new Position(this.document.getText().split("\n").length + 1, 0),
`${createNodeText(node)}\n`
);
};
}