feat: add thumbnail support and refactor vscode extension
- Add optional APP_THUMBNAIL prompt to Start Tunnel command - Extract each prompt step into its own function - Use options struct for buildCommand - Rewrite README with usage, settings, and development guide Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
oscar.rs committed
Mar 8, 2026 at 00:04 UTC
d63fa622828acf03711b3dd1462aa2e213d98fd3
2 files changed
+127
-109
extensions/vscode/portal/README.md
+38
-51
@@ -1,71 +1,58 @@
1
-# portal README
1
+# Portal — VSCode Extension
2
3
-This is the README for your extension "portal". After writing up a brief description, we recommend including the following sections.
3
+Expose your local service to the internet via a [Portal](https://github.com/gosuda/portal) relay tunnel, directly from VSCode — no terminal copy-paste needed.
4
5
## Features
6
7
-Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file.
8
-
9
-For example if there is an image subfolder under your extension project workspace:
10
-
11
-\!\[feature X\]\(images/feature-x.png\)
12
-
13
-> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow.
7
+- **Portal: Start Tunnel** — prompts for host, service name, relay URL, and optional thumbnail, then runs the tunnel command in the integrated terminal
8
+- **Portal: Stop Tunnel** — stops the active tunnel terminal
9
+- Persisted settings for relay URLs, default host, and default service name
10
+- Auto-detects OS (macOS/Linux uses `curl`, Windows uses PowerShell)
11
12
## Requirements
13
17
-If you have any requirements or dependencies, add a section describing those and how to install and configure them.
18
-
19
-## Extension Settings
20
-
21
-Include if your extension adds any VS Code settings through the `contributes.configuration` extension point.
14
+- A running [Portal relay server](https://github.com/gosuda/portal) (self-hosted or public)
15
+- `curl` on macOS/Linux, PowerShell on Windows
16
23
-For example:
17
+## Settings
18
25
-This extension contributes the following settings:
19
+| Setting | Default | Description |
20
+|---|---|---|
21
+| `portal.relayUrls` | `[]` | Relay server URLs. If empty, prompted on each start. |
22
+| `portal.defaultHost` | `localhost:3000` | Default local host:port to expose. |
23
+| `portal.defaultName` | `""` | Default tunnel service name. Falls back to workspace folder name. |
24
27
-* `myExtension.enable`: Enable/disable this extension.
28
-* `myExtension.thing`: Set to `blah` to do something.
29
-
30
-## Known Issues
31
-
32
-Calling out known issues can help limit users opening duplicate issues against your extension.
33
-
34
-## Release Notes
25
+Example `settings.json`:
26
36
-Users appreciate release notes as you update your extension.
27
+```json
28
+{
29
+ "portal.relayUrls": ["https://my-relay.example.com"],
30
+ "portal.defaultHost": "localhost:3000",
31
+ "portal.defaultName": "my-app"
32
+}
33
+```
34
38
-### 1.0.0
35
+## Usage
36
40
-Initial release of ...
37
+1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`)
38
+2. Run **Portal: Start Tunnel**
39
+3. Fill in the prompts (host, name, relay URL, thumbnail URL — all pre-filled from settings)
40
+4. Tunnel starts in the integrated terminal and prints the public URL
41
42
-### 1.0.1
42
+To stop: run **Portal: Stop Tunnel** or close the `Portal Tunnel` terminal.
43
44
-Fixed issue #.
44
+## Development
45
46
-### 1.1.0
46
+```bash
47
+git clone https://github.com/gosuda/portal
48
+cd portal/extensions/vscode/portal
49
+pnpm install
50
+```
51
48
-Added features X, Y, and Z.
52
+Open the folder in VSCode, then press `F5` to launch the Extension Development Host.
53
50
----
51
-
52
-## Following extension guidelines
53
-
54
-Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension.
55
-
56
-* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines)
57
-
58
-## Working with Markdown
59
-
60
-You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts:
61
-
62
-* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux).
63
-* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux).
64
-* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets.
65
-
66
-## For more information
54
+## Release Notes
55
68
-* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown)
69
-* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/)
56
+### 0.0.1
57
71
-**Enjoy!**
58
+Initial release — Start/Stop Tunnel commands with thumbnail support.
extensions/vscode/portal/src/extension.ts
+89
-58
@@ -15,57 +15,33 @@ export function activate(context: vscode.ExtensionContext) {
15
);
16
}
17
18
-async function startTunnel() {
19
- const config = vscode.workspace.getConfiguration("portal");
18
+export function deactivate() {
19
+ tunnelTerminal?.dispose();
20
+}
21
21
- // --- host ---
22
- const defaultHost = config.get<string>("portal.defaultHost") ?? "localhost:3000";
23
- const host = await vscode.window.showInputBox({
24
- title: "Portal: Local Host",
25
- prompt: "hostname or IP:Port where your service is running",
26
- value: defaultHost,
27
- validateInput: (v) => (v.trim() ? undefined : "Required"),
28
- });
22
+async function startTunnel() {
23
+ const host = await promptHost();
24
if (!host) { return; }
25
31
- // --- name ---
32
- const workspaceName =
33
- vscode.workspace.workspaceFolders?.[0]?.name ?? "my-app";
34
- const defaultName =
35
- config.get<string>("portal.defaultName") || workspaceName;
36
- const name = await vscode.window.showInputBox({
37
- title: "Portal: Service Name",
38
- prompt: "Unique identifier for your tunnel",
39
- value: defaultName,
40
- validateInput: (v) => (v.trim() ? undefined : "Required"),
41
- });
26
+ const name = await promptName();
27
if (!name) { return; }
28
44
- // --- relay URLs ---
45
- let relayUrls = config.get<string[]>("portal.relayUrls") ?? [];
46
- if (relayUrls.length === 0) {
47
- const input = await vscode.window.showInputBox({
48
- title: "Portal: Relay URL",
49
- prompt: "Relay server URL (e.g. https://my-relay.example.com)",
50
- validateInput: (v) => {
51
- try {
52
- new URL(v.trim());
53
- return undefined;
54
- } catch {
55
- return "Enter a valid URL";
56
- }
57
- },
58
- });
59
- if (!input) { return; }
60
- relayUrls = [input.trim()];
61
- }
29
+ const relayUrls = await promptRelayUrls();
30
+ if (!relayUrls) { return; }
31
+
32
+ const thumbnail = await promptThumbnail();
33
+ if (thumbnail === undefined) { return; }
34
35
const relayUrl = relayUrls[0];
64
- const relayList = relayUrls.join(",");
65
- const isLocal = isLocalhost(relayUrl);
66
- const command = buildCommand(host.trim(), name.trim(), relayList, relayUrl, isLocal);
36
+ const command = buildCommand({
37
+ host,
38
+ name,
39
+ relayList: relayUrls.join(","),
40
+ relayUrl,
41
+ thumbnail,
42
+ isLocal: isLocalhost(relayUrl),
43
+ });
44
68
- // reuse existing terminal or create a new one
45
if (tunnelTerminal) {
46
tunnelTerminal.dispose();
47
}
@@ -84,24 +60,83 @@ function stopTunnel() {
60
}
61
}
62
87
-function buildCommand(
88
- host: string,
89
- name: string,
90
- relayList: string,
91
- relayUrl: string,
92
- isLocal: boolean
93
-): string {
63
+async function promptHost(): Promise<string | undefined> {
64
+ const config = vscode.workspace.getConfiguration("portal");
65
+ const defaultHost = config.get<string>("defaultHost") ?? "localhost:3000";
66
+ return vscode.window.showInputBox({
67
+ title: "Portal: Local Host",
68
+ prompt: "Hostname or IP:Port where your service is running",
69
+ value: defaultHost,
70
+ validateInput: (v) => (v.trim() ? undefined : "Required"),
71
+ });
72
+}
73
+
74
+async function promptName(): Promise<string | undefined> {
75
+ const config = vscode.workspace.getConfiguration("portal");
76
+ const workspaceName = vscode.workspace.workspaceFolders?.[0]?.name ?? "my-app";
77
+ const defaultName = config.get<string>("defaultName") || workspaceName;
78
+ return vscode.window.showInputBox({
79
+ title: "Portal: Service Name",
80
+ prompt: "Unique identifier for your tunnel",
81
+ value: defaultName,
82
+ validateInput: (v) => (v.trim() ? undefined : "Required"),
83
+ });
84
+}
85
+
86
+async function promptRelayUrls(): Promise<string[] | undefined> {
87
+ const config = vscode.workspace.getConfiguration("portal");
88
+ const saved = config.get<string[]>("relayUrls") ?? [];
89
+ if (saved.length > 0) { return saved; }
90
+
91
+ const input = await vscode.window.showInputBox({
92
+ title: "Portal: Relay URL",
93
+ prompt: "Relay server URL (e.g. https://my-relay.example.com)",
94
+ validateInput: (v) => {
95
+ try { new URL(v.trim()); return undefined; } catch { return "Enter a valid URL"; }
96
+ },
97
+ });
98
+ return input ? [input.trim()] : undefined;
99
+}
100
+
101
+async function promptThumbnail(): Promise<string | undefined> {
102
+ const result = await vscode.window.showInputBox({
103
+ title: "Portal: Thumbnail URL (optional)",
104
+ prompt: "Image URL to display as thumbnail. Leave empty to skip.",
105
+ placeHolder: "https://example.com/image.png",
106
+ validateInput: (v) => {
107
+ if (!v.trim()) { return undefined; }
108
+ try { new URL(v.trim()); return undefined; } catch { return "Enter a valid URL or leave empty"; }
109
+ },
110
+ });
111
+ // undefined = user pressed Escape, "" = user skipped
112
+ return result;
113
+}
114
+
115
+interface TunnelCommandOptions {
116
+ host: string;
117
+ name: string;
118
+ relayList: string;
119
+ relayUrl: string;
120
+ thumbnail: string;
121
+ isLocal: boolean;
122
+}
123
+
124
+function buildCommand(opts: TunnelCommandOptions): string {
125
+ const { host, name, relayList, relayUrl, thumbnail, isLocal } = opts;
126
const tunnelScript = `${relayUrl}/tunnel`;
127
+ const thumbEnv = thumbnail ? ` APP_THUMBNAIL=${thumbnail}` : "";
128
+
129
if (os.platform() === "win32") {
96
- const windowsScript = `${tunnelScript}?os=windows`;
130
+ const thumbEnvWin = thumbnail ? ` $env:APP_THUMBNAIL="${thumbnail}";` : "";
131
return (
132
`$ProgressPreference = 'SilentlyContinue'; ` +
99
- `$env:HOST="${host}"; $env:NAME="${name}"; $env:RELAY_URL="${relayList}"; ` +
100
- `irm ${windowsScript} | iex`
133
+ `$env:HOST="${host}"; $env:NAME="${name}"; $env:RELAY_URL="${relayList}";${thumbEnvWin} ` +
134
+ `irm ${tunnelScript}?os=windows | iex`
135
);
136
}
137
+
138
const curlFlags = isLocal ? "-kfsSL" : "-fsSL";
104
- return `curl ${curlFlags} ${tunnelScript} | APP_HOST=${host} APP_NAME=${name} RELAYS="${relayList}" sh`;
139
+ return `curl ${curlFlags} ${tunnelScript} | APP_HOST=${host} APP_NAME=${name}${thumbEnv} RELAYS="${relayList}" sh`;
140
}
141
142
function isLocalhost(url: string): boolean {
@@ -112,7 +147,3 @@ function isLocalhost(url: string): boolean {
147
return false;
148
}
149
}
115
-
116
-export function deactivate() {
117
- tunnelTerminal?.dispose();
118
-}