opencode-preview-plus 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ interface ExternalPreviewEntry {
2
+ absolutePath: string;
3
+ title: string;
4
+ }
5
+ interface ProjectInfo {
6
+ id: string;
7
+ worktree: string;
8
+ name?: string;
9
+ icon?: {
10
+ color?: string;
11
+ };
12
+ }
13
+ declare function isPreviewable(filePath: string): boolean;
14
+ declare function getCodeLanguage(filePath: string): string | null;
15
+ declare function ensureInsideRoot(rootDir: string, relativeFilePath: string): string;
16
+ declare function registerExternalPreviewFile(absolutePath: string, title?: string): string;
17
+ declare function buildExternalPreviewUrl(baseUrl: string, token: string): string;
18
+ declare function resolveExternalPreviewFile(token: string): ExternalPreviewEntry | null;
19
+ declare function clearExternalPreviewFilesForTest(): void;
20
+ declare function getCurrentBranch(baseDir: string): Promise<string>;
21
+ declare function renderCopyPathHtmlForTest(rootDir: string, projectId: string, projects: ProjectInfo[]): string;
22
+ declare function renderFileTreeHtmlForTest(files: string[], emptyDirectories?: string[]): string;
23
+ /**
24
+ * Start the preview server.
25
+ * @param port - Port to listen on (default: PREVIEW_PORT env or 17890)
26
+ * @param serverUrl - OpenCode serve URL for project discovery (e.g. "http://localhost:10013")
27
+ */
28
+ declare function startServer(port?: number, serverUrl?: string): Promise<number>;
29
+ declare function stopServer(): void;
30
+
31
+ export { buildExternalPreviewUrl, clearExternalPreviewFilesForTest, ensureInsideRoot, getCodeLanguage, getCurrentBranch, isPreviewable, registerExternalPreviewFile, renderCopyPathHtmlForTest, renderFileTreeHtmlForTest, resolveExternalPreviewFile, startServer, stopServer };
package/dist/server.js ADDED
@@ -0,0 +1,28 @@
1
+ import {
2
+ buildExternalPreviewUrl,
3
+ clearExternalPreviewFilesForTest,
4
+ ensureInsideRoot,
5
+ getCodeLanguage,
6
+ getCurrentBranch,
7
+ isPreviewable,
8
+ registerExternalPreviewFile,
9
+ renderCopyPathHtmlForTest,
10
+ renderFileTreeHtmlForTest,
11
+ resolveExternalPreviewFile,
12
+ startServer,
13
+ stopServer
14
+ } from "./chunk-ZHJFGWJ2.js";
15
+ export {
16
+ buildExternalPreviewUrl,
17
+ clearExternalPreviewFilesForTest,
18
+ ensureInsideRoot,
19
+ getCodeLanguage,
20
+ getCurrentBranch,
21
+ isPreviewable,
22
+ registerExternalPreviewFile,
23
+ renderCopyPathHtmlForTest,
24
+ renderFileTreeHtmlForTest,
25
+ resolveExternalPreviewFile,
26
+ startServer,
27
+ stopServer
28
+ };
@@ -0,0 +1,258 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Preview Browser</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="browser-shell">
11
+ <header class="browser-header">
12
+ <h1>Preview Browser</h1>
13
+ <p class="project-path">Project: <code>{{PROJECT_DIRECTORY}}</code></p>
14
+ </header>
15
+
16
+ <section id="worktree-panel" class="worktree-panel" style="display:none">
17
+ <div id="worktree-switcher" class="wt-switcher"></div>
18
+ </section>
19
+
20
+ <section class="file-list-panel">
21
+ <div class="panel-title-row">
22
+ <h2>Previewable Files</h2>
23
+ <button id="refresh" type="button">Refresh</button>
24
+ </div>
25
+ <ul id="file-list" class="file-list"></ul>
26
+ </section>
27
+ </main>
28
+
29
+ <script>
30
+ const fileList = document.getElementById("file-list");
31
+ const refreshButton = document.getElementById("refresh");
32
+ const worktreePanel = document.getElementById("worktree-panel");
33
+ const projectId = "{{PROJECT_ID}}";
34
+ let worktreeParams = "{{WORKTREE_PARAMS}}";
35
+
36
+ const escapeHtml = (value) => value
37
+ .replaceAll("&", "&amp;")
38
+ .replaceAll("<", "&lt;")
39
+ .replaceAll(">", "&gt;")
40
+ .replaceAll("\"", "&quot;")
41
+ .replaceAll("'", "&#39;");
42
+
43
+ const iconFor = (file) => {
44
+ let color = "#519aba";
45
+ let text = "";
46
+ const f = file.toLowerCase();
47
+ if (f.endsWith(".ts")) { color = "#3178c6"; text = "TS"; }
48
+ else if (f.endsWith(".tsx")) { color = "#3178c6"; text = "TX"; }
49
+ else if (f.endsWith(".js") || f.endsWith(".cjs") || f.endsWith(".mjs")) { color = "#f1e05a"; text = "JS"; }
50
+ else if (f.endsWith(".jsx")) { color = "#f1e05a"; text = "JX"; }
51
+ else if (f.endsWith(".html") || f.endsWith(".htm")) { color = "#e34f26"; text = "<>"; }
52
+ else if (f.endsWith(".css")) { color = "#1572b6"; text = "#"; }
53
+ else if (f.endsWith(".json")) { color = "#cbcb41"; text = "{}"; }
54
+ else if (f.endsWith(".md")) { color = "#42a5f5"; text = "M↓"; }
55
+ else if (f.endsWith(".py")) { color = "#3572A5"; text = "PY"; }
56
+ else if (f.endsWith(".go")) { color = "#00ADD8"; text = "GO"; }
57
+ else if (f.endsWith(".rs")) { color = "#dea584"; text = "RS"; }
58
+ else if (f.endsWith(".drawio")) { color = "#f08705"; text = "D"; }
59
+ else if (f.endsWith(".csv")) { color = "#217346"; text = "CSV"; }
60
+ else { text = f.split('.').pop().substring(0, 2).toUpperCase() || "F"; }
61
+
62
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
63
+ <path fill="${color}" d="M13.85 4.44l-3.28-3.3c-.19-.18-.43-.28-.71-.28H3.5c-.55 0-1 .45-1 1v12.28c0 .55.45 1 1 1h9c.55 0 1-.45 1-1V5.14c0-.26-.1-.51-.28-.7zM9.5 2.56L12.06 5H9.5V2.56zM12.5 14h-9V2.5h5V5.5h3.5v8.5z"/>
64
+ <text x="8" y="11" font-size="5" font-family="sans-serif" font-weight="bold" fill="${color}" text-anchor="middle">${text}</text>
65
+ </svg>`;
66
+ };
67
+
68
+ function buildHref(file) {
69
+ let href = "/preview?project=" + encodeURIComponent(projectId) + "&file=" + encodeURIComponent(file);
70
+ if (worktreeParams) href += "&" + worktreeParams;
71
+ return href;
72
+ }
73
+
74
+ function buildTree(files) {
75
+ const root = {};
76
+ for (const file of files) {
77
+ const parts = file.split("/");
78
+ let cursor = root;
79
+ for (let i = 0; i < parts.length; i += 1) {
80
+ const part = parts[i];
81
+ const isLeaf = i === parts.length - 1;
82
+ if (isLeaf) {
83
+ cursor[part] = file;
84
+ } else {
85
+ cursor[part] = cursor[part] || {};
86
+ cursor = cursor[part];
87
+ }
88
+ }
89
+ }
90
+ return root;
91
+ }
92
+
93
+ const FOLDER_STATE_KEY = "browser-folder-state:" + projectId + ":" + worktreeParams;
94
+
95
+ function getSavedFolderState() {
96
+ try {
97
+ const raw = localStorage.getItem(FOLDER_STATE_KEY);
98
+ return raw ? JSON.parse(raw) : null;
99
+ } catch { return null; }
100
+ }
101
+
102
+ function saveFolderState() {
103
+ const open = [];
104
+ fileList.querySelectorAll("details").forEach((d) => {
105
+ if (d.open) open.push(d.getAttribute("data-folder-path"));
106
+ });
107
+ try { localStorage.setItem(FOLDER_STATE_KEY, JSON.stringify(open)); } catch {}
108
+ }
109
+
110
+ function renderTree(node, parentPath) {
111
+ parentPath = parentPath || "";
112
+ const entries = Object.entries(node).sort(([a], [b]) => a.localeCompare(b));
113
+ return `<ul class=\"file-tree\">${entries.map(([name, value]) => {
114
+ if (typeof value === "string") {
115
+ const href = buildHref(value);
116
+ return `<li class=\"file-item\"><a href=\"${href}\" class=\"file-link\" data-tooltip=\"${escapeHtml(value)}\"><span class=\"file-icon\">${iconFor(value)}</span><span class=\"file-path\">${escapeHtml(name)}</span></a></li>`;
117
+ }
118
+ const folderPath = parentPath ? parentPath + "/" + name : name;
119
+ return `<li class=\"folder-item\"><details data-folder-path=\"${escapeHtml(folderPath)}\"><summary>${escapeHtml(name)}</summary>${renderTree(value, folderPath)}</details></li>`;
120
+ }).join("")}</ul>`;
121
+ }
122
+
123
+ function restoreFolderState() {
124
+ const saved = getSavedFolderState();
125
+ if (!saved) return; // first visit: all collapsed by default
126
+ const openSet = new Set(saved);
127
+ fileList.querySelectorAll("details").forEach((d) => {
128
+ d.open = openSet.has(d.getAttribute("data-folder-path"));
129
+ });
130
+ }
131
+
132
+ function attachFolderToggleListeners() {
133
+ fileList.addEventListener("toggle", (e) => {
134
+ if (e.target.tagName === "DETAILS") saveFolderState();
135
+ }, true);
136
+ }
137
+
138
+ async function loadFiles() {
139
+ fileList.innerHTML = "<li class=\"file-empty\">Loading...</li>";
140
+ let apiUrl = "/api/files?project=" + encodeURIComponent(projectId);
141
+ if (worktreeParams) apiUrl += "&" + worktreeParams;
142
+ const response = await fetch(apiUrl);
143
+ if (!response.ok) {
144
+ fileList.innerHTML = "<li class=\"file-empty\">Failed to load files.</li>";
145
+ return;
146
+ }
147
+
148
+ const data = await response.json();
149
+ const files = Array.isArray(data.files) ? data.files : [];
150
+
151
+ if (files.length === 0) {
152
+ fileList.innerHTML = "<li class=\"file-empty\">No previewable files found.</li>";
153
+ return;
154
+ }
155
+
156
+ fileList.innerHTML = renderTree(buildTree(files));
157
+ restoreFolderState();
158
+ attachFolderToggleListeners();
159
+ }
160
+
161
+ refreshButton.addEventListener("click", () => {
162
+ loadFiles().catch(() => {
163
+ fileList.innerHTML = "<li class=\"file-empty\">Failed to load files.</li>";
164
+ });
165
+ });
166
+
167
+ loadFiles().catch(() => {
168
+ fileList.innerHTML = "<li class=\"file-empty\">Failed to load files.</li>";
169
+ });
170
+
171
+ function getActiveWorktree() {
172
+ const params = new URLSearchParams(worktreeParams);
173
+ return params.get("worktree") || "";
174
+ }
175
+
176
+ function switchWorktree(name) {
177
+ const url = new URL(window.location);
178
+ if (name) {
179
+ url.searchParams.set("worktree", name);
180
+ } else {
181
+ url.searchParams.delete("worktree");
182
+ }
183
+ window.location.href = url.toString();
184
+ }
185
+
186
+ const branchSvg = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM5 9.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM11 3.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z" fill="currentColor" opacity="0"/><path d="M5 6.5v3M11 6.5C11 8 9.5 9.5 5 9.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/><circle cx="5" cy="5" r="1.5" stroke="currentColor" stroke-width="1.3"/><circle cx="5" cy="11" r="1.5" stroke="currentColor" stroke-width="1.3"/><circle cx="11" cy="5" r="1.5" stroke="currentColor" stroke-width="1.3"/></svg>';
187
+ const chevronSvg = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
188
+ const checkSvg = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 7L6 9.5L10.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
189
+
190
+ function buildSwitcher(container, worktrees, activeWt, defaultBranch, onSwitch) {
191
+ const allOptions = [{ value: "", label: defaultBranch || "unknown" }];
192
+ for (const wt of worktrees) {
193
+ const label = (typeof wt === "object" && wt.branch) ? wt.name + " (" + wt.branch + ")" : (wt.name || wt);
194
+ allOptions.push({ value: wt.name || wt, label: label });
195
+ }
196
+ const current = allOptions.find(o => o.value === activeWt) || allOptions[0];
197
+
198
+ const trigger = document.createElement("button");
199
+ trigger.className = "wt-trigger";
200
+ trigger.type = "button";
201
+ trigger.setAttribute("aria-expanded", "false");
202
+ trigger.innerHTML =
203
+ '<span class="wt-trigger-icon">' + branchSvg + '</span>'
204
+ + '<span class="wt-trigger-name">' + escapeHtml(current.label) + '</span>'
205
+ + '<span class="wt-trigger-chevron">' + chevronSvg + '</span>';
206
+
207
+ const dropdown = document.createElement("div");
208
+ dropdown.className = "wt-dropdown";
209
+ dropdown.setAttribute("data-open", "false");
210
+
211
+ for (const opt of allOptions) {
212
+ const btn = document.createElement("button");
213
+ btn.className = "wt-option";
214
+ btn.type = "button";
215
+ btn.setAttribute("data-active", opt.value === activeWt ? "true" : "false");
216
+ const badge = opt.value === "" ? '<span class="wt-option-badge">default</span>' : "";
217
+ btn.innerHTML =
218
+ '<span class="wt-option-check">' + checkSvg + '</span>'
219
+ + '<span class="wt-option-label">' + escapeHtml(opt.label) + '</span>'
220
+ + badge;
221
+ btn.addEventListener("click", () => onSwitch(opt.value));
222
+ dropdown.appendChild(btn);
223
+ }
224
+
225
+ trigger.addEventListener("click", (e) => {
226
+ e.stopPropagation();
227
+ const open = dropdown.getAttribute("data-open") === "true";
228
+ dropdown.setAttribute("data-open", open ? "false" : "true");
229
+ trigger.setAttribute("aria-expanded", open ? "false" : "true");
230
+ });
231
+
232
+ document.addEventListener("click", () => {
233
+ dropdown.setAttribute("data-open", "false");
234
+ trigger.setAttribute("aria-expanded", "false");
235
+ });
236
+
237
+ container.appendChild(trigger);
238
+ container.appendChild(dropdown);
239
+ }
240
+
241
+ async function loadWorktrees() {
242
+ try {
243
+ const resp = await fetch("/api/worktrees?project=" + encodeURIComponent(projectId));
244
+ const data = await resp.json();
245
+ const worktrees = Array.isArray(data.worktrees) ? data.worktrees : [];
246
+ if (worktrees.length === 0) return;
247
+
248
+ worktreePanel.style.display = "";
249
+ const active = getActiveWorktree();
250
+ const container = document.getElementById("worktree-switcher");
251
+ buildSwitcher(container, worktrees, active, data.defaultBranch, switchWorktree);
252
+ } catch {}
253
+ }
254
+
255
+ loadWorktrees();
256
+ </script>
257
+ </body>
258
+ </html>