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:
Riccardo
2025-10-02 10:48:47 +02:00
committed by GitHub
parent 145653ec85
commit 40740db416
14 changed files with 1330 additions and 250 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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 */

View File

@@ -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);
}

View 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);
});
});
});

View 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');
});
});
});

View 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}`
);
}
}

View File

@@ -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();
});

View File

@@ -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';

View File

@@ -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');
}
);
});
});

View File

@@ -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);
}

View File

@@ -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]
);

View 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();
});
});

View File

@@ -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: '',