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