Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- `[Open](folder://~/Documents)` opens folder
- `[Reveal](reveal://./README.md)` reveals item
- `[Edit](edit://./notes/todo.md)` opens/creates in VS Code
- `[View](view://./README.md)` opens in preview mode (text) or system viewer (binary)
- Cross-platform: Sanity check macOS/Windows/Linux behavior where possible.

## Continuous Integration
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to the "markdown-folder-links" extension will be documented in this file.

## [Unreleased]

### Added
- Added support for `view://` protocol for read-only file viewing
- Opens text files in VSCode with preview mode
- Opens binary files (PDFs, images, videos, etc.) with system default viewer
- Shows error if file doesn't exist (doesn't create files like `edit://`)
- Supports both standard markdown syntax `[text](view://path)` and wiki-style `[[view:path]]`

## [1.0.0] - 2024-01-XX

### Initial Release
Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ A VSCode extension that makes local folder and file paths clickable in Markdown
- `folder://` - Opens the folder/file in your system's file manager
- `reveal://` - Reveals the folder/file in the file manager (shows in parent directory)
- `edit://` - Opens the path in VSCode, creating files if needed
- 📝 **Wiki-style links** - Use `[[folder:path]]`, `[[reveal:path]]`, or `[[edit:path]]`
- `view://` - Opens files in read-only mode (VSCode for text, system viewer for PDFs/images)
- 📝 **Wiki-style links** - Use `[[folder:path]]`, `[[reveal:path]]`, `[[edit:path]]`, or `[[view:path]]`
- 🏠 **Path expansion** - Supports `~` for home directory and relative paths
- 👁️ **Hover preview** - See if paths exist and get file/folder information
- ⚠️ **Smart handling** - Prompts to create missing folders and creates files for `edit://` links
Expand All @@ -26,7 +27,8 @@ Standard markdown link format:
```markdown
[Open Documents](folder://~/Documents)
[Reveal in Finder](reveal://~/Downloads/file.pdf)
[Open in VSCode](edit://~/Projects/my-project)
[Edit in VSCode](edit://~/Projects/my-project)
[View PDF](view://~/Documents/report.pdf)
```

Wiki-style double brackets:
Expand All @@ -35,6 +37,7 @@ Wiki-style double brackets:
[[folder:~/Documents]]
[[reveal:~/Downloads/file.pdf]]
[[edit:~/Projects/my-app]]
[[view:~/Documents/report.pdf]]
```

### Path Formats
Expand All @@ -44,6 +47,20 @@ Wiki-style double brackets:
- **Relative paths**: `./subfolder` or `../parent-folder`
- **URL encoded**: Spaces and special characters are automatically handled

### Protocol Comparison

| Protocol | Creates Files | Opens In | Use Case |
|----------|--------------|----------|----------|
| `edit://` | ✅ Yes | VSCode (editable) | Editing files, creating new files |
| `view://` | ❌ No | VSCode (preview) or system viewer | Read-only viewing, PDFs, images |
| `folder://` | N/A | File manager | Browsing folders |
| `reveal://` | N/A | File manager (highlighted) | Locating specific files |

**When to use `view://` vs `edit://`:**
- Use `view://` for read-only content like PDFs, images, or when you want to preview without editing
- Use `edit://` when you want to modify files or create them if they don't exist
- Binary files (PDF, PNG, etc.) opened with `view://` use your system's default viewer

## Examples

```markdown
Expand All @@ -55,12 +72,15 @@ Wiki-style double brackets:
- [Documentation](reveal://./docs)
- [Open Workspace in VSCode](edit://~/workspace)
- [Edit config file](edit://~/Projects/config.json)
- [View presentation](view://~/Documents/slides.pdf)
- [View diagram](view://./assets/architecture.png)

## Quick Links

- [[folder:~/Downloads]]
- [[reveal:~/Desktop/important.pdf]]
- [[edit:~/Projects/my-app]]
- [[view:~/Documents/report.pdf]]
```

## Configuration
Expand Down
79 changes: 74 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const fsAccess = promisify(fs.access);

interface LinkPattern {
pattern: RegExp;
action: "open" | "reveal" | "vscode";
action: "open" | "reveal" | "vscode" | "view";
}

export function activate(context: vscode.ExtensionContext) {
Expand Down Expand Up @@ -51,10 +51,12 @@ class FolderLinkProvider implements vscode.DocumentLinkProvider {
{ pattern: /\[([^\]]+)\]\(folder:\/\/([^)]+)\)/g, action: "open" },
{ pattern: /\[([^\]]+)\]\(reveal:\/\/([^)]+)\)/g, action: "reveal" },
{ pattern: /\[([^\]]+)\]\(edit:\/\/([^)]+)\)/g, action: "vscode" },
{ pattern: /\[([^\]]+)\]\(view:\/\/([^)]+)\)/g, action: "view" },
// Wiki-style syntax
{ pattern: /\[\[folder:([^\]]+)\]\]/g, action: "open" },
{ pattern: /\[\[reveal:([^\]]+)\]\]/g, action: "reveal" },
{ pattern: /\[\[edit:([^\]]+)\]\]/g, action: "vscode" },
{ pattern: /\[\[view:([^\]]+)\]\]/g, action: "view" },
];

provideDocumentLinks(
Expand Down Expand Up @@ -88,6 +90,8 @@ class FolderLinkProvider implements vscode.DocumentLinkProvider {
? "Edit in VSCode"
: linkPattern.action === "reveal"
? "Reveal"
: linkPattern.action === "view"
? "View"
: "Open";
link.tooltip = `${actionLabel}: ${expandPath(linkPath)}`;
links.push(link);
Expand All @@ -111,15 +115,15 @@ class FolderLinkHoverProvider implements vscode.HoverProvider {

const range = document.getWordRangeAtPosition(
position,
/\[([^\]]+)\]\((?:folder|reveal|edit):\/\/([^)]+)\)|\[\[(?:folder|reveal|edit):([^\]]+)\]\]/
/\[([^\]]+)\]\((?:folder|reveal|edit|view):\/\/([^)]+)\)|\[\[(?:folder|reveal|edit|view):([^\]]+)\]\]/
);
if (!range) {
return undefined;
}

const text = document.getText(range);
const pathMatch = text.match(
/(?:folder|reveal|edit):\/\/([^)]+)|(?:folder|reveal|edit):([^\]]+)/
/(?:folder|reveal|edit|view):\/\/([^)]+)|(?:folder|reveal|edit|view):([^\]]+)/
);
if (!pathMatch) {
return undefined;
Expand Down Expand Up @@ -153,8 +157,8 @@ async function openPath(linkPath: string, action: string) {
try {
const expandedPath = expandPath(linkPath);

// Check if path exists (skip for vscode action as it has its own error handling)
if (action !== "vscode") {
// Check if path exists (skip for vscode and view actions as they have their own error handling)
if (action !== "vscode" && action !== "view") {
try {
await fsAccess(expandedPath);
} catch {
Expand Down Expand Up @@ -216,6 +220,71 @@ async function openPath(linkPath: string, action: string) {
}
break;

case "view":
// View file (read-only, no creation)
try {
const stats = await fs.promises.stat(expandedPath);

if (stats.isDirectory()) {
vscode.window.showWarningMessage(
`Cannot view directory: ${path.basename(expandedPath)}`
);
return;
}

// Check if it's a binary file by extension
const ext = path.extname(expandedPath).toLowerCase();
const binaryExtensions = [
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg', '.ico',
'.mp4', '.mp3', '.avi', '.mov', '.wav', '.flac',
'.zip', '.tar', '.gz', '.rar', '.7z',
'.exe', '.dll', '.so', '.dylib',
'.bin', '.dat', '.iso'
];

if (binaryExtensions.includes(ext)) {
// Binary file: open with system default viewer
if (platform === "darwin") {
command = `open "${expandedPath}"`;
} else if (platform === "win32") {
command = `start "" "${expandedPath}"`;
} else {
command = `xdg-open "${expandedPath}"`;
}
await execAsync(command);
vscode.window.showInformationMessage(
`Opened with system viewer: ${path.basename(expandedPath)}`
);
} else {
// Text file: open in VSCode with preview and set read-only
const doc = await vscode.workspace.openTextDocument(expandedPath);
await vscode.window.showTextDocument(doc, { preview: true });
// Set the active editor to read-only (VS Code 1.79+)
try {
await vscode.commands.executeCommand(
'workbench.action.files.setActiveEditorReadonlyInSession'
);
} catch {
// Command not available in older VS Code versions; preview-only fallback
}
vscode.window.showInformationMessage(
`Viewing: ${path.basename(expandedPath)}`
);
}
} catch (error) {
// File doesn't exist or other error
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
vscode.window.showErrorMessage(
`File does not exist: ${expandedPath}`
);
} else {
vscode.window.showErrorMessage(
`Unable to view file: ${expandedPath}`
);
}
}
break;

case "reveal":
// Reveal in file manager
if (platform === "darwin") {
Expand Down
39 changes: 36 additions & 3 deletions src/test/suite/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ suite('Extension Basics', () => {
assert.ok(ext?.isActive, 'Extension did not activate');
});

test('provides document links for folder/reveal/edit', async () => {
test('provides document links for folder/reveal/edit/view', async () => {
const content = [
'[Open](folder://./tmp-dir)',
'[Reveal](reveal://./README.md)',
'[Edit](edit://./notes/todo.md)'
'[Edit](edit://./notes/todo.md)',
'[View](view://./README.md)'
].join(' ');

const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content });
Expand All @@ -27,14 +28,31 @@ suite('Extension Basics', () => {
doc.uri
)) as vscode.DocumentLink[];

assert.ok(links && links.length >= 3, `Expected >=3 links, got ${links?.length ?? 0}`);
assert.ok(links && links.length >= 4, `Expected >=4 links, got ${links?.length ?? 0}`);
const targets = links.map((l) => l.target?.toString() ?? '');
assert.ok(
targets.every((t) => t.startsWith('command:markdownFolderLinks.openLink?')),
'All links should invoke markdownFolderLinks.openLink command'
);
});

test('provides document links for wiki-style view syntax', async () => {
const content = 'Check this: [[view:./README.md]] for details.';

const doc = await vscode.workspace.openTextDocument({ language: 'markdown', content });
const links = (await vscode.commands.executeCommand(
'vscode.executeLinkProvider',
doc.uri
)) as vscode.DocumentLink[];

assert.ok(links && links.length >= 1, `Expected >=1 links, got ${links?.length ?? 0}`);
const target = links[0].target?.toString() ?? '';
assert.ok(
target.startsWith('command:markdownFolderLinks.openLink?'),
'Wiki-style view link should invoke markdownFolderLinks.openLink command'
);
});

test('hover provider returns info for wiki-style link', async () => {
const doc = await vscode.workspace.openTextDocument({
language: 'markdown',
Expand All @@ -49,5 +67,20 @@ suite('Extension Basics', () => {

assert.ok(hovers && hovers.length > 0, 'Expected a hover result');
});

test('hover provider returns info for view:// link', async () => {
const doc = await vscode.workspace.openTextDocument({
language: 'markdown',
content: 'See [View](view://./non-existent-file.txt) for details.'
});
const position = new vscode.Position(0, 10); // inside the link
const hovers = (await vscode.commands.executeCommand(
'vscode.executeHoverProvider',
doc.uri,
position
)) as vscode.Hover[];

assert.ok(hovers && hovers.length > 0, 'Expected a hover result for view:// link');
});
});