mirror of
https://github.com/foambubble/foam.git
synced 2026-01-08 05:34:09 -05:00
Add Link Conversion Commands and Auto-Convert on Completion (#1525)
* Added setting to automatically convert wikilinks into md links on insertion/completion * Improved Position mock and added mock for `extensions` namespace Fixes #1464
This commit is contained in:
@@ -71,3 +71,25 @@ Examples:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Link Conversion Commands
|
||||
|
||||
Foam provides commands to convert between wikilink and markdown link formats.
|
||||
|
||||
### foam-vscode.convert-wikilink-to-mdlink
|
||||
|
||||
Converts a wikilink at the cursor position to markdown link format with a relative path.
|
||||
|
||||
Example: `[[my-note]]` → `[My Note](../path/to/my-note.md)`
|
||||
|
||||
### foam-vscode.convert-mdlink-to-wikilink
|
||||
|
||||
Converts a markdown link at the cursor position to wikilink format.
|
||||
|
||||
Example: `[My Note](../path/to/my-note.md)` → `[[my-note]]`
|
||||
|
||||
**Usage:**
|
||||
|
||||
1. Place your cursor inside a wikilink or markdown link
|
||||
2. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)
|
||||
3. Type "Foam: Convert" and select the desired conversion command
|
||||
|
||||
@@ -367,12 +367,12 @@
|
||||
"title": "Foam: Open Resource"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.convert-link-style-inplace",
|
||||
"title": "Foam: Convert Link Style in Place"
|
||||
"command": "foam-vscode.convert-wikilink-to-mdlink",
|
||||
"title": "Foam: Convert Wikilink to Markdown Link"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.convert-link-style-incopy",
|
||||
"title": "Foam: Convert Link Format in Copy"
|
||||
"command": "foam-vscode.convert-mdlink-to-wikilink",
|
||||
"title": "Foam: Convert Markdown Link to Wikilink"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.search-tag",
|
||||
@@ -526,6 +526,19 @@
|
||||
"Use alias if resource path is different from title"
|
||||
]
|
||||
},
|
||||
"foam.completion.linkFormat": {
|
||||
"type": "string",
|
||||
"default": "wikilink",
|
||||
"description": "Controls the format of completed links",
|
||||
"enum": [
|
||||
"wikilink",
|
||||
"link"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Complete as wikilinks (e.g., [[note-name]])",
|
||||
"Complete as markdown links (e.g., [Note Name](note-name.md))"
|
||||
]
|
||||
},
|
||||
"foam.files.ignore": {
|
||||
"type": [
|
||||
"array"
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Resource, ResourceLink } from '../model/note';
|
||||
import { URI } from '../model/uri';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { isNone } from '../utils';
|
||||
import { MarkdownLink } from '../services/markdown-link';
|
||||
|
||||
export interface LinkReplace {
|
||||
newText: string;
|
||||
range: Range /* old range */;
|
||||
}
|
||||
import { TextEdit } from '../services/text-edit';
|
||||
|
||||
/**
|
||||
* convert a link based on its workspace and the note containing it.
|
||||
@@ -27,7 +22,7 @@ export function convertLinkFormat(
|
||||
targetFormat: 'wikilink' | 'link',
|
||||
workspace: FoamWorkspace,
|
||||
note: Resource | URI
|
||||
): LinkReplace {
|
||||
): TextEdit {
|
||||
const resource = note instanceof URI ? workspace.find(note) : note;
|
||||
const targetUri = workspace.resolveLink(resource, link);
|
||||
/* If it's already the target format or a placeholder, no transformation happens */
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { commands, ExtensionContext, window, workspace, Uri } from 'vscode';
|
||||
import { Foam } from '../../core/model/foam';
|
||||
import { FoamWorkspace } from '../../core/model/workspace';
|
||||
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
|
||||
import { ResourceParser } from '../../core/model/note';
|
||||
import { IMatcher } from '../../core/services/datastore';
|
||||
import { convertLinkFormat } from '../../core/janitor';
|
||||
import { isMdEditor } from '../../services/editor';
|
||||
|
||||
type LinkFormat = 'wikilink' | 'link';
|
||||
|
||||
enum ConvertOption {
|
||||
Wikilink2MDlink,
|
||||
MDlink2Wikilink,
|
||||
}
|
||||
|
||||
interface IConfig {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const Config: { [key in ConvertOption]: IConfig } = {
|
||||
[ConvertOption.Wikilink2MDlink]: {
|
||||
from: 'wikilink',
|
||||
to: 'link',
|
||||
},
|
||||
[ConvertOption.MDlink2Wikilink]: {
|
||||
from: 'link',
|
||||
to: 'wikilink',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function activate(
|
||||
context: ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
/*
|
||||
commands:
|
||||
foam-vscode.convert-link-style-inplace
|
||||
foam-vscode.convert-link-style-incopy
|
||||
*/
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand('foam-vscode.convert-link-style-inplace', () => {
|
||||
return convertLinkAdapter(
|
||||
foam.workspace,
|
||||
foam.services.parser,
|
||||
foam.services.matcher,
|
||||
true
|
||||
);
|
||||
}),
|
||||
commands.registerCommand('foam-vscode.convert-link-style-incopy', () => {
|
||||
return convertLinkAdapter(
|
||||
foam.workspace,
|
||||
foam.services.parser,
|
||||
foam.services.matcher,
|
||||
false
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function convertLinkAdapter(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
isInPlace: boolean
|
||||
) {
|
||||
const convertOption = await pickConvertStrategy();
|
||||
if (!convertOption) {
|
||||
window.showInformationMessage('Convert canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInPlace) {
|
||||
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
|
||||
} else {
|
||||
await convertLinkInCopy(fWorkspace, fParser, fMatcher, convertOption);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickConvertStrategy(): Promise<IConfig | undefined> {
|
||||
const options = {
|
||||
'to wikilink': ConvertOption.MDlink2Wikilink,
|
||||
'to markdown link': ConvertOption.Wikilink2MDlink,
|
||||
};
|
||||
return window.showQuickPick(Object.keys(options)).then(name => {
|
||||
if (name) {
|
||||
return Config[options[name]];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* convert links based on its workspace and the note containing it.
|
||||
* Changes happen in-place
|
||||
* @param fWorkspace
|
||||
* @param fParser
|
||||
* @param fMatcher
|
||||
* @param convertOption
|
||||
* @returns void
|
||||
*/
|
||||
async function convertLinkInPlace(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
convertOption: IConfig
|
||||
) {
|
||||
const editor = window.activeTextEditor;
|
||||
const doc = editor.document;
|
||||
|
||||
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
|
||||
return;
|
||||
}
|
||||
// const eol = getEditorEOL();
|
||||
let text = doc.getText();
|
||||
|
||||
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
|
||||
|
||||
const textReplaceArr = resource.links
|
||||
.filter(link => link.type === convertOption.from)
|
||||
.map(link =>
|
||||
convertLinkFormat(
|
||||
link,
|
||||
convertOption.to as LinkFormat,
|
||||
fWorkspace,
|
||||
resource
|
||||
)
|
||||
)
|
||||
/* transform .range property into vscode range */
|
||||
.map(linkReplace => ({
|
||||
...linkReplace,
|
||||
range: toVsCodeRange(linkReplace.range),
|
||||
}));
|
||||
|
||||
/* reorder the array such that the later range comes first */
|
||||
textReplaceArr.sort((a, b) => b.range.start.compareTo(a.range.start));
|
||||
|
||||
await editor.edit(editorBuilder => {
|
||||
textReplaceArr.forEach(edit => {
|
||||
editorBuilder.replace(edit.range, edit.newText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* convert links based on its workspace and the note containing it.
|
||||
* Changes happen in a copy
|
||||
* 1. prepare a copy file, and makt it the activeTextEditor
|
||||
* 2. call to convertLinkInPlace
|
||||
* @param fWorkspace
|
||||
* @param fParser
|
||||
* @param fMatcher
|
||||
* @param convertOption
|
||||
* @returns void
|
||||
*/
|
||||
async function convertLinkInCopy(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
convertOption: IConfig
|
||||
) {
|
||||
const editor = window.activeTextEditor;
|
||||
const doc = editor.document;
|
||||
|
||||
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
|
||||
return;
|
||||
}
|
||||
// const eol = getEditorEOL();
|
||||
let text = doc.getText();
|
||||
|
||||
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
|
||||
const basePath = doc.uri.path.split('/').slice(0, -1).join('/');
|
||||
|
||||
const fileUri = doc.uri.with({
|
||||
path: `${
|
||||
basePath ? basePath + '/' : ''
|
||||
}${resource.uri.getName()}.copy${resource.uri.getExtension()}`,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
await workspace.fs.writeFile(fileUri, encoder.encode(text));
|
||||
await window.showTextDocument(fileUri);
|
||||
|
||||
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
|
||||
}
|
||||
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal file
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/* @unit-ready */
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
waitForNoteInFoamWorkspace,
|
||||
} from '../../test/test-utils-vscode';
|
||||
import { deleteFile } from '../../services/editor';
|
||||
import { Logger } from '../../core/utils/log';
|
||||
import {
|
||||
CONVERT_WIKILINK_TO_MDLINK,
|
||||
CONVERT_MDLINK_TO_WIKILINK,
|
||||
} from './convert-links';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('Link Conversion Commands', () => {
|
||||
beforeEach(async () => {
|
||||
await cleanWorkspace();
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanWorkspace();
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
describe('foam-vscode.convert-wikilink-to-markdown', () => {
|
||||
it('should convert wikilink to markdown link', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile('Text before [[note-a]] text after');
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
const result = editor.document.getText();
|
||||
expect(result).toBe('Text before [Note A](note-a.md) text after');
|
||||
|
||||
await deleteFile(noteA.uri);
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should position cursor at end of converted text', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile('Text before [[note-a]] text after');
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
// Cursor should be at the end of the converted markdown link
|
||||
const expectedPosition = 'Text before [Note A](note-a.md)'.length;
|
||||
expect(editor.selection.active).toEqual(
|
||||
new vscode.Position(0, expectedPosition)
|
||||
);
|
||||
|
||||
await deleteFile(noteA.uri);
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should show info message when no wikilink at cursor', async () => {
|
||||
const { uri } = await createFile('Text with no wikilinks');
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 5, 0, 5);
|
||||
|
||||
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
'No wikilink found at cursor position'
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should show error when resource not found', async () => {
|
||||
const { uri } = await createFile(
|
||||
'Text before [[nonexistent-file]] text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 20, 0, 20);
|
||||
|
||||
const showErrorSpy = jest
|
||||
.spyOn(vscode.window, 'showErrorMessage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
Logger.setLevel('off');
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
Logger.setLevel('error');
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalled();
|
||||
|
||||
showErrorSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foam-vscode.convert-markdown-to-wikilink', () => {
|
||||
it('should convert markdown link to wikilink', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile(
|
||||
'Text before [Note A](note-a.md) text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
const result = editor.document.getText();
|
||||
expect(result).toBe('Text before [[note-a]] text after');
|
||||
|
||||
await deleteFile(uri);
|
||||
await deleteFile(noteA.uri);
|
||||
});
|
||||
|
||||
it('should position cursor at end of converted text', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile(
|
||||
'Text before [Note A](note-a.md) text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
// Cursor should be at the end of the converted wikilink
|
||||
const expectedPosition = 'Text before [[note-a]]'.length;
|
||||
expect(editor.document.getText()).toBe(
|
||||
'Text before [[note-a]] text after'
|
||||
);
|
||||
expect(editor.selection.active).toEqual(
|
||||
new vscode.Position(0, expectedPosition)
|
||||
);
|
||||
|
||||
await deleteFile(uri);
|
||||
await deleteFile(noteA.uri);
|
||||
});
|
||||
|
||||
it('should show info message when no markdown link at cursor', async () => {
|
||||
const { uri } = await createFile('Text with no markdown links');
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 5, 0, 5);
|
||||
|
||||
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
'No markdown link found at cursor position'
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command registration', () => {
|
||||
it('should handle no active editor gracefully', async () => {
|
||||
await closeEditors();
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal file
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
convertWikilinkToMarkdownAtPosition,
|
||||
convertMarkdownToWikilinkAtPosition,
|
||||
} from './convert-links';
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { Position } from '../../core/model/position';
|
||||
import { Range } from '../../core/model/range';
|
||||
import { TextEdit } from '../../core/services/text-edit';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { createMarkdownParser } from '../../core/services/markdown-parser';
|
||||
|
||||
describe('Link Conversion Functions', () => {
|
||||
describe('convertWikilinkToMarkdownAtPosition', () => {
|
||||
it('should convert simple wikilink to markdown link', () => {
|
||||
const documentText = 'Text before [[note-a]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 }; // Inside [[note-a]]
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note A](note-a.md)');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 22));
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe('Text before [Note A](note-a.md) text after');
|
||||
});
|
||||
|
||||
it('should convert wikilink with alias to markdown link', () => {
|
||||
const documentText = 'Text before [[note-a|Custom Title]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Custom Title](note-a.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Custom Title](note-a.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle subfolders paths correctly', () => {
|
||||
const documentText = 'Text before [[path/to/note-b]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 20 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/path/to/note-b.md', title: 'Note B' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note B](path/to/note-b.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Note B](path/to/note-b.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle relative paths correctly', () => {
|
||||
const documentText = 'Text before [[note-b]] text after';
|
||||
const documentUri = URI.file('/test/sub1/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 20 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/sub2/note-b.md', title: 'Note B' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note B](../sub2/note-b.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Note B](../sub2/note-b.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no wikilink at cursor position', () => {
|
||||
const documentText = 'Text with no wikilink at cursor';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 5 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when target resource not found', () => {
|
||||
const documentText = 'Text before [[nonexistent]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace(); // Empty workspace
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
expect(() => {
|
||||
convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
}).toThrow('Resource "nonexistent" not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMarkdownToWikilinkAtPosition', () => {
|
||||
it('should convert simple markdown link to wikilink', () => {
|
||||
const documentText = 'Text before [Note A](note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a]]');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 31));
|
||||
});
|
||||
|
||||
it('should convert simple markdown link to other folder to wikilink', () => {
|
||||
const documentText = 'Text before [Note A](docs/note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/docs/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a]]');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 36));
|
||||
});
|
||||
|
||||
it('should preserve alias when different from title', () => {
|
||||
const documentText = 'Text before [Custom Title](note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a|Custom Title]]');
|
||||
});
|
||||
|
||||
it('should return null when no markdown link at cursor position', () => {
|
||||
const documentText = 'Text with no markdown link at cursor';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 5 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when target resource not found', () => {
|
||||
const documentText = 'Text before [Link](nonexistent.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
expect(() => {
|
||||
convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
}).toThrow('Resource not found: /test/nonexistent.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal file
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../../core/model/foam';
|
||||
import { Resource, ResourceLink } from '../../core/model/note';
|
||||
import { MarkdownLink } from '../../core/services/markdown-link';
|
||||
import { Range } from '../../core/model/range';
|
||||
import { Position } from '../../core/model/position';
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
|
||||
import { Logger } from '../../core/utils/log';
|
||||
import { TextEdit } from '../../core/services/text-edit';
|
||||
|
||||
export const CONVERT_WIKILINK_TO_MDLINK = {
|
||||
command: 'foam-vscode.convert-wikilink-to-mdlink',
|
||||
title: 'Foam: Convert Wikilink to Markdown Link',
|
||||
};
|
||||
|
||||
export const CONVERT_MDLINK_TO_WIKILINK = {
|
||||
command: 'foam-vscode.convert-mdlink-to-wikilink',
|
||||
title: 'Foam: Convert Markdown Link to Wikilink',
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function to convert a wikilink to markdown link at a specific position
|
||||
* Returns the TextEdit to apply, or null if no conversion is possible
|
||||
*/
|
||||
export function convertWikilinkToMarkdownAtPosition(
|
||||
documentText: string,
|
||||
documentUri: URI,
|
||||
linkPosition: Position,
|
||||
foamWorkspace: { find: (identifier: string) => Resource | null },
|
||||
foamParser: { parse: (uri: URI, text: string) => Resource }
|
||||
): TextEdit | null {
|
||||
// Parse the document to get all links using Foam's parser
|
||||
const resource = foamParser.parse(documentUri, documentText);
|
||||
|
||||
// Find the link at cursor position
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(
|
||||
link =>
|
||||
link.type === 'wikilink' &&
|
||||
Range.containsPosition(link.range, linkPosition)
|
||||
);
|
||||
|
||||
if (!targetLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the link to get target and alias information
|
||||
const linkInfo = MarkdownLink.analyzeLink(targetLink);
|
||||
|
||||
// Find the target resource in the workspace
|
||||
const targetResource = foamWorkspace.find(linkInfo.target);
|
||||
if (!targetResource) {
|
||||
throw new Error(`Resource "${linkInfo.target}" not found`);
|
||||
}
|
||||
|
||||
// Compute relative path from current file to target file
|
||||
const currentDirectory = documentUri.getDirectory();
|
||||
const relativePath = targetResource.uri.relativeTo(currentDirectory).path;
|
||||
|
||||
const alias = linkInfo.alias ? linkInfo.alias : targetResource.title;
|
||||
return MarkdownLink.createUpdateLinkEdit(targetLink, {
|
||||
type: 'link',
|
||||
target: relativePath,
|
||||
alias: alias,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to convert a markdown link to wikilink at a specific position
|
||||
* Returns the TextEdit to apply, or null if no conversion is possible
|
||||
*/
|
||||
export function convertMarkdownToWikilinkAtPosition(
|
||||
documentText: string,
|
||||
documentUri: URI,
|
||||
cursorPosition: Position,
|
||||
foamWorkspace: {
|
||||
resolveLink: (resource: Resource, link: ResourceLink) => URI;
|
||||
get: (uri: URI) => Resource | null;
|
||||
getIdentifier: (uri: URI) => string;
|
||||
},
|
||||
foamParser: { parse: (uri: URI, text: string) => Resource }
|
||||
): TextEdit | null {
|
||||
// Parse the document to get all links using Foam's parser
|
||||
const resource = foamParser.parse(documentUri, documentText);
|
||||
|
||||
// Find the link at cursor position
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(
|
||||
link =>
|
||||
link.type === 'link' && Range.containsPosition(link.range, cursorPosition)
|
||||
);
|
||||
|
||||
if (!targetLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the link to get target and alias information
|
||||
const linkInfo = MarkdownLink.analyzeLink(targetLink);
|
||||
|
||||
// Try to resolve the target resource from the link
|
||||
const targetUri = foamWorkspace.resolveLink(resource, targetLink);
|
||||
const targetResource = foamWorkspace.get(targetUri);
|
||||
|
||||
if (!targetResource) {
|
||||
throw new Error(`Resource not found: ${targetUri.path}`);
|
||||
}
|
||||
|
||||
// Get the workspace identifier for the target resource
|
||||
const identifier = foamWorkspace.getIdentifier(targetResource.uri);
|
||||
|
||||
return MarkdownLink.createUpdateLinkEdit(targetLink, {
|
||||
type: 'wikilink',
|
||||
target: identifier,
|
||||
alias:
|
||||
linkInfo.alias && linkInfo.alias !== targetResource.title
|
||||
? linkInfo.alias
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
export default async function activate(
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(CONVERT_WIKILINK_TO_MDLINK.command, () =>
|
||||
convertWikilinkToMarkdown(foam)
|
||||
),
|
||||
|
||||
vscode.commands.registerCommand(CONVERT_MDLINK_TO_WIKILINK.command, () =>
|
||||
convertMarkdownToWikilink(foam)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wikilink at cursor position to markdown link format
|
||||
*/
|
||||
export async function convertWikilinkToMarkdown(foam: Foam): Promise<void> {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (!activeEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeEditor.document;
|
||||
const position = activeEditor.selection.active;
|
||||
|
||||
try {
|
||||
const edit = convertWikilinkToMarkdownAtPosition(
|
||||
document.getText(),
|
||||
fromVsCodeUri(document.uri),
|
||||
{
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
},
|
||||
foam.workspace,
|
||||
foam.services.parser
|
||||
);
|
||||
|
||||
if (!edit) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No wikilink found at cursor position'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the edit to the document
|
||||
const range = toVsCodeRange(edit.range);
|
||||
const success = await activeEditor.edit(editBuilder => {
|
||||
editBuilder.replace(range, edit.newText);
|
||||
});
|
||||
|
||||
// Position cursor at the end of the updated text
|
||||
if (success) {
|
||||
const newEndPosition = new vscode.Position(
|
||||
range.start.line,
|
||||
range.start.character + edit.newText.length
|
||||
);
|
||||
activeEditor.selection = new vscode.Selection(
|
||||
newEndPosition,
|
||||
newEndPosition
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to convert wikilink to markdown link', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to convert wikilink to markdown link: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown link at cursor position to wikilink format
|
||||
*/
|
||||
export async function convertMarkdownToWikilink(foam: Foam): Promise<void> {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (!activeEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeEditor.document;
|
||||
const position = activeEditor.selection.active;
|
||||
|
||||
try {
|
||||
const edit = convertMarkdownToWikilinkAtPosition(
|
||||
document.getText(),
|
||||
fromVsCodeUri(document.uri),
|
||||
{
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
},
|
||||
foam.workspace,
|
||||
foam.services.parser
|
||||
);
|
||||
|
||||
if (!edit) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No markdown link found at cursor position'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the edit to the document
|
||||
const range = toVsCodeRange(edit.range);
|
||||
const success = await activeEditor.edit(editBuilder => {
|
||||
editBuilder.replace(range, edit.newText);
|
||||
});
|
||||
|
||||
// Position cursor at the end of the updated text
|
||||
if (success) {
|
||||
const newEndPosition = new vscode.Position(
|
||||
range.start.line,
|
||||
range.start.character + edit.newText.length
|
||||
);
|
||||
activeEditor.selection = new vscode.Selection(
|
||||
newEndPosition,
|
||||
newEndPosition
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to convert markdown link to wikilink', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to convert markdown link to wikilink: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
/* @unit-ready */
|
||||
import { commands, window, workspace } from 'vscode';
|
||||
import { toVsCodeUri } from '../../utils/vsc-utils';
|
||||
import { createFile } from '../../test/test-utils-vscode';
|
||||
import { cleanWorkspace, createFile } from '../../test/test-utils-vscode';
|
||||
|
||||
describe('create-note-from-template command', () => {
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,6 @@ export { default as openResource } from './open-resource';
|
||||
export { default as updateGraphCommand } from './update-graph';
|
||||
export { default as updateWikilinksCommand } from './update-wikilinks';
|
||||
export { default as createNote } from './create-note';
|
||||
export { default as generateStandaloneNote } from './convert-links-format-in-note';
|
||||
export { default as searchTagCommand } from './search-tag';
|
||||
export { default as renameTagCommand } from './rename-tag';
|
||||
export { default as convertLinksCommand } from './convert-links';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* @unit-ready */
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { createMarkdownParser } from '../core/services/markdown-parser';
|
||||
import { FoamGraph } from '../core/model/graph';
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
WikilinkCompletionProvider,
|
||||
SectionCompletionProvider,
|
||||
} from './link-completion';
|
||||
import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';
|
||||
|
||||
describe('Link Completion', () => {
|
||||
const parser = createMarkdownParser([]);
|
||||
@@ -281,4 +284,220 @@ alias: alias-a
|
||||
expect(aliasCompletionItem.label).toBe('alias-a');
|
||||
expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a');
|
||||
});
|
||||
|
||||
it('should support linkFormat setting - wikilink format (default)', async () => {
|
||||
const { uri: noteUri, content } = await createFile(`# My Note Title`);
|
||||
const workspace = createTestWorkspace();
|
||||
workspace.set(parser.parse(noteUri, content));
|
||||
const provider = new WikilinkCompletionProvider(
|
||||
workspace,
|
||||
FoamGraph.fromWorkspace(workspace)
|
||||
);
|
||||
|
||||
const { uri } = await createFile('[[');
|
||||
const { doc } = await showInEditor(uri);
|
||||
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'wikilink',
|
||||
async () => {
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.useAlias',
|
||||
'never',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 2)
|
||||
);
|
||||
|
||||
expect(links.items.length).toBe(1);
|
||||
expect(links.items[0].insertText).toBe(
|
||||
workspace.getIdentifier(noteUri)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should support linkFormat setting - markdown link format', async () => {
|
||||
const { uri: noteUri, content } = await createFile(`# My Note Title`, [
|
||||
'my',
|
||||
'path',
|
||||
'to',
|
||||
'test-note.md',
|
||||
]);
|
||||
const workspace = createTestWorkspace();
|
||||
workspace.set(parser.parse(noteUri, content));
|
||||
const provider = new WikilinkCompletionProvider(
|
||||
workspace,
|
||||
FoamGraph.fromWorkspace(workspace)
|
||||
);
|
||||
|
||||
const { uri } = await createFile('[[');
|
||||
const { doc } = await showInEditor(uri);
|
||||
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'link',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 2)
|
||||
);
|
||||
|
||||
expect(links.items.length).toBe(1);
|
||||
const insertText = String(links.items[0].insertText);
|
||||
|
||||
// In test environment, the command converts wikilink to markdown after insertion
|
||||
// The insertText is the wikilink format, conversion happens via command
|
||||
// So we expect just the identifier (no alias because linkFormat === 'link')
|
||||
expect(insertText).toBe(workspace.getIdentifier(noteUri));
|
||||
|
||||
// Commit characters should be empty when using conversion command
|
||||
expect(links.items[0].commitCharacters).toEqual([]);
|
||||
|
||||
// Verify command is attached for conversion
|
||||
expect(links.items[0].command).toBeDefined();
|
||||
expect(links.items[0].command.command).toBe(
|
||||
CONVERT_WIKILINK_TO_MDLINK.command
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should support linkFormat setting with aliases - markdown format', async () => {
|
||||
const { uri: noteUri, content } = await createFile(`# My Different Title`, [
|
||||
'another-note.md',
|
||||
]);
|
||||
const workspace = createTestWorkspace();
|
||||
workspace.set(parser.parse(noteUri, content));
|
||||
const provider = new WikilinkCompletionProvider(
|
||||
workspace,
|
||||
FoamGraph.fromWorkspace(workspace)
|
||||
);
|
||||
|
||||
const { uri } = await createFile('[[');
|
||||
const { doc } = await showInEditor(uri);
|
||||
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'link',
|
||||
async () => {
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.useAlias',
|
||||
'whenPathDiffersFromTitle',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 2)
|
||||
);
|
||||
|
||||
expect(links.items.length).toBe(1);
|
||||
const insertText = links.items[0].insertText;
|
||||
|
||||
// When linkFormat is 'link', we don't use alias in insertText
|
||||
// The conversion command handles the title mapping
|
||||
expect(insertText).toBe(workspace.getIdentifier(noteUri));
|
||||
expect(links.items[0].commitCharacters).toEqual([]);
|
||||
|
||||
// Verify command is attached for conversion
|
||||
expect(links.items[0].command).toBeDefined();
|
||||
expect(links.items[0].command.command).toBe(
|
||||
CONVERT_WIKILINK_TO_MDLINK.command
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle alias completion with markdown link format', async () => {
|
||||
const { uri, content } = await createFile(
|
||||
`
|
||||
---
|
||||
alias: test-alias
|
||||
---
|
||||
[[
|
||||
`,
|
||||
['note-with-alias.md']
|
||||
);
|
||||
ws.set(parser.parse(uri, content));
|
||||
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new WikilinkCompletionProvider(ws, graph);
|
||||
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'link',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(4, 2)
|
||||
);
|
||||
|
||||
const aliasCompletionItem = links.items.find(
|
||||
i => i.label === 'test-alias'
|
||||
);
|
||||
expect(aliasCompletionItem).not.toBeNull();
|
||||
expect(aliasCompletionItem.label).toBe('test-alias');
|
||||
|
||||
// Alias completions always use pipe syntax in insertText
|
||||
// The conversion command will convert it to markdown format
|
||||
expect(aliasCompletionItem.insertText).toBe(
|
||||
'note-with-alias|test-alias'
|
||||
);
|
||||
expect(aliasCompletionItem.commitCharacters).toEqual([]);
|
||||
|
||||
// Verify command is attached for conversion
|
||||
expect(aliasCompletionItem.command).toBeDefined();
|
||||
expect(aliasCompletionItem.command.command).toBe(
|
||||
CONVERT_WIKILINK_TO_MDLINK.command
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore linkFormat setting for placeholder completions', async () => {
|
||||
const { uri } = await createFile('[[');
|
||||
const { doc } = await showInEditor(uri);
|
||||
const provider = new WikilinkCompletionProvider(ws, graph);
|
||||
|
||||
// Test with wikilink format - should return plain text
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'wikilink',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 2)
|
||||
);
|
||||
|
||||
const placeholderItem = links.items.find(
|
||||
i => i.label === 'placeholder text'
|
||||
);
|
||||
expect(placeholderItem).not.toBeNull();
|
||||
expect(placeholderItem.insertText).toBe('placeholder text');
|
||||
}
|
||||
);
|
||||
|
||||
// Test with markdown link format - should also return plain text (ignore format conversion)
|
||||
await withModifiedFoamConfiguration(
|
||||
'completion.linkFormat',
|
||||
'link',
|
||||
async () => {
|
||||
const links = await provider.provideCompletionItems(
|
||||
doc,
|
||||
new vscode.Position(0, 2)
|
||||
);
|
||||
|
||||
const placeholderItem = links.items.find(
|
||||
i => i.label === 'placeholder text'
|
||||
);
|
||||
expect(placeholderItem).not.toBeNull();
|
||||
// Placeholders should remain as plain text, not converted to wikilink format
|
||||
expect(placeholderItem.insertText).toBe('placeholder text');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FoamWorkspace } from '../core/model/workspace';
|
||||
import { getFoamVsCodeConfig } from '../services/config';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
|
||||
import { getNoteTooltip, getFoamDocSelectors } from '../services/editor';
|
||||
import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';
|
||||
|
||||
export const aliasCommitCharacters = ['#'];
|
||||
export const linkCommitCharacters = ['#', '|'];
|
||||
@@ -169,15 +170,17 @@ export class WikilinkCompletionProvider
|
||||
}
|
||||
|
||||
const text = requiresAutocomplete[0];
|
||||
const labelStyle = getCompletionLabelSetting();
|
||||
const aliasSetting = getCompletionAliasSetting();
|
||||
const linkFormat = getCompletionLinkFormatSetting();
|
||||
|
||||
// Use safe range that VS Code accepts - replace content inside brackets only
|
||||
const replacementRange = new vscode.Range(
|
||||
position.line,
|
||||
position.character - (text.length - 2),
|
||||
position.line,
|
||||
position.character
|
||||
);
|
||||
const labelStyle = getCompletionLabelSetting();
|
||||
const aliasSetting = getCompletionAliasSetting();
|
||||
|
||||
const resources = this.ws.list().map(resource => {
|
||||
const resourceIsDocument =
|
||||
@@ -206,15 +209,22 @@ export class WikilinkCompletionProvider
|
||||
|
||||
const useAlias =
|
||||
resourceIsDocument &&
|
||||
linkFormat !== 'link' &&
|
||||
aliasSetting !== 'never' &&
|
||||
wikilinkRequiresAlias(resource);
|
||||
wikilinkRequiresAlias(resource, this.ws.defaultExtension);
|
||||
|
||||
item.insertText = useAlias
|
||||
? `${identifier}|${resource.title}`
|
||||
: identifier;
|
||||
item.commitCharacters = useAlias ? [] : linkCommitCharacters;
|
||||
// When using aliases or markdown link format, don't allow commit characters
|
||||
// since we either have the full text or will convert it
|
||||
item.commitCharacters =
|
||||
useAlias || linkFormat === 'link' ? [] : linkCommitCharacters;
|
||||
item.range = replacementRange;
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
item.command =
|
||||
linkFormat === 'link'
|
||||
? CONVERT_WIKILINK_TO_MDLINK
|
||||
: COMPLETION_CURSOR_MOVE;
|
||||
return item;
|
||||
});
|
||||
const aliases = this.ws.list().flatMap(resource =>
|
||||
@@ -224,13 +234,27 @@ export class WikilinkCompletionProvider
|
||||
vscode.CompletionItemKind.Reference,
|
||||
resource.uri
|
||||
);
|
||||
item.insertText = this.ws.getIdentifier(resource.uri) + '|' + a.title;
|
||||
|
||||
const identifier = this.ws.getIdentifier(resource.uri);
|
||||
|
||||
item.insertText = `${identifier}|${a.title}`;
|
||||
// When using markdown link format, don't allow commit characters
|
||||
item.commitCharacters =
|
||||
linkFormat === 'link' ? [] : aliasCommitCharacters;
|
||||
item.range = replacementRange;
|
||||
|
||||
// If link format is enabled, convert after completion
|
||||
item.command =
|
||||
linkFormat === 'link'
|
||||
? {
|
||||
command: CONVERT_WIKILINK_TO_MDLINK.command,
|
||||
title: CONVERT_WIKILINK_TO_MDLINK.title,
|
||||
}
|
||||
: COMPLETION_CURSOR_MOVE;
|
||||
|
||||
item.detail = `Alias of ${vscode.workspace.asRelativePath(
|
||||
toVsCodeUri(resource.uri)
|
||||
)}`;
|
||||
item.range = replacementRange;
|
||||
item.command = COMPLETION_CURSOR_MOVE;
|
||||
item.commitCharacters = aliasCommitCharacters;
|
||||
return item;
|
||||
})
|
||||
);
|
||||
@@ -293,7 +317,19 @@ function getCompletionAliasSetting() {
|
||||
return aliasStyle;
|
||||
}
|
||||
|
||||
const normalize = (text: string) => text.toLocaleLowerCase().trim();
|
||||
function wikilinkRequiresAlias(resource: Resource) {
|
||||
return normalize(resource.uri.getName()) !== normalize(resource.title);
|
||||
function getCompletionLinkFormatSetting() {
|
||||
const linkFormat: 'wikilink' | 'link' = getFoamVsCodeConfig(
|
||||
'completion.linkFormat'
|
||||
);
|
||||
return linkFormat;
|
||||
}
|
||||
|
||||
const normalize = (text: string) => text.toLocaleLowerCase().trim();
|
||||
function wikilinkRequiresAlias(resource: Resource, defaultExtension: string) {
|
||||
// Compare filename (without extension) to title
|
||||
const nameWithoutExt = resource.uri.getName();
|
||||
const titleWithoutExt = resource.title.endsWith(defaultExtension)
|
||||
? resource.title.slice(0, -defaultExtension.length)
|
||||
: resource.title;
|
||||
return normalize(nameWithoutExt) !== normalize(titleWithoutExt);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,43 @@ import * as glob from 'glob';
|
||||
|
||||
const rootDir = path.join(__dirname, '..', '..');
|
||||
|
||||
function parseJestArgs(args: string[]): any {
|
||||
const config: any = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--testNamePattern' && i + 1 < args.length) {
|
||||
config.testNamePattern = args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--testPathPattern' && i + 1 < args.length) {
|
||||
config.testPathPattern = args[i + 1].split('/').at(-1) || args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--json') {
|
||||
config.json = true;
|
||||
} else if (arg === '--useStderr') {
|
||||
config.useStderr = true;
|
||||
} else if (arg === '--outputFile' && i + 1 < args.length) {
|
||||
config.outputFile = args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--no-coverage') {
|
||||
config.collectCoverage = false;
|
||||
} else if (arg === '--watchAll=false') {
|
||||
config.watchAll = false;
|
||||
} else if (arg === '--colors') {
|
||||
config.colors = true;
|
||||
} else if (arg === '--reporters' && i + 1 < args.length) {
|
||||
if (!config.reporters) {
|
||||
config.reporters = [];
|
||||
}
|
||||
config.reporters.push(args[i + 1]);
|
||||
i++; // Skip next arg as it's the value
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getUnitReadySpecFiles(rootDir: string): string[] {
|
||||
const specFiles = glob.sync('**/*.spec.ts', {
|
||||
cwd: path.join(rootDir, 'src'),
|
||||
@@ -59,35 +96,37 @@ export function runUnit(
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { results } = await runCLI(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
runInBand: true,
|
||||
testRegex: excludeSpecs
|
||||
? ['\\.(test)\\.ts$']
|
||||
: (() => {
|
||||
const unitReadySpecs = getUnitReadySpecFiles(rootDir);
|
||||
Object.assign(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
runInBand: true,
|
||||
testRegex: excludeSpecs
|
||||
? ['\\.(test)\\.ts$']
|
||||
: (() => {
|
||||
const unitReadySpecs = getUnitReadySpecFiles(rootDir);
|
||||
|
||||
// Create pattern that includes .test files + specific .spec files
|
||||
return [
|
||||
'\\.(test)\\.ts$', // All .test files
|
||||
...unitReadySpecs.map(
|
||||
file =>
|
||||
file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$'
|
||||
),
|
||||
];
|
||||
})(),
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/test/support/jest-setup-after-env.ts',
|
||||
],
|
||||
testTimeout: 20000,
|
||||
verbose: false,
|
||||
silent: false,
|
||||
colors: true,
|
||||
// Pass through any additional args
|
||||
_: extraArgs,
|
||||
} as any,
|
||||
// Create pattern that includes .test files + specific .spec files
|
||||
return [
|
||||
'\\.(test)\\.ts$', // All .test files
|
||||
...unitReadySpecs.map(
|
||||
file =>
|
||||
file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$'
|
||||
),
|
||||
];
|
||||
})(),
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/test/support/jest-setup-after-env.ts',
|
||||
],
|
||||
testTimeout: 20000,
|
||||
verbose: false,
|
||||
silent: false,
|
||||
colors: true,
|
||||
},
|
||||
// Parse additional Jest arguments into config object
|
||||
parseJestArgs(extraArgs)
|
||||
) as any,
|
||||
[rootDir]
|
||||
);
|
||||
|
||||
|
||||
41
packages/foam-vscode/src/test/vscode-mock-extensions.test.ts
Normal file
41
packages/foam-vscode/src/test/vscode-mock-extensions.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as vscode from './vscode-mock';
|
||||
|
||||
describe('vscode-mock extensions API', () => {
|
||||
it('should provide extensions.getExtension', () => {
|
||||
expect(vscode.extensions).toBeDefined();
|
||||
expect(vscode.extensions.getExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return foam extension', () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext).toBeDefined();
|
||||
expect(ext?.id).toBe('foam.foam-vscode');
|
||||
expect(ext?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown extensions', () => {
|
||||
const ext = vscode.extensions.getExtension('unknown.extension');
|
||||
expect(ext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should provide foam instance through extension exports', async () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext?.exports).toBeDefined();
|
||||
expect(ext?.exports.foam).toBeDefined();
|
||||
|
||||
// foam is a getter that returns a Promise
|
||||
const foam = await ext?.exports.foam;
|
||||
expect(foam).toBeDefined();
|
||||
expect(foam.workspace).toBeDefined();
|
||||
expect(foam.graph).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support activate() method', async () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext?.activate).toBeDefined();
|
||||
|
||||
const exports = await ext?.activate();
|
||||
expect(exports).toBeDefined();
|
||||
expect(exports.foam).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Position } from '../core/model/position';
|
||||
import { Position as FoamPosition } from '../core/model/position';
|
||||
import { Range as FoamRange } from '../core/model/range';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { Logger } from '../core/utils/log';
|
||||
@@ -35,7 +35,117 @@ interface Thenable<T> {
|
||||
|
||||
// ===== Basic VS Code Types =====
|
||||
|
||||
export { Position };
|
||||
export class Position implements FoamPosition {
|
||||
public readonly line: number;
|
||||
public readonly character: number;
|
||||
constructor(line: number, character: number) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
static create(line: number, character: number): Position {
|
||||
return new Position(line, character);
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
compareTo(other: Position): number {
|
||||
if (this.line < other.line) return -1;
|
||||
if (this.line > other.line) return 1;
|
||||
if (this.character < other.character) return -1;
|
||||
if (this.character > other.character) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
isAfter(other: Position): boolean {
|
||||
return this.compareTo(other) > 0;
|
||||
}
|
||||
|
||||
isAfterOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) >= 0;
|
||||
}
|
||||
|
||||
isBefore(other: Position): boolean {
|
||||
return this.compareTo(other) < 0;
|
||||
}
|
||||
|
||||
isBeforeOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) <= 0;
|
||||
}
|
||||
|
||||
isEqual(other: Position): boolean {
|
||||
return this.compareTo(other) === 0;
|
||||
}
|
||||
|
||||
translate(lineDelta?: number, characterDelta?: number): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
translate(change: { lineDelta?: number; characterDelta?: number }): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
translate(
|
||||
lineDeltaOrChange?:
|
||||
| number
|
||||
| { lineDelta?: number; characterDelta?: number },
|
||||
characterDelta?: number
|
||||
): Position {
|
||||
let lineDelta: number;
|
||||
let charDelta: number;
|
||||
|
||||
if (typeof lineDeltaOrChange === 'object') {
|
||||
lineDelta = lineDeltaOrChange.lineDelta ?? 0;
|
||||
charDelta = lineDeltaOrChange.characterDelta ?? 0;
|
||||
} else {
|
||||
lineDelta = lineDeltaOrChange ?? 0;
|
||||
charDelta = characterDelta ?? 0;
|
||||
}
|
||||
|
||||
return new Position(this.line + lineDelta, this.character + charDelta);
|
||||
}
|
||||
|
||||
with(line?: number, character?: number): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
with(change: { line?: number; character?: number }): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
with(
|
||||
lineOrChange?: number | { line?: number; character?: number },
|
||||
character?: number
|
||||
): Position {
|
||||
let line: number;
|
||||
let char: number;
|
||||
|
||||
if (typeof lineOrChange === 'object') {
|
||||
line = lineOrChange.line ?? this.line;
|
||||
char = lineOrChange.character ?? this.character;
|
||||
} else {
|
||||
line = lineOrChange ?? this.line;
|
||||
char = character ?? this.character;
|
||||
}
|
||||
|
||||
return new Position(line, char);
|
||||
}
|
||||
|
||||
// Static helper methods
|
||||
static isAfter(a: Position, b: Position): boolean {
|
||||
return a.isAfter(b);
|
||||
}
|
||||
|
||||
static isAfterOrEqual(a: Position, b: Position): boolean {
|
||||
return a.isAfterOrEqual(b);
|
||||
}
|
||||
|
||||
static isBefore(a: Position, b: Position): boolean {
|
||||
return a.isBefore(b);
|
||||
}
|
||||
|
||||
static isBeforeOrEqual(a: Position, b: Position): boolean {
|
||||
return a.isBeforeOrEqual(b);
|
||||
}
|
||||
|
||||
static isEqual(a: Position, b: Position): boolean {
|
||||
return a.isEqual(b);
|
||||
}
|
||||
|
||||
static compareTo(a: Position, b: Position): number {
|
||||
return a.compareTo(b);
|
||||
}
|
||||
}
|
||||
|
||||
// VS Code Range class
|
||||
export class Range implements FoamRange {
|
||||
@@ -56,8 +166,8 @@ export class Range implements FoamRange {
|
||||
endCharacter?: number
|
||||
) {
|
||||
if (typeof startOrLine === 'number') {
|
||||
this.start = { line: startOrLine, character: endOrCharacter as number };
|
||||
this.end = { line: endLine!, character: endCharacter! };
|
||||
this.start = new Position(startOrLine, endOrCharacter as number);
|
||||
this.end = new Position(endLine!, endCharacter!);
|
||||
} else {
|
||||
this.start = startOrLine;
|
||||
this.end = endOrCharacter as Position;
|
||||
@@ -237,8 +347,8 @@ export class Selection extends Range {
|
||||
let active: Position;
|
||||
|
||||
if (typeof anchorOrLine === 'number') {
|
||||
anchor = { line: anchorOrLine, character: activeOrCharacter as number };
|
||||
active = { line: activeLine!, character: activeCharacter! };
|
||||
anchor = new Position(anchorOrLine, activeOrCharacter as number);
|
||||
active = new Position(activeLine!, activeCharacter!);
|
||||
} else {
|
||||
anchor = anchorOrLine;
|
||||
active = activeOrCharacter as Position;
|
||||
@@ -819,6 +929,7 @@ class MockTextDocument implements TextDocument {
|
||||
fs.writeFileSync(this.uri.fsPath, content);
|
||||
} catch (error) {
|
||||
Logger.error('vscode-mock: Failed to write file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,6 +965,32 @@ class MockTextEditor implements TextEditor {
|
||||
|
||||
async edit(callback: (editBuilder: any) => void): Promise<boolean> {
|
||||
// Simplified edit implementation
|
||||
const edits: { range: Range; newText: string }[] = [];
|
||||
const editBuilder = {
|
||||
replace: (range: Range, newText: string) => {
|
||||
edits.push({ range, newText });
|
||||
},
|
||||
};
|
||||
callback(editBuilder);
|
||||
|
||||
// Apply edits in reverse order to avoid offset issues
|
||||
const document = this.document as MockTextDocument;
|
||||
let content = document.getText();
|
||||
edits
|
||||
.sort(
|
||||
(a, b) =>
|
||||
document.offsetAt(b.range.start) - document.offsetAt(a.range.start)
|
||||
)
|
||||
.forEach(edit => {
|
||||
const startOffset = document.offsetAt(edit.range.start);
|
||||
const endOffset = document.offsetAt(edit.range.end);
|
||||
content =
|
||||
content.substring(0, startOffset) +
|
||||
edit.newText +
|
||||
content.substring(endOffset);
|
||||
});
|
||||
|
||||
document._updateContent(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1114,6 +1251,33 @@ function createMockExtensionContext(): ExtensionContext {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Extension API =====
|
||||
|
||||
export interface Extension<T> {
|
||||
id: string;
|
||||
extensionPath: string;
|
||||
isActive: boolean;
|
||||
packageJSON: any;
|
||||
exports: T;
|
||||
activate(): Thenable<T>;
|
||||
}
|
||||
|
||||
class MockExtension<T> implements Extension<T> {
|
||||
constructor(
|
||||
public id: string,
|
||||
public exports: T,
|
||||
public isActive: boolean = true
|
||||
) {}
|
||||
|
||||
extensionPath = '/mock/extension/path';
|
||||
packageJSON = {};
|
||||
|
||||
activate(): Thenable<T> {
|
||||
this.isActive = true;
|
||||
return Promise.resolve(this.exports);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Foam Commands Lazy Initialization =====
|
||||
|
||||
class TestFoam {
|
||||
@@ -1233,8 +1397,8 @@ async function initializeFoamCommands(foam: Foam): Promise<void> {
|
||||
await foamCommands.openResource(mockContext, foamPromise);
|
||||
await foamCommands.updateGraphCommand(mockContext, foamPromise);
|
||||
await foamCommands.updateWikilinksCommand(mockContext, foamPromise);
|
||||
await foamCommands.generateStandaloneNote(mockContext, foamPromise);
|
||||
await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise);
|
||||
await foamCommands.convertLinksCommand(mockContext, foamPromise);
|
||||
|
||||
// Commands that only need context
|
||||
await foamCommands.copyWithoutBracketsCommand(mockContext);
|
||||
@@ -1345,8 +1509,10 @@ export const window = {
|
||||
message: string,
|
||||
...items: string[]
|
||||
): Promise<string | undefined> {
|
||||
// Mock implementation - do nothing
|
||||
return undefined;
|
||||
throw new Error(
|
||||
'showErrorMessage called - should be mocked in tests if error handling is expected. Message was: ' +
|
||||
message
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1599,6 +1765,37 @@ export const languages = {
|
||||
},
|
||||
};
|
||||
|
||||
// Extensions namespace
|
||||
export const extensions = {
|
||||
getExtension<T = any>(extensionId: string): Extension<T> | undefined {
|
||||
if (extensionId === 'foam.foam-vscode') {
|
||||
return new MockExtension<any>(
|
||||
extensionId,
|
||||
{
|
||||
get foam() {
|
||||
return TestFoam.getInstance();
|
||||
},
|
||||
},
|
||||
true
|
||||
) as Extension<T>;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
get all(): Extension<any>[] {
|
||||
const foamExtension = new MockExtension<any>(
|
||||
'foam.foam-vscode',
|
||||
{
|
||||
get foam() {
|
||||
return TestFoam.getInstance();
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
return [foamExtension];
|
||||
},
|
||||
};
|
||||
|
||||
// Env namespace
|
||||
export const env = {
|
||||
__mockClipboard: '',
|
||||
|
||||
Reference in New Issue
Block a user