Skip to content

Commit da3074a

Browse files
committed
feat: Add "Extract range to a new note" command
1 parent 501b48b commit da3074a

13 files changed

+312
-0
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ If you want to try out Memo just install it via marketplace using [this link](ht
9494

9595
- "Rename Symbol" command support for renaming links right in the editor
9696

97+
- "Extract range to a new note" command to ease notes refactoring
98+
9799
## FAQ
98100

99101
- [Memo vs Foam](https://github.com/svsool/vscode-memo/issues/9#issuecomment-658346216)
Loading

help/Features/Commands.md

+4
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@
2727
### `Rename Symbol` command which allows you to rename links right in the editor
2828

2929
![[Automatic link synchronization 2.gif]]
30+
31+
### `Extract range to a new note` command to ease notes refactoring
32+
33+
![[Extracting range to a new note.gif]]

help/How to/How to.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
- [[Pasting images from clipboard]]
44
- [[Pasting HTML as Markdown]]
5+
- [[Notes refactoring]]
56

67
Continue to [[Features]].

help/How to/Notes refactoring.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Notes refactoring
2+
3+
## Extract range to a new note
4+
5+
Select the following fragment, hit `cmd/ctrl + .` and select `Extract range to a new note` [code action](https://code.visualstudio.com/docs/editor/refactoring#_code-actions-quick-fixes-and-refactorings).
6+
7+
![[Extracting range to a new note.gif]]

package.json

+10
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"onCommand:memo.openReferenceInDefaultApp",
4343
"onCommand:memo.openReferenceBeside",
4444
"onCommand:memo.pasteHtmlAsMarkdown",
45+
"onCommand:memo.extractRangeToNewNote",
4546
"onCommand:_memo.openReference",
4647
"onCommand:_memo.cacheWorkspace",
4748
"onCommand:_memo.cleanWorkspaceCache",
@@ -76,6 +77,11 @@
7677
"command": "memo.pasteHtmlAsMarkdown",
7778
"title": "Paste HTML as Markdown",
7879
"category": "Memo"
80+
},
81+
{
82+
"command": "memo.extractRangeToNewNote",
83+
"title": "Extract range to a new note",
84+
"category": "Memo"
7985
}
8086
],
8187
"configuration": {
@@ -198,6 +204,10 @@
198204
{
199205
"command": "memo.openReferenceBeside",
200206
"when": "editorLangId == markdown && memo:refFocusedOrHovered"
207+
},
208+
{
209+
"command": "memo.extractRangeToNewNote",
210+
"when": "editorHasSelection && editorLangId == markdown"
201211
}
202212
]
203213
},

src/commands/commands.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import openReferenceInDefaultApp from './openReferenceInDefaultApp';
66
import openReferenceBeside from './openReferenceBeside';
77
import openDailyNote from './openDailyNote';
88
import pasteHtmlAsMarkdown from './pasteHtmlAsMarkdown';
9+
import extractRangeToNewNote from './extractRangeToNewNote';
910
import { cacheWorkspace, cleanWorkspaceCache, getWorkspaceCache } from '../utils';
1011

