From a6d2a21138ef900699a7b900f9287916b07ab9e4 Mon Sep 17 00:00:00 2001 From: Svyat Sobol Date: Thu, 23 Jul 2020 00:53:47 +0300 Subject: [PATCH] Support rename of the links with unknown extensions --- src/extensions/ReferenceHoverProvider.spec.ts | 60 ++++++++++++++++ src/extensions/ReferenceHoverProvider.ts | 25 +++++-- .../ReferenceRenameProvider.spec.ts | 69 ++++++++++++++++--- src/extensions/ReferenceRenameProvider.ts | 46 ++++++++----- src/extensions/fsWatcher.spec.ts | 44 +++++++++++- src/extensions/fsWatcher.ts | 37 +++++++--- src/utils/utils.spec.ts | 2 +- src/utils/utils.ts | 34 +++++++-- 8 files changed, 269 insertions(+), 48 deletions(-) diff --git a/src/extensions/ReferenceHoverProvider.spec.ts b/src/extensions/ReferenceHoverProvider.spec.ts index 70d2ee7e..f6e71085 100644 --- a/src/extensions/ReferenceHoverProvider.spec.ts +++ b/src/extensions/ReferenceHoverProvider.spec.ts @@ -97,6 +97,66 @@ describe('ReferenceHoverProvider', () => { }); }); + it('should provide hover with a warning about unknown extension', async () => { + const name0 = rndName(); + const name1 = rndName(); + + await createFile(`${name0}.md`, `[[${name1}.unknown]]`); + await createFile(`${name1}.unknown`, '# Hello world'); + + const doc = await openTextDocument(`${name0}.md`); + + const referenceHoverProvider = new ReferenceHoverProvider(); + + expect(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))) + .toMatchInlineSnapshot(` + B { + "contents": Array [ + "Link contains unknown extension: .unknown. Please use common file extensions .md,.png,.jpg,.jpeg,.svg,.gif,.doc,.docx,.rtf,.txt,.odt,.xls,.xlsx,.ppt,.pptm,.pptx,.pdf to get full support.", + ], + "range": Array [ + Object { + "character": 2, + "line": 0, + }, + Object { + "character": 15, + "line": 0, + }, + ], + } + `); + }); + + it('should provide hover with a warning that file is not created yet', async () => { + const name0 = rndName(); + + await createFile(`${name0}.md`, `[[any-link]]`); + + const doc = await openTextDocument(`${name0}.md`); + + const referenceHoverProvider = new ReferenceHoverProvider(); + + expect(referenceHoverProvider.provideHover(doc, new vscode.Position(0, 4))) + .toMatchInlineSnapshot(` + B { + "contents": Array [ + "\\"any-link\\" is not created yet. Click to create.", + ], + "range": Array [ + Object { + "character": 2, + "line": 0, + }, + Object { + "character": 10, + "line": 0, + }, + ], + } + `); + }); + it('should not provide hover for a link within code span', async () => { const name0 = rndName(); const name1 = rndName(); diff --git a/src/extensions/ReferenceHoverProvider.ts b/src/extensions/ReferenceHoverProvider.ts index 58705572..9e9680d1 100644 --- a/src/extensions/ReferenceHoverProvider.ts +++ b/src/extensions/ReferenceHoverProvider.ts @@ -4,12 +4,14 @@ import path from 'path'; import { containsImageExt, + containsUnknownExt, containsOtherKnownExts, getWorkspaceCache, getConfigProperty, getReferenceAtPosition, isUncPath, findUriByRef, + commonExts, } from '../utils'; export default class ReferenceHoverProvider implements vscode.HoverProvider { @@ -18,16 +20,25 @@ export default class ReferenceHoverProvider implements vscode.HoverProvider { if (refAtPos) { const { ref, range } = refAtPos; - const uris = getWorkspaceCache().allUris; - const imagePreviewMaxHeight = Math.max(getConfigProperty('imagePreviewMaxHeight', 200), 10); - - const foundUri = findUriByRef(uris, ref); - const hoverRange = new vscode.Range( new vscode.Position(range.start.line, range.start.character + 2), new vscode.Position(range.end.line, range.end.character - 2), ); + if (containsUnknownExt(ref)) { + return new vscode.Hover( + `Link contains unknown extension: ${ + path.parse(ref).ext + }. Please use common file extensions ${commonExts} to get full support.`, + hoverRange, + ); + } + + const uris = getWorkspaceCache().allUris; + const imagePreviewMaxHeight = Math.max(getConfigProperty('imagePreviewMaxHeight', 200), 10); + + const foundUri = findUriByRef(uris, ref); + if (foundUri && fs.existsSync(foundUri.fsPath)) { const getContent = () => { if (containsImageExt(foundUri.fsPath)) { @@ -45,9 +56,9 @@ export default class ReferenceHoverProvider implements vscode.HoverProvider { }; return new vscode.Hover(getContent(), hoverRange); - } else { - return new vscode.Hover(`"${ref}" is not created yet. Click to create.`, hoverRange); } + + return new vscode.Hover(`"${ref}" is not created yet. Click to create.`, hoverRange); } return null; diff --git a/src/extensions/ReferenceRenameProvider.spec.ts b/src/extensions/ReferenceRenameProvider.spec.ts index daf173ce..48ba7376 100644 --- a/src/extensions/ReferenceRenameProvider.spec.ts +++ b/src/extensions/ReferenceRenameProvider.spec.ts @@ -22,9 +22,9 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(() => + await expect( referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), - ).toThrowError('Rename is not available for nonexistent links.'); + ).rejects.toThrow('Rename is not available for nonexistent links.'); }); it('should not provide rename for file with unsaved changes', async () => { @@ -40,9 +40,9 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(() => + await expect( referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), - ).toThrowError('Rename is not available for unsaved files.'); + ).rejects.toThrow('Rename is not available for unsaved files.'); }); it('should not provide rename for multiline link', async () => { @@ -54,9 +54,9 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(() => + await expect( referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), - ).toThrowError('Rename is not available.'); + ).rejects.toThrow('Rename is not available.'); }); it('should provide rename for a link to the existing file', async () => { @@ -70,7 +70,7 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2))) + expect(await referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2))) .toMatchInlineSnapshot(` Array [ Object { @@ -85,6 +85,32 @@ describe('ReferenceRenameProvider', () => { `); }); + it('should provide rename for a link to the existing file with an unknown extension', async () => { + const name0 = rndName(); + const name1 = rndName(); + + await createFile(`${name0}.md`, `[[${name1}.unknown]]`); + await createFile(`${name1}.unknown`); + + const doc = await openTextDocument(`${name0}.md`); + + const referenceRenameProvider = new ReferenceRenameProvider(); + + expect(await referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2))) + .toMatchInlineSnapshot(` + Array [ + Object { + "character": 2, + "line": 0, + }, + Object { + "character": 15, + "line": 0, + }, + ] + `); + }); + it('should provide rename edit', async () => { const name0 = rndName(); const name1 = rndName(); @@ -106,6 +132,27 @@ describe('ReferenceRenameProvider', () => { expect(workspaceEdit!).not.toBeNull(); }); + it('should provide rename edit for a link to the existing file with unknown extension', async () => { + const name0 = rndName(); + const name1 = rndName(); + const newLinkName = rndName(); + + await createFile(`${name0}.md`, `[[${name1}.unknown]]`); + await createFile(`${name1}.unknown`); + + const doc = await openTextDocument(`${name0}.md`); + + const referenceRenameProvider = new ReferenceRenameProvider(); + + const workspaceEdit = await referenceRenameProvider.provideRenameEdits( + doc, + new vscode.Position(0, 2), + newLinkName, + ); + + expect(workspaceEdit!).not.toBeNull(); + }); + it('should not provide rename for a link within code span', async () => { const name0 = rndName(); const name1 = rndName(); @@ -117,9 +164,9 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(() => + await expect( referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), - ).toThrowError('Rename is not available.'); + ).rejects.toThrow('Rename is not available.'); }); it('should not provide rename for a link within fenced code block', async () => { @@ -142,8 +189,8 @@ describe('ReferenceRenameProvider', () => { const referenceRenameProvider = new ReferenceRenameProvider(); - expect(() => + await expect( referenceRenameProvider.prepareRename(doc, new vscode.Position(0, 2)), - ).toThrowError('Rename is not available.'); + ).rejects.toThrow('Rename is not available.'); }); }); diff --git a/src/extensions/ReferenceRenameProvider.ts b/src/extensions/ReferenceRenameProvider.ts index a7324df6..9d5796ad 100644 --- a/src/extensions/ReferenceRenameProvider.ts +++ b/src/extensions/ReferenceRenameProvider.ts @@ -1,12 +1,4 @@ -import { - RenameProvider, - TextDocument, - ProviderResult, - Position, - Range, - WorkspaceEdit, - Uri, -} from 'vscode'; +import { RenameProvider, TextDocument, Position, Range, WorkspaceEdit, Uri } from 'vscode'; import path from 'path'; import { @@ -15,15 +7,19 @@ import { getWorkspaceCache, isLongRef, getWorkspaceFolder, + containsUnknownExt, + findFilesByExts, + extractExt, + sortPaths, } from '../utils'; const openingBracketsLength = 2; export default class ReferenceRenameProvider implements RenameProvider { - public prepareRename( + public async prepareRename( document: TextDocument, position: Position, - ): ProviderResult { + ): Promise { if (document.isDirty) { throw new Error('Rename is not available for unsaved files. Please save your changes first.'); } @@ -33,7 +29,16 @@ export default class ReferenceRenameProvider implements RenameProvider { if (refAtPos) { const { range, ref } = refAtPos; - if (!findUriByRef(getWorkspaceCache().allUris, ref)) { + const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; + + const augmentedUris = unknownUris.length + ? sortPaths([...getWorkspaceCache().allUris, ...unknownUris], { + pathKey: 'path', + shallowFirst: true, + }) + : getWorkspaceCache().allUris; + + if (!findUriByRef(augmentedUris, ref)) { throw new Error( 'Rename is not available for nonexistent links. Create file first by clicking on the link.', ); @@ -48,11 +53,11 @@ export default class ReferenceRenameProvider implements RenameProvider { throw new Error('Rename is not available. Please try when focused on the link.'); } - public provideRenameEdits( + public async provideRenameEdits( document: TextDocument, position: Position, newName: string, - ): ProviderResult { + ): Promise { const refAtPos = getReferenceAtPosition(document, position); if (refAtPos) { @@ -60,10 +65,19 @@ export default class ReferenceRenameProvider implements RenameProvider { const workspaceEdit = new WorkspaceEdit(); - const fsPath = findUriByRef(getWorkspaceCache().allUris, ref)?.fsPath; + const unknownUris = containsUnknownExt(ref) ? await findFilesByExts([extractExt(ref)]) : []; + + const augmentedUris = unknownUris.length + ? sortPaths([...getWorkspaceCache().allUris, ...unknownUris], { + pathKey: 'path', + shallowFirst: true, + }) + : getWorkspaceCache().allUris; + + const fsPath = findUriByRef(augmentedUris, ref)?.fsPath; if (fsPath) { - const newRelativePath = `${newName}${!newName.includes('.') ? '.md' : ''}`; + const newRelativePath = `${newName}${path.parse(newName).ext === '' ? '.md' : ''}`; const newUri = Uri.file( isLongRef(ref) ? path.join(getWorkspaceFolder()!, newRelativePath) diff --git a/src/extensions/fsWatcher.spec.ts b/src/extensions/fsWatcher.spec.ts index 72571ff8..1f61376b 100644 --- a/src/extensions/fsWatcher.spec.ts +++ b/src/extensions/fsWatcher.spec.ts @@ -20,7 +20,7 @@ describe('fsWatcher extension', () => { afterEach(closeEditorsAndCleanWorkspace); describe('automatic refs update on file rename', () => { - it('should update short ref without label with short ref without label on file rename', async () => { + it('should update short ref with short ref on file rename', async () => { const noteName0 = rndName(); const noteName1 = rndName(); const nextNoteName1 = rndName(); @@ -131,6 +131,48 @@ describe('fsWatcher extension', () => { await waitForExpect(() => expect(doc.getText()).toBe(`[[folder2/${noteName1}]]`)); }); + + it('should update short ref to short ref with unknown extension on file rename', async () => { + const noteName0 = rndName(); + const noteName1 = rndName(); + const nextName = rndName(); + + await createFile(`${noteName0}.md`, `[[${noteName1}]]`, false); + await createFile(`${noteName1}.md`, '', false); + + const edit = new WorkspaceEdit(); + edit.renameFile( + Uri.file(`${getWorkspaceFolder()}/${noteName1}.md`), + Uri.file(`${getWorkspaceFolder()}/${nextName}.unknown`), + ); + + await workspace.applyEdit(edit); + + const doc = await openTextDocument(`${noteName0}.md`); + + await waitForExpect(() => expect(doc.getText()).toBe(`[[${nextName}.unknown]]`)); + }); + + it('should update short ref with unknown extension to short ref with a known extension on file rename', async () => { + const noteName0 = rndName(); + const noteName1 = rndName(); + const nextName = rndName(); + + await createFile(`${noteName0}.md`, `[[${noteName1}.unknown]]`, false); + await createFile(`${noteName1}.unknown`, '', false); + + const edit = new WorkspaceEdit(); + edit.renameFile( + Uri.file(`${getWorkspaceFolder()}/${noteName1}.unknown`), + Uri.file(`${getWorkspaceFolder()}/${nextName}.gif`), + ); + + await workspace.applyEdit(edit); + + const doc = await openTextDocument(`${noteName0}.md`); + + await waitForExpect(() => expect(doc.getText()).toBe(`[[${nextName}.gif]]`)); + }); }); it.skip('should sync workspace cache on file create', async () => { diff --git a/src/extensions/fsWatcher.ts b/src/extensions/fsWatcher.ts index 07b75617..03c1d1c7 100644 --- a/src/extensions/fsWatcher.ts +++ b/src/extensions/fsWatcher.ts @@ -10,8 +10,11 @@ import { cacheWorkspace, getWorkspaceCache, escapeForRegExp, + sortPaths, + findAllUrisWithUnknownExts, } from '../utils'; +// TODO: Extract to utils const replaceRefs = ({ refs, content, @@ -60,20 +63,38 @@ export const activate = (context: ExtensionContext) => { const deleteListenerDisposable = fileWatcher.onDidDelete(cacheWorkspace); const renameFilesDisposable = workspace.onDidRenameFiles(async ({ files }) => { + await cacheWorkspace(); + if (files.some(({ newUri }) => fs.lstatSync(newUri.fsPath).isDirectory())) { window.showWarningMessage( - 'Recursive links update on renaming / moving directory is not supported yet.', + 'Recursive links update on directory rename is currently not supported.', ); } - const oldUrisByPathBasename = groupBy(getWorkspaceCache().allUris, ({ fsPath }) => - path.basename(fsPath).toLowerCase(), + const oldFsPaths = files.map(({ oldUri }) => oldUri.fsPath); + + const oldUrisByPathBasename = groupBy( + sortPaths( + [ + ...getWorkspaceCache().allUris.filter((uri) => !oldFsPaths.includes(uri.fsPath)), + ...files.map(({ oldUri }) => oldUri), + ], + { + pathKey: 'path', + shallowFirst: true, + }, + ), + ({ fsPath }) => path.basename(fsPath).toLowerCase(), ); - // TODO: I think it's not going to work after introducing full-blown indexing - await cacheWorkspace(); + const urisWithUnknownExts = await findAllUrisWithUnknownExts(files.map(({ newUri }) => newUri)); - const newUris = getWorkspaceCache().allUris; + const newUris = urisWithUnknownExts.length + ? sortPaths([...getWorkspaceCache().allUris, ...urisWithUnknownExts], { + pathKey: 'path', + shallowFirst: true, + }) + : getWorkspaceCache().allUris; const newUrisByPathBasename = groupBy(newUris, ({ fsPath }) => path.basename(fsPath).toLowerCase(), @@ -136,7 +157,7 @@ export const activate = (context: ExtensionContext) => { if (!oldUriIsShortRef && !newUriIsShortRef) { // replace long ref with long ref - // TODO: Consider finding previous short ref and make it point to long ref + // TODO: Consider finding previous short ref and make it pointing to the long ref nextContent = replaceRefs({ refs: [{ old: oldLongRef, new: newLongRef }], content: fileContent, @@ -153,7 +174,7 @@ export const activate = (context: ExtensionContext) => { }); } else if (oldUriIsShortRef && !newUriIsShortRef) { // replace short ref with long ref - // TODO: Consider finding new short ref and making long refs point to new short ref + // TODO: Consider finding new short ref and making long refs pointing to the new short ref nextContent = replaceRefs({ refs: [{ old: oldShortRef, new: newLongRef }], content: fileContent, diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 6f68ea93..19c4a7a0 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -238,7 +238,7 @@ describe('getRefUriUnderCursor()', () => { describe('parseRef()', () => { it('should fail on providing wrong parameter type', () => { - expect(() => parseRef((undefined as unknown) as string)).toThrowError(); + expect(() => parseRef((undefined as unknown) as string)).toThrow(); }); it('should return empty ref and label', () => { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index dfe17881..e96b5555 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,4 @@ -import vscode from 'vscode'; +import vscode, { workspace } from 'vscode'; import path from 'path'; import { sort as sortPaths } from 'cross-path-sort'; import fs from 'fs'; @@ -11,9 +11,12 @@ export { sortPaths }; const markdownExtRegex = /\.md$/i; -const imageExtsRegex = /\.(png|jpg|jpeg|svg|gif)/i; +const imageExtsRegex = /\.(png|jpg|jpeg|svg|gif)$/i; -const otherExtsRegex = /\.(doc|docx|odt|pdf|rtf|tex|txt|wpd)$/i; +const otherExtsRegex = /\.(doc|docx|rtf|txt|odt|xls|xlsx|ppt|pptm|pptx|pdf|pages|mp4|mov|wmv|flv|avi|mkv|mp3|webm|wav|m4a|ogg|3gp|flac)$/i; + +export const commonExts = + '.md,.png,.jpg,.jpeg,.svg,.gif,.doc,.docx,.rtf,.txt,.odt,.xls,.xlsx,.ppt,.pptm,.pptx,.pdf'; export const refPattern = '(\\[\\[)([^\\[\\]]+?)(\\]\\])'; @@ -23,6 +26,12 @@ export const containsMarkdownExt = (path: string): boolean => !!markdownExtRegex export const containsOtherKnownExts = (path: string): boolean => !!otherExtsRegex.exec(path); +export const containsUnknownExt = (path: string): boolean => + path.includes('.') && + !containsMarkdownExt(path) && + !containsImageExt(path) && + !containsOtherKnownExts(path); + export const trimLeadingSlash = (value: string) => value.replace(/^\/+|^\\+/g, ''); export const trimTrailingSlash = (value: string) => value.replace(/\/+|^\\+$/g, ''); export const trimSlashes = (value: string) => trimLeadingSlash(trimTrailingSlash(value)); @@ -200,9 +209,26 @@ const uncPathRegex = /^[\\\/]{2,}[^\\\/]+[\\\/]+[^\\\/]+/; export const isUncPath = (path: string): boolean => uncPathRegex.test(path); +export const findFilesByExts = async (exts: string[]) => + await workspace.findFiles(`**/*.{${exts.join(',')}}`); + +export const findAllUrisWithUnknownExts = async (uris: vscode.Uri[]) => { + const unknownExts = Array.from( + new Set( + uris + .filter(({ fsPath }) => containsUnknownExt(fsPath)) + .map(({ fsPath }) => path.parse(fsPath).ext.replace(/^\./, '')), + ), + ); + + return unknownExts.length ? await findFilesByExts(unknownExts) : []; +}; + +export const extractExt = (value: string) => path.parse(value).ext.replace(/^\./, ''); + export const findUriByRef = (uris: vscode.Uri[], ref: string): vscode.Uri | undefined => uris.find((uri) => { - if (containsImageExt(ref) || containsOtherKnownExts(ref)) { + if (containsImageExt(ref) || containsOtherKnownExts(ref) || containsUnknownExt(ref)) { if (isLongRef(ref)) { return normalizeSlashes(uri.fsPath.toLowerCase()).endsWith(ref.toLowerCase()); }