diff --git a/assets/screenshots/feature-link-sync.gif b/assets/screenshots/feature-link-sync.gif new file mode 100644 index 00000000..ce519d16 Binary files /dev/null and b/assets/screenshots/feature-link-sync.gif differ diff --git a/packages/foam-vscode/README.md b/packages/foam-vscode/README.md index 3c6573cd..cd8ca9ed 100644 --- a/packages/foam-vscode/README.md +++ b/packages/foam-vscode/README.md @@ -27,6 +27,12 @@ Foam helps you create the connections between your notes, and your placeholders ![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif) +### Sync links on file rename + +Foam updates the links to renamed files, so your notes stay consistent. + +![Sync links on file rename](./assets/screenshots/feature-link-sync.gif) + ### Unique identifiers across directories Foam supports files with the same name in multiple directories. diff --git a/packages/foam-vscode/assets/screenshots/feature-link-sync.gif b/packages/foam-vscode/assets/screenshots/feature-link-sync.gif new file mode 100644 index 00000000..ce519d16 Binary files /dev/null and b/packages/foam-vscode/assets/screenshots/feature-link-sync.gif differ diff --git a/packages/foam-vscode/package.json b/packages/foam-vscode/package.json index 3a92b7a1..494099ad 100644 --- a/packages/foam-vscode/package.json +++ b/packages/foam-vscode/package.json @@ -255,6 +255,11 @@ "Disable wikilink definitions generation" ] }, + "foam.links.sync.enable": { + "description": "Enable synching links when moving/renaming notes", + "type": "boolean", + "default": true + }, "foam.links.hover.enable": { "description": "Enable displaying note content on hover links", "type": "boolean", @@ -418,7 +423,8 @@ "tsdx": "^0.13.2", "tslib": "^2.0.0", "typescript": "^3.9.5", - "vscode-test": "^1.3.0" + "vscode-test": "^1.3.0", + "wait-for-expect": "^3.0.2" }, "dependencies": { "dateformat": "^3.0.3", @@ -440,4 +446,4 @@ "unist-util-visit": "^2.0.2", "yaml": "^1.10.0" } -} +} \ No newline at end of file diff --git a/packages/foam-vscode/src/core/model/uri.test.ts b/packages/foam-vscode/src/core/model/uri.test.ts index f36d96f0..cdb21722 100644 --- a/packages/foam-vscode/src/core/model/uri.test.ts +++ b/packages/foam-vscode/src/core/model/uri.test.ts @@ -71,5 +71,13 @@ describe('Foam URI', () => { expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual( URI.file('/hello.markdown') ); + expect( + URI.file('/path/to/a/note.md').resolve('../another-note.md') + ).toEqual(URI.file('/path/to/another-note.md')); + expect( + URI.file('/path/to/a/note.md').relativeTo( + URI.file('/path/to/another/note.md').getDirectory() + ) + ).toEqual(URI.file('../a/note.md')); }); }); diff --git a/packages/foam-vscode/src/core/model/workspace.test.ts b/packages/foam-vscode/src/core/model/workspace.test.ts index 470d484d..a94a135b 100644 --- a/packages/foam-vscode/src/core/model/workspace.test.ts +++ b/packages/foam-vscode/src/core/model/workspace.test.ts @@ -167,4 +167,23 @@ describe('Identifier computation', () => { const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack); expect(identifier).toEqual('project/car/todo'); }); + + it('should ignore elements from the exclude list', () => { + const workspace = new FoamWorkspace(); + const noteA = createTestNote({ uri: '/path/to/note-a.md' }); + const noteB = createTestNote({ uri: '/path/to/note-b.md' }); + const noteC = createTestNote({ uri: '/path/to/note-c.md' }); + const noteD = createTestNote({ uri: '/path/to/note-d.md' }); + const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' }); + + workspace + .set(noteA) + .set(noteB) + .set(noteC) + .set(noteD); + expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a'); + expect( + workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri]) + ).toEqual('note-a'); + }); }); diff --git a/packages/foam-vscode/src/core/model/workspace.ts b/packages/foam-vscode/src/core/model/workspace.ts index 45175a56..a09282cd 100644 --- a/packages/foam-vscode/src/core/model/workspace.ts +++ b/packages/foam-vscode/src/core/model/workspace.ts @@ -5,7 +5,6 @@ import { isSome } from '../utils'; import { Emitter } from '../common/event'; import { ResourceProvider } from './provider'; import { IDisposable } from '../common/lifecycle'; -import { Logger } from '../utils/log'; export class FoamWorkspace implements IDisposable { private onDidAddEmitter = new Emitter(); @@ -83,17 +82,25 @@ export class FoamWorkspace implements IDisposable { * * @param forResource the resource to compute the identifier for */ - public getIdentifier(forResource: URI): string { + public getIdentifier(forResource: URI, exclude?: URI[]): string { const amongst = []; const basename = forResource.getBasename(); for (const res of this._resources.values()) { - // Just a quick optimization to only add the elements that might match - if (res.uri.path.endsWith(basename)) { - if (!res.uri.isEqual(forResource)) { - amongst.push(res.uri); - } + // skip elements that cannot possibly match + if (!res.uri.path.endsWith(basename)) { + continue; } + // skip self + if (res.uri.isEqual(forResource)) { + continue; + } + // skip exclude list + if (exclude && exclude.find(ex => ex.isEqual(res.uri))) { + continue; + } + amongst.push(res.uri); } + let identifier = FoamWorkspace.getShortestIdentifier( forResource.path, amongst.map(uri => uri.path) diff --git a/packages/foam-vscode/src/core/services/markdown-link.test.ts b/packages/foam-vscode/src/core/services/markdown-link.test.ts index e3023709..fc4c6aaa 100644 --- a/packages/foam-vscode/src/core/services/markdown-link.test.ts +++ b/packages/foam-vscode/src/core/services/markdown-link.test.ts @@ -96,7 +96,7 @@ describe('MarkdownLink', () => { }); describe('rename wikilink', () => { - it.skip('should rename the target only', () => { + it('should rename the target only', () => { const link = parser.parse( getRandomURI(), `this is a [[wikilink#section]]` diff --git a/packages/foam-vscode/src/core/services/markdown-link.ts b/packages/foam-vscode/src/core/services/markdown-link.ts index ff797968..2af5962b 100644 --- a/packages/foam-vscode/src/core/services/markdown-link.ts +++ b/packages/foam-vscode/src/core/services/markdown-link.ts @@ -2,7 +2,7 @@ import { ResourceLink } from '../model/note'; export abstract class MarkdownLink { private static wikilinkRegex = new RegExp( - /\[\[([^#\|]+)?#?([^\|]+)?\|?(.*)?\]\]/ + /\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/ ); private static directLinkRegex = new RegExp( /\[([^\]]+)\]\(([^#]*)?#?([^\]]+)?\)/ @@ -10,7 +10,7 @@ export abstract class MarkdownLink { public static analyzeLink(link: ResourceLink) { if (link.type === 'wikilink') { - const [_, target, section, alias] = this.wikilinkRegex.exec(link.rawText); + const [, target, section, alias] = this.wikilinkRegex.exec(link.rawText); return { target: target?.replace(/\\/g, ''), section, @@ -18,7 +18,7 @@ export abstract class MarkdownLink { }; } if (link.type === 'link') { - const [_, alias, target, section] = this.directLinkRegex.exec( + const [, alias, target, section] = this.directLinkRegex.exec( link.rawText ); return { target, section, alias }; diff --git a/packages/foam-vscode/src/core/services/markdown-parser.test.ts b/packages/foam-vscode/src/core/services/markdown-parser.test.ts index 4bdec5c1..ab5b2294 100644 --- a/packages/foam-vscode/src/core/services/markdown-parser.test.ts +++ b/packages/foam-vscode/src/core/services/markdown-parser.test.ts @@ -1,5 +1,4 @@ import { createMarkdownParser, ParserPlugin } from './markdown-parser'; -import { ResourceLink } from '../model/note'; import { Logger } from '../utils/log'; import { URI } from '../model/uri'; import { Range } from '../model/range'; diff --git a/packages/foam-vscode/src/core/services/markdown-provider.test.ts b/packages/foam-vscode/src/core/services/markdown-provider.test.ts index 283c2475..4340e2f3 100644 --- a/packages/foam-vscode/src/core/services/markdown-provider.test.ts +++ b/packages/foam-vscode/src/core/services/markdown-provider.test.ts @@ -150,7 +150,22 @@ describe('Link resolution', () => { }); describe('Markdown direct links', () => { - it('should support absolute path', () => { + it('should support absolute path 1', () => { + const noteA = createTestNote({ + uri: '/path/to/page-a.md', + links: [{ to: '/path/to/another/page-b.md' }], + }); + const noteB = createTestNote({ + uri: '/path/to/another/page-b.md', + links: [{ to: '../../to/page-a.md' }], + }); + + const ws = createTestWorkspace(); + ws.set(noteA).set(noteB); + expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri); + }); + + it('should support relative path 1', () => { const noteA = createTestNote({ uri: '/path/to/page-a.md', links: [{ to: './another/page-b.md' }], @@ -165,13 +180,13 @@ describe('Link resolution', () => { expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri); }); - it('should support relative path', () => { + it('should support relative path 2', () => { const noteA = createTestNote({ uri: '/path/to/page-a.md', - links: [{ to: 'more/page-c.md' }], + links: [{ to: 'more/page-b.md' }], }); const noteB = createTestNote({ - uri: '/path/to/more/page-c.md', + uri: '/path/to/more/page-b.md', }); const ws = createTestWorkspace(); ws.set(noteA).set(noteB); @@ -181,10 +196,10 @@ describe('Link resolution', () => { it('should default to relative path', () => { const noteA = createTestNote({ uri: '/path/to/page-a.md', - links: [{ to: 'page c.md' }], + links: [{ to: 'page .md' }], }); const noteB = createTestNote({ - uri: '/path/to/page c.md', + uri: '/path/to/page .md', }); const ws = createTestWorkspace(); ws.set(noteA).set(noteB); diff --git a/packages/foam-vscode/src/features/copy-without-brackets.spec.ts b/packages/foam-vscode/src/features/copy-without-brackets.spec.ts index 89984774..cb0051bc 100644 --- a/packages/foam-vscode/src/features/copy-without-brackets.spec.ts +++ b/packages/foam-vscode/src/features/copy-without-brackets.spec.ts @@ -1,9 +1,5 @@ import { env, Position, Selection, commands } from 'vscode'; -import { - createFile, - getUriInWorkspace, - showInEditor, -} from '../test/test-utils-vscode'; +import { createFile, showInEditor } from '../test/test-utils-vscode'; describe('copyWithoutBrackets', () => { it('should get the input from the active editor selection', async () => { diff --git a/packages/foam-vscode/src/features/index.ts b/packages/foam-vscode/src/features/index.ts index 914c1f60..fbd7d3f2 100644 --- a/packages/foam-vscode/src/features/index.ts +++ b/packages/foam-vscode/src/features/index.ts @@ -18,9 +18,11 @@ import tagCompletionProvider from './tag-completion'; import linkDecorations from './document-decorator'; import navigationProviders from './navigation-provider'; import wikilinkDiagnostics from './wikilink-diagnostics'; +import refactor from './refactor'; import { FoamFeature } from '../types'; export const features: FoamFeature[] = [ + refactor, navigationProviders, wikilinkDiagnostics, tagsExplorer, diff --git a/packages/foam-vscode/src/features/link-completion.spec.ts b/packages/foam-vscode/src/features/link-completion.spec.ts index 98945122..83ae8d94 100644 --- a/packages/foam-vscode/src/features/link-completion.spec.ts +++ b/packages/foam-vscode/src/features/link-completion.spec.ts @@ -1,7 +1,6 @@ import * as vscode from 'vscode'; import { createMarkdownParser } from '../core/services/markdown-parser'; import { FoamGraph } from '../core/model/graph'; -import { FoamWorkspace } from '../core/model/workspace'; import { createTestNote, createTestWorkspace } from '../test/test-utils'; import { cleanWorkspace, diff --git a/packages/foam-vscode/src/features/navigation-provider.spec.ts b/packages/foam-vscode/src/features/navigation-provider.spec.ts index d55087e6..40e002f8 100644 --- a/packages/foam-vscode/src/features/navigation-provider.spec.ts +++ b/packages/foam-vscode/src/features/navigation-provider.spec.ts @@ -11,7 +11,6 @@ import { NavigationProvider } from './navigation-provider'; import { OPEN_COMMAND } from './utility-commands'; import { toVsCodeUri } from '../utils/vsc-utils'; import { createMarkdownParser } from '../core/services/markdown-parser'; -import { FoamWorkspace } from '../core/model/workspace'; import { FoamGraph } from '../core/model/graph'; describe('Document navigation', () => { @@ -232,6 +231,6 @@ describe('Document navigation', () => { range: new vscode.Range(0, 23, 0, 23 + 9), }); }); - // it('should provide references for placeholders', async () => {}); + it.todo('should provide references for placeholders'); }); }); diff --git a/packages/foam-vscode/src/features/refactor.spec.ts b/packages/foam-vscode/src/features/refactor.spec.ts new file mode 100644 index 00000000..f7b9199c --- /dev/null +++ b/packages/foam-vscode/src/features/refactor.spec.ts @@ -0,0 +1,205 @@ +import { wait, waitForExpect } from '../test/test-utils'; +import { + closeEditors, + createFile, + cleanWorkspace, + readFile, + renameFile, + showInEditor, +} from '../test/test-utils-vscode'; + +describe('Note rename sync', () => { + beforeAll(async () => { + await closeEditors(); + await cleanWorkspace(); + }); + afterAll(closeEditors); + + describe('wikilinks', () => { + it('should sync wikilinks to renamed notes', async () => { + const noteA = await createFile(`Content of note A`, [ + 'refactor', + 'wikilinks', + 'rename-note-a.md', + ]); + const noteB = await createFile( + `Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`, + ['refactor', 'wikilinks', 'rename-note-b.md'] + ); + const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [ + 'refactor', + 'wikilinks', + 'rename-note-c.md', + ]); + const { doc } = await showInEditor(noteB.uri); + + const newName = 'renamed-note-a'; + const newUri = noteA.uri.resolve(newName); + + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + // check it updates documents open in editors + expect(doc.getText()).toEqual( + `Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]` + ); + // and documents not open in editors + expect(await readFile(noteC.uri)).toEqual( + `Link to [[${newName}]] from note C.` + ); + }); + }); + + it('should use the best identifier based on the new note location', async () => { + const noteA = await createFile(`Content of note A`, [ + 'refactor', + 'wikilink', + 'first', + 'note-a.md', + ]); + await createFile(`Content of note B`, [ + 'refactor', + 'wikilink', + 'second', + 'note-b.md', + ]); + const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`); + + const { doc } = await showInEditor(noteC.uri); + + // rename note A + const newUri = noteA.uri.resolve('note-b.md'); + + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + expect(doc.getText()).toEqual(`Link to [[first/note-b]] from note C.`); + }); + }); + + it('should use the best identifier when moving the note to another directory', async () => { + const noteA = await createFile(`Content of note A`, [ + 'refactor', + 'wikilink', + 'first', + 'note-a.md', + ]); + await createFile(`Content of note B`, [ + 'refactor', + 'wikilink', + 'second', + 'note-b.md', + ]); + const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`); + + const { doc } = await showInEditor(noteC.uri); + + const newUri = noteA.uri.resolve('../second/note-a.md'); + + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + expect(doc.getText()).toEqual(`Link to [[note-a]] from note C.`); + }); + }); + + it('should keep the alias in wikilinks', async () => { + const noteA = await createFile(`Content of note A`); + const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`); + + const { doc } = await showInEditor(noteB.uri); + + // rename note A + const newUri = noteA.uri.resolve('new-note-a.md'); + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + expect(doc.getText()).toEqual(`Link to [[new-note-a|Alias]]`); + }); + }); + + it('should keep the section part of the wikilink', async () => { + const noteA = await createFile(`Content of note A`); + const noteB = await createFile(`Link to [[${noteA.name}#Section]]`); + + const { doc } = await showInEditor(noteB.uri); + + // rename note A + const newUri = noteA.uri.resolve('new-note-with-section.md'); + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + expect(doc.getText()).toEqual( + `Link to [[new-note-with-section#Section]]` + ); + }); + }); + + it('should sync when moving the note to a new folder', async () => { + const noteA = await createFile(`Content of note A`, [ + 'refactor', + 'first', + 'note-a.md', + ]); + const noteC = await createFile(`Link to [[note-a]] from note C.`); + + const newUri = noteA.uri.resolve('../note-a.md'); + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + const content = await readFile(noteC.uri); + await waitForExpect(async () => { + expect(content).toEqual(`Link to [[note-a]] from note C.`); + }); + }); + }); + + describe('direct links', () => { + beforeAll(async () => { + await closeEditors(); + await cleanWorkspace(); + }); + beforeEach(closeEditors); + + it('should rename relative direct links', async () => { + const noteA = await createFile( + `Content of note A. Lorem etc etc etc etc`, + ['refactor', 'direct-links', 'f1', 'note-a.md'] + ); + const noteB = await createFile( + `Link to [note](../f1/note-a.md) from note B.`, + ['refactor', 'direct-links', 'f2', 'note-b.md'] + ); + const { doc } = await showInEditor(noteB.uri); + + const newUri = noteA.uri.resolve('../note-a.md'); + // wait for workspace files to be added to graph (because of graph debounced update) + // TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode + await wait(600); + await renameFile(noteA.uri, newUri); + + await waitForExpect(async () => { + expect(doc.getText()).toEqual( + `Link to [note](../note-a.md) from note B.` + ); + }); + }); + }); +}); diff --git a/packages/foam-vscode/src/features/refactor.ts b/packages/foam-vscode/src/features/refactor.ts new file mode 100644 index 00000000..305f9c1d --- /dev/null +++ b/packages/foam-vscode/src/features/refactor.ts @@ -0,0 +1,108 @@ +import * as vscode from 'vscode'; +import { Foam } from '../core/model/foam'; +import { MarkdownLink } from '../core/services/markdown-link'; +import { Logger } from '../core/utils/log'; +import { isAbsolute } from '../core/utils/path'; +import { getFoamVsCodeConfig } from '../services/config'; +import { FoamFeature } from '../types'; +import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils'; + +const feature: FoamFeature = { + activate: async ( + context: vscode.ExtensionContext, + foamPromise: Promise + ) => { + const foam = await foamPromise; + + context.subscriptions.push( + vscode.workspace.onWillRenameFiles(async e => { + if (!getFoamVsCodeConfig('links.sync.enable')) { + return; + } + const renameEdits = new vscode.WorkspaceEdit(); + e.files.forEach(({ oldUri, newUri }) => { + const connections = foam.graph.getBacklinks(fromVsCodeUri(oldUri)); + connections.forEach(async connection => { + const { target } = MarkdownLink.analyzeLink(connection.link); + switch (connection.link.type) { + case 'wikilink': { + const identifier = foam.workspace.getIdentifier( + fromVsCodeUri(newUri), + [fromVsCodeUri(oldUri)] + ); + const edit = MarkdownLink.createUpdateLinkEdit( + connection.link, + { target: identifier } + ); + renameEdits.replace( + toVsCodeUri(connection.source), + toVsCodeRange(edit.selection), + edit.newText + ); + break; + } + case 'link': { + const path = isAbsolute(target) + ? '/' + vscode.workspace.asRelativePath(newUri) + : fromVsCodeUri(newUri).relativeTo( + connection.source.getDirectory() + ).path; + const edit = MarkdownLink.createUpdateLinkEdit( + connection.link, + { target: path } + ); + renameEdits.replace( + toVsCodeUri(connection.source), + toVsCodeRange(edit.selection), + edit.newText + ); + break; + } + } + }); + }); + + try { + if (renameEdits.size > 0) { + // We break the update by file because applying it at once was causing + // dirty state and editors not always saving or closing + for (const renameEditForUri of renameEdits.entries()) { + const [uri, edits] = renameEditForUri; + const fileEdits = new vscode.WorkspaceEdit(); + fileEdits.set(uri, edits); + await vscode.workspace.applyEdit(fileEdits); + const editor = await vscode.workspace.openTextDocument(uri); + // Because the save happens within 50ms of opening the doc, it will be then closed + editor.save(); + } + + // Reporting + const nUpdates = renameEdits.entries().reduce((acc, entry) => { + return (acc += entry[1].length); + }, 0); + const links = nUpdates > 1 ? 'links' : 'link'; + const nFiles = renameEdits.size; + const files = nFiles > 1 ? 'files' : 'file'; + Logger.info( + `Updated links in the following files:`, + ...renameEdits + .entries() + .map(e => vscode.workspace.asRelativePath(e[0])) + ); + vscode.window.showInformationMessage( + `Updated ${nUpdates} ${links} across ${nFiles} ${files}.` + ); + } + } catch (e) { + Logger.error('Error while updating references to file', e); + vscode.window.showErrorMessage( + `Foam couldn't update the links to ${vscode.workspace.asRelativePath( + e.newUri + )}. Check the logs for error details.` + ); + } + }) + ); + }, +}; +export default feature; diff --git a/packages/foam-vscode/src/features/utility-commands.ts b/packages/foam-vscode/src/features/utility-commands.ts index a76356a6..faff0d87 100644 --- a/packages/foam-vscode/src/features/utility-commands.ts +++ b/packages/foam-vscode/src/features/utility-commands.ts @@ -1,10 +1,9 @@ import * as vscode from 'vscode'; import { FoamFeature } from '../types'; import { URI } from '../core/model/uri'; -import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils'; +import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils'; import { NoteFactory } from '../services/templates'; import { Foam } from '../core/model/foam'; -import { Resource } from '../core/model/note'; export const OPEN_COMMAND = { command: 'foam-vscode.open-resource', diff --git a/packages/foam-vscode/src/test/test-utils-vscode.ts b/packages/foam-vscode/src/test/test-utils-vscode.ts index 4982c740..22b8dacb 100644 --- a/packages/foam-vscode/src/test/test-utils-vscode.ts +++ b/packages/foam-vscode/src/test/test-utils-vscode.ts @@ -3,7 +3,7 @@ */ import * as vscode from 'vscode'; import path from 'path'; -import { TextEncoder } from 'util'; +import { TextDecoder, TextEncoder } from 'util'; import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils'; import { Logger } from '../core/utils/log'; import { URI } from '../core/model/uri'; @@ -64,6 +64,18 @@ export const createFile = async (content: string, filepath: string[] = []) => { return { uri, content, ...filenameComponents }; }; +export const renameFile = (from: URI, to: URI) => { + const edit = new vscode.WorkspaceEdit(); + edit.renameFile(toVsCodeUri(from), toVsCodeUri(to)); + return vscode.workspace.applyEdit(edit); +}; + +const decoder = new TextDecoder('utf-8'); +export const readFile = async (uri: URI) => { + const content = await vscode.workspace.fs.readFile(toVsCodeUri(uri)); + return decoder.decode(content); +}; + export const createNote = (r: Resource) => { const content = `# ${r.title} diff --git a/packages/foam-vscode/src/test/test-utils.ts b/packages/foam-vscode/src/test/test-utils.ts index 0710a75d..0a5c754f 100644 --- a/packages/foam-vscode/src/test/test-utils.ts +++ b/packages/foam-vscode/src/test/test-utils.ts @@ -9,6 +9,8 @@ import { Matcher } from '../core/services/datastore'; import { MarkdownResourceProvider } from '../core/services/markdown-provider'; import { NoteLinkDefinition, Resource } from '../core/model/note'; +export { default as waitForExpect } from 'wait-for-expect'; + Logger.setLevel('error'); export const TEST_DATA_DIR = URI.file(__dirname).joinPath( diff --git a/readme.md b/readme.md index 9c197cf2..daca7d95 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,12 @@ Foam helps you create the connections between your notes, and your placeholders ![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif) +### Sync links on file rename + +Foam updates the links to renamed files, so your notes stay consistent. + +![Sync links on file rename](./assets/screenshots/feature-link-sync.gif) + ### Unique identifiers across directories Foam supports files with the same name in multiple directories. diff --git a/yarn.lock b/yarn.lock index 4b6a4e63..f63b54a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11229,6 +11229,11 @@ w3c-xmlserializer@^2.0.0: dependencies: xml-name-validator "^3.0.0" +wait-for-expect@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-3.0.2.tgz#d2f14b2f7b778c9b82144109c8fa89ceaadaa463" + integrity sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag== + walker@^1.0.7, walker@~1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"