1112
const commands = [
@@ -17,6 +18,7 @@ const commands = [
1718
vscode.commands.registerCommand('memo.openDailyNote', openDailyNote),
1819
vscode.commands.registerCommand('memo.openReferenceInDefaultApp', openReferenceInDefaultApp),
1920
vscode.commands.registerCommand('memo.openReferenceBeside', openReferenceBeside),
21+
vscode.commands.registerCommand('memo.extractRangeToNewNote', extractRangeToNewNote),
2022
vscode.commands.registerCommand('memo.pasteHtmlAsMarkdown', pasteHtmlAsMarkdown),
2123
];
2224

+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import vscode, { window } from 'vscode';
2+
import path from 'path';
3+
4+
import extractRangeToNewNote from './extractRangeToNewNote';
5+
import { getWorkspaceFolder } from '../utils';
6+
import {
7+
closeEditorsAndCleanWorkspace,
8+
rndName,
9+
createFile,
10+
openTextDocument,
11+
} from '../test/testUtils';
12+
13+
describe('extractRangeToNewNote command', () => {
14+
beforeEach(closeEditorsAndCleanWorkspace);
15+
16+
afterEach(closeEditorsAndCleanWorkspace);
17+
18+
it('should extract range to a new note', async () => {
19+
const name0 = rndName();
20+
const name1 = rndName();
21+
22+
await createFile(`${name0}.md`, 'Hello world.');
23+
24+
const doc = await openTextDocument(`${name0}.md`);
25+
26+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
27+
28+
targetPathInputBoxSpy.mockReturnValue(
29+
Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)),
30+
);
31+
32+
await extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12));
33+
34+
expect(await doc.getText()).toBe('');
35+
36+
const newDoc = await openTextDocument(`${name1}.md`);
37+
38+
expect(await newDoc.getText()).toBe('Hello world.');
39+
40+
targetPathInputBoxSpy.mockRestore();
41+
});
42+
43+
it('should extract a multiline range to a new note', async () => {
44+
const name0 = rndName();
45+
const name1 = rndName();
46+
47+
await createFile(
48+
`${name0}.md`,
49+
`Multiline
50+
Hello world.`,
51+
);
52+
53+
const doc = await openTextDocument(`${name0}.md`);
54+
55+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
56+
57+
targetPathInputBoxSpy.mockReturnValue(
58+
Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)),
59+
);
60+
61+
await extractRangeToNewNote(doc, new vscode.Range(0, 0, 1, 16));
62+
63+
expect(await doc.getText()).toBe('');
64+
65+
const newDoc = await openTextDocument(`${name1}.md`);
66+
67+
expect(await newDoc.getText()).toMatchInlineSnapshot(`
68+
"Multiline
69+
Hello world."
70+
`);
71+
72+
targetPathInputBoxSpy.mockRestore();
73+
});
74+
75+
it('should extract range from active markdown file', async () => {
76+
const name0 = rndName();
77+
const name1 = rndName();
78+
79+
await createFile(`${name0}.md`, 'Hello world.');
80+
81+
const doc = await openTextDocument(`${name0}.md`);
82+
const editor = await window.showTextDocument(doc);
83+
84+
editor.selection = new vscode.Selection(0, 0, 0, 12);
85+
86+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
87+
88+
targetPathInputBoxSpy.mockReturnValue(
89+
Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)),
90+
);
91+
92+
await extractRangeToNewNote();
93+
94+
expect(await doc.getText()).toBe('');
95+
96+
const newDoc = await openTextDocument(`${name1}.md`);
97+
98+
expect(await newDoc.getText()).toBe('Hello world.');
99+
100+
targetPathInputBoxSpy.mockRestore();
101+
});
102+
103+
it('should not extract anything from unknown file format', async () => {
104+
const name0 = rndName();
105+
106+
await createFile(`${name0}.txt`, 'Hello world.');
107+
108+
const doc = await openTextDocument(`${name0}.txt`);
109+
const editor = await window.showTextDocument(doc);
110+
111+
editor.selection = new vscode.Selection(0, 0, 0, 12);
112+
113+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
114+
115+
await extractRangeToNewNote();
116+
117+
expect(await doc.getText()).toBe('Hello world.');
118+
119+
expect(targetPathInputBoxSpy).not.toBeCalled();
120+
121+
targetPathInputBoxSpy.mockRestore();
122+
});
123+
124+
it('should fail when target path is outside of the workspace', async () => {
125+
const name0 = rndName();
126+
127+
await createFile(`${name0}.md`, 'Hello world.');
128+
129+
const doc = await openTextDocument(`${name0}.md`);
130+
131+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
132+
133+
targetPathInputBoxSpy.mockReturnValue(Promise.resolve('/random-path/file.md'));
134+
135+
expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError(
136+
'should be within the current workspace',
137+
);
138+
139+
targetPathInputBoxSpy.mockRestore();
140+
});
141+
142+
it('should fail when entered file already exists', async () => {
143+
const name0 = rndName();
144+
const name1 = rndName();
145+
146+
await createFile(`${name0}.md`, 'Hello world.');
147+
await createFile(`${name1}.md`);
148+
149+
const doc = await openTextDocument(`${name0}.md`);
150+
151+
const targetPathInputBoxSpy = jest.spyOn(vscode.window, 'showInputBox');
152+
153+
targetPathInputBoxSpy.mockReturnValue(
154+
Promise.resolve(path.join(getWorkspaceFolder()!, `${name1}.md`)),
155+
);
156+
157+
expect(extractRangeToNewNote(doc, new vscode.Range(0, 0, 0, 12))).rejects.toThrowError(
158+
'Such file or directory already exists. Please use unique filename instead.',
159+
);
160+
161+
targetPathInputBoxSpy.mockRestore();
162+
});
163+
});

src/commands/extractRangeToNewNote.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import vscode, { Uri, window } from 'vscode';
2+
import fs from 'fs-extra';
3+
import path from 'path';
4+
5+
const filename = 'New File.md';
6+
7+
const prompt = 'New location within workspace';
8+
9+
const createFile = async (uri: vscode.Uri, content: string) => {
10+
const workspaceEdit = new vscode.WorkspaceEdit();
11+
workspaceEdit.createFile(uri);
12+
workspaceEdit.set(uri, [new vscode.TextEdit(new vscode.Range(0, 0, 0, 0), content)]);
13+
14+
await vscode.workspace.applyEdit(workspaceEdit);
15+
};
16+
17+
const showFile = async (uri: vscode.Uri) =>
18+
await window.showTextDocument(await vscode.workspace.openTextDocument(uri));
19+
20+
const deleteRange = async (document: vscode.TextDocument, range: vscode.Range) => {
21+
const editor = await window.showTextDocument(document);
22+
await editor.edit((edit) => edit.delete(range));
23+
};
24+
25+
const extractRangeToNewNote = async (
26+
documentParam?: vscode.TextDocument,
27+
rangeParam?: vscode.Range,
28+
) => {
29+
const document = documentParam ? documentParam : window.activeTextEditor?.document;
30+
31+
if (!document || (document && document.languageId !== 'markdown')) {
32+
return;
33+
}
34+
35+
const range = rangeParam ? rangeParam : window.activeTextEditor?.selection;
36+
37+
if (!range || (range && range.isEmpty)) {
38+
return;
39+
}
40+
41+
const filepath = path.join(path.dirname(document.uri.fsPath), filename);
42+
const targetPath = await window.showInputBox({
43+
prompt,
44+
value: filepath,
45+
valueSelection: [filepath.lastIndexOf(filename), filepath.lastIndexOf('.md')],
46+
});
47+
48+
const targetUri = Uri.file(targetPath || '');
49+
50+
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
51+
52+
if (!targetPath) {
53+
return;
54+
}
55+
56+
if (!vscode.workspace.getWorkspaceFolder(targetUri)) {
57+
throw new Error(
58+
`New location "${targetUri.fsPath}" should be within the current workspace.${
59+
workspaceFolder ? ` Example: ${path.join(workspaceFolder.uri.fsPath, filename)}` : ''
60+
}`,
61+
);
62+
}
63+
64+
if (await fs.pathExists(targetUri.fsPath)) {
65+
throw new Error('Such file or directory already exists. Please use unique filename instead.');
66+
}
67+
68+
// Order matters
69+
await createFile(targetUri, document.getText(range).trim());
70+
71+
await deleteRange(document, range);
72+
73+
await showFile(targetUri);
74+
};
75+
76+
export default extractRangeToNewNote;

src/extension.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
BacklinksTreeDataProvider,
1313
extendMarkdownIt,
1414
newVersionNotifier,
15+
codeActionProvider,
1516
} from './features';
1617
import commands from './commands';
1718
import { cacheWorkspace, getMemoConfigProperty, MemoBoolConfigProp, isDefined } from './utils';
@@ -40,6 +41,7 @@ export const activate = async (
4041

4142
context.subscriptions.push(
4243
...commands,
44+
vscode.languages.registerCodeActionsProvider(mdLangSelector, codeActionProvider),
4345
vscode.workspace.onDidChangeConfiguration(async (configChangeEvent) => {
4446
if (configChangeEvent.affectsConfiguration('search.exclude')) {
4547
await cacheWorkspace();
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import vscode from 'vscode';
2+
3+
import codeActionProvider from './codeActionProvider';
4+
import { rndName, createFile, openTextDocument } from '../test/testUtils';
5+
6+
describe('codeActionProvider', () => {
7+
it('should provide code actions', async () => {
8+
const name0 = rndName();
9+
10+
await createFile(`${name0}.md`, 'Hello world!');
11+
12+
const doc = await openTextDocument(`${name0}.md`);
13+
const range = new vscode.Range(0, 0, 0, 12);
14+
15+
expect(
16+
codeActionProvider.provideCodeActions(doc, range, undefined as any, undefined as any),
17+
).toEqual([
18+
{
19+
title: 'Extract range to a new note',
20+
command: 'memo.extractRangeToNewNote',
21+
arguments: [doc, range],
22+
},
23+
]);
24+
});
25+
});

src/features/codeActionProvider.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { CodeActionProvider } from 'vscode';
2+
3+
const codeActionProvider: CodeActionProvider = {
4+
provideCodeActions(document, range) {
5+
if (range.isEmpty) {
6+
return [];
7+
}
8+
9+
return [
10+
{
11+
title: 'Extract range to a new note',
12+
command: 'memo.extractRangeToNewNote',
13+
arguments: [document, range],
14+
},
15+
];
16+
},
17+
};
18+
19+
export default codeActionProvider;

src/features/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { default as ReferenceProvider } from './ReferenceProvider';
55
export { default as ReferenceRenameProvider } from './ReferenceRenameProvider';
66
export { default as BacklinksTreeDataProvider } from './BacklinksTreeDataProvider';
77
export { default as extendMarkdownIt } from './extendMarkdownIt';
8+
export { default as codeActionProvider } from './codeActionProvider';
89
export * as fsWatcher from './fsWatcher';
910
export * as referenceContextWatcher from './referenceContextWatcher';
1011
export * as syntaxDecorations from './syntaxDecorations';

0 commit comments

Comments
 (0)