Skip to content

Commit 9e0c5a4

Browse files
authored
Merge pull request #107 from dockersamples/106-open-files-from-links
Add markdown directive to open a file in the IDE
2 parents 607b93d + 2390743 commit 9e0c5a4

File tree

8 files changed

+157
-27
lines changed

8 files changed

+157
-27
lines changed

components/interface/api/src/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ app.get("/api/labspace", (req, res) => {
1414
res.json(workshopStore.getWorkshopDetails());
1515
});
1616

17+
app.post("/api/open-file", (req, res) => {
18+
const { filePath, line } = req.body;
19+
workshopStore
20+
.openFileInIDE(filePath, line)
21+
.then(() => res.json({ success: true }))
22+
.catch((error) => {
23+
console.error("Error opening file:", error);
24+
res.status(500).json({ error: "Failed to open file" });
25+
});
26+
});
27+
1728
app.get("/api/sections/:sectionId", (req, res) => {
1829
const sectionId = req.params.sectionId;
1930
const content = workshopStore.getSectionDetails(sectionId);

components/interface/api/src/workshopStore.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ export class WorkshopStore {
119119
fs.writeFileSync(filePath, codeBlock.code, "utf8");
120120
}
121121

122+
async openFileInIDE(filePath, line) {
123+
return fetch("http://localhost/open", {
124+
method: "POST",
125+
body: JSON.stringify({ filePath, line }),
126+
headers: {
127+
"Content-Type": "application/json",
128+
},
129+
dispatcher: new Agent({
130+
connect: {
131+
socketPath: "/etc/cmd-executor/socket/cmd-executor.sock",
132+
},
133+
}),
134+
}).then((res) => {
135+
if (!res.ok)
136+
throw new Error(`Failed to execute command: ${res.statusText}`);
137+
});
138+
}
139+
122140
#getCodeBlock(content, index) {
123141
const codeBlocks = content.match(/```(.*?)```/gs);
124142
if (!codeBlocks || codeBlocks[index] === undefined) {

components/interface/client/src/WorkshopContext.jsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ export const WorkshopContextProvider = ({ children }) => {
110110
});
111111
}, []);
112112

113+
const openFile = useCallback((filePath, line) => {
114+
fetch(`/api/open-file`, {
115+
method: "POST",
116+
headers: {
117+
"Content-Type": "application/json",
118+
},
119+
body: JSON.stringify({ filePath, line }),
120+
}).catch((error) => {
121+
console.error("Error opening file:", error);
122+
toast.error("Failed to open file. Please try again.");
123+
});
124+
}, []);
125+
113126
useEffect(() => {
114127
if (!workshop) return;
115128
document.title = `${workshop.title} ${activeSection ? `- ${activeSection.title}` : ""}`;
@@ -156,6 +169,7 @@ export const WorkshopContextProvider = ({ children }) => {
156169
changeActiveSection,
157170
runCommand,
158171
saveFileCommand,
172+
openFile,
159173
}}
160174
>
161175
{children}
@@ -179,3 +193,7 @@ export const useSaveFileCommand = () => {
179193
const { saveFileCommand } = useContext(WorkshopContext);
180194
return saveFileCommand;
181195
};
196+
197+
export const useOpenFile = () => {
198+
return useContext(WorkshopContext).openFile;
199+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useOpenFile } from "../../../WorkshopContext";
2+
3+
export function FileLink({ path, line, children }) {
4+
const openFile = useOpenFile();
5+
6+
const lineAsNumber = (line) ? parseInt(line, 10) : undefined;
7+
8+
return (
9+
<a
10+
href={path}
11+
onClick={(e) => {
12+
e.preventDefault();
13+
openFile(path, lineAsNumber);
14+
}}
15+
>
16+
{children}
17+
</a>
18+
);
19+
}

components/interface/client/src/components/WorkshopPanel/markdown/MarkdownRenderer.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { RenderedImage } from "./RenderedImage";
1111
import { RenderedSvg } from "./RenderedSvg";
1212
import { tabDirective } from "./reactDirective";
1313
import { TabLink } from "./TabLink";
14+
import { FileLink } from "./FileLink";
1415

1516
export function MarkdownRenderer({ children }) {
1617
return (
@@ -28,6 +29,7 @@ export function MarkdownRenderer({ children }) {
2829
img: RenderedImage,
2930
svg: RenderedSvg,
3031
tablink: TabLink,
32+
filelink: FileLink,
3133
}}
3234
>
3335
{children}

components/support-vscode-extension/src/extension.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
const vscode = require('vscode');
22
const { ExpressSocketServer } = require("./socketServer.js");
33

4-
let server;
4+
let server, outputChannel;
55

66
/**
77
* @param {vscode.ExtensionContext} context
88
*/
99
function activate(context) {
10+
outputChannel = vscode.window.createOutputChannel('Labspace Support');
11+
1012
const start = () => {
1113
stop();
1214
server = new ExpressSocketServer(
1315
vscode.workspace.getConfiguration('labRunner'),
14-
context.asAbsolutePath('package.json')
16+
context.asAbsolutePath('package.json'),
17+
outputChannel,
1518
);
1619
server.start();
1720
};

components/support-vscode-extension/src/socketServer.js

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
const fs = require('fs');
2+
const path = require('path');
23
const express = require('express');
34
const jwt = require('jsonwebtoken');
45
const vscode = require('vscode');
56

67
class SocketServer {
7-
constructor(config, packagePath) {
8+
constructor(config, packagePath, outputChannel) {
89
this.pkgInfo = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
910

1011
this.cfg = {
@@ -18,34 +19,20 @@ class SocketServer {
1819

1920
this.app = express();
2021
this.app.use(express.json());
22+
this.outputChannel = outputChannel;
2123

2224
this.app.get('/healthz', (req, res) => res.json({ ok: true }));
2325
this.app.get('/version', (req, res) => res.json({ name: this.pkgInfo.name, version: this.pkgInfo.version }));
2426

25-
this.app.post('/command', async (req, res) => {
26-
try {
27-
const token = req.body?.token;
28-
if (!token) throw new Error('missing token');
29-
30-
const pubKey = fs.readFileSync(this.cfg.publicKeyPath, 'utf8');
31-
const payload = jwt.verify(token, pubKey, {
32-
algorithms: ['RS256', 'ES256'],
33-
audience: this.cfg.allowedAud
34-
});
35-
36-
const cmd = payload.cmd;
37-
if (!cmd || typeof cmd !== 'string') throw new Error('missing cmd claim');
38-
if (this.cfg.allowedCommandPattern && !new RegExp(this.cfg.allowedCommandPattern).test(cmd)) {
39-
throw new Error('command rejected by allowedCommandPattern');
40-
}
41-
42-
const term = await this.#ensureTerminal(payload.cwd, payload.terminalId);
43-
term.sendText(cmd, true);
44-
res.json({ ok: true, ran: cmd });
45-
} catch (err) {
46-
res.status(400).json({ ok: false, error: err?.message || String(err) });
47-
}
48-
});
27+
this.app.post('/command', this.#onCommandRequest.bind(this));
28+
29+
/**
30+
* Open a file in the editor, optionally at a specific line.
31+
* Expects JSON body with:
32+
* - filePath: string (relative to workspace root)
33+
* - line: integer (optional, 1-based line number)
34+
*/
35+
this.app.post("/open", this.#onFileOpenRequest.bind(this));
4936
}
5037

5138
start() {
@@ -66,6 +53,62 @@ class SocketServer {
6653
try { if (fs.existsSync(this.cfg.socketPath)) fs.unlinkSync(this.cfg.socketPath); } catch {}
6754
}
6855

56+
async #onCommandRequest (req, res) {
57+
try {
58+
const token = req.body?.token;
59+
if (!token) throw new Error('missing token');
60+
61+
const pubKey = fs.readFileSync(this.cfg.publicKeyPath, 'utf8');
62+
const payload = jwt.verify(token, pubKey, {
63+
algorithms: ['RS256', 'ES256'],
64+
audience: this.cfg.allowedAud
65+
});
66+
67+
const cmd = payload.cmd;
68+
if (!cmd || typeof cmd !== 'string') throw new Error('missing cmd claim');
69+
if (this.cfg.allowedCommandPattern && !new RegExp(this.cfg.allowedCommandPattern).test(cmd)) {
70+
throw new Error('command rejected by allowedCommandPattern');
71+
}
72+
73+
const term = await this.#ensureTerminal(payload.cwd, payload.terminalId);
74+
term.sendText(cmd, true);
75+
res.json({ ok: true, ran: cmd });
76+
} catch (err) {
77+
res.status(400).json({ ok: false, error: err?.message || String(err) });
78+
}
79+
}
80+
81+
async #onFileOpenRequest (req, res) {
82+
this.outputChannel.appendLine(`/open request: ${JSON.stringify(req.body)}`);
83+
84+
const { filePath, line } = req.body || {};
85+
if (!filePath) {
86+
return res.status(400).json({ ok: false, error: 'Missing required filePath' });
87+
}
88+
89+
try {
90+
const absolutePath = path.join(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '/', filePath);
91+
if (!fs.existsSync(absolutePath)) {
92+
return res.status(404).json({ ok: false, error: `Cannot open file ${absolutePath}, as it does not exist` });
93+
}
94+
95+
const fileUri = vscode.Uri.file(absolutePath);
96+
97+
const doc = await vscode.workspace.openTextDocument(fileUri);
98+
const editor = await vscode.window.showTextDocument(doc, { preview: false });
99+
if (line && Number.isInteger(line) && line > 0 && line <= doc.lineCount) {
100+
const lineIndex = line - 1;
101+
const range = new vscode.Range(lineIndex, 0, lineIndex, 0);
102+
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
103+
editor.selection = new vscode.Selection(range.start, range.start);
104+
}
105+
res.json({ success: true });
106+
} catch (err) {
107+
vscode.window.showErrorMessage(`Failed to open file: ${err?.message || String(err)}`);
108+
return res.status(500).json({ ok: false, error: 'failed to open file' });
109+
}
110+
}
111+
69112
async #ensureTerminal(cwd, requestedTerminalName = null) {
70113
if (requestedTerminalName) {
71114
const existing = vscode.window.terminals.find(t => t.name === requestedTerminalName);

docs/markdown-options.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,24 @@ If a user clicks this "Save file" button, a `compose.yaml` file will be created
8282

8383
By default, all links are configured to open new browser tabs when clicked.
8484

85+
### Opening links as a tab
86+
8587
If you want to add another tab to the right-hand panel, you can use the following directive:
8688

8789
::tabLink[Link text]{href="http://localhost:3000" title="Tab title"}
8890

8991
This will render a link with the visible text of "Link text" pointing to "http://localhost:3000". When clicked, a new tab will be created with the title of "Tab title".
92+
93+
94+
### Opening files in the IDE
95+
96+
If you want to create a link that will open a project file in the IDE, you can use the following directive:
97+
98+
Open the :fileLink[compose.yaml]{path="compose.yaml"} file ...
99+
100+
Directive arguments include:
101+
102+
- **path** - the full path _from the root of the project_ of the file to open
103+
- **line** (optional) - the line number (1-based) to put the cursor on
104+
105+
The body (text inside the `[]`) is what will be displayed to the user.

0 commit comments

Comments
 (0)