@haowjy/remote-workspace 0.1.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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/launcher.js +308 -0
- package/dist/server.js +914 -0
- package/package.json +46 -0
- package/static/app.js +1240 -0
- package/static/index.html +185 -0
- package/static/styles.css +140 -0
package/static/app.js
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Theme — initialize before Tailwind processes classes
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
const savedTheme = localStorage.getItem("workspace-theme") || "dark";
|
|
5
|
+
document.documentElement.dataset.theme = savedTheme;
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Constants
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const ALLOWED_UPLOAD_EXTENSIONS = new Set([
|
|
11
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic", ".heif", ".avif",
|
|
12
|
+
]);
|
|
13
|
+
const IMAGE_MIME_TO_EXTENSION = {
|
|
14
|
+
"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
|
|
15
|
+
"image/webp": ".webp", "image/svg+xml": ".svg", "image/bmp": ".bmp",
|
|
16
|
+
"image/heic": ".heic", "image/heif": ".heif", "image/avif": ".avif",
|
|
17
|
+
};
|
|
18
|
+
const EXTENSION_TO_HLJS_LANG = {
|
|
19
|
+
".js": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
20
|
+
".ts": "typescript", ".tsx": "typescript", ".jsx": "javascript",
|
|
21
|
+
".go": "go", ".py": "python", ".rs": "rust",
|
|
22
|
+
".sh": "bash", ".bash": "bash", ".zsh": "bash",
|
|
23
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
24
|
+
".css": "css", ".html": "html", ".htm": "html",
|
|
25
|
+
".sql": "sql", ".diff": "diff", ".patch": "diff",
|
|
26
|
+
".toml": "ini", ".ini": "ini", ".cfg": "ini",
|
|
27
|
+
".xml": "xml", ".svg": "xml",
|
|
28
|
+
".rb": "ruby", ".java": "java", ".kt": "kotlin",
|
|
29
|
+
".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
|
30
|
+
".cs": "csharp", ".swift": "swift", ".lua": "lua",
|
|
31
|
+
".r": "r", ".R": "r", ".pl": "perl",
|
|
32
|
+
".dockerfile": "dockerfile", ".makefile": "makefile",
|
|
33
|
+
};
|
|
34
|
+
const SEARCH_DEBOUNCE_MS = 150;
|
|
35
|
+
const SEARCH_MAX_RESULTS = 20;
|
|
36
|
+
const CACHE_VERSION = 1;
|
|
37
|
+
const CACHE_PREFIX = `workspace-cache-v${CACHE_VERSION}:`;
|
|
38
|
+
const CACHE_KEYS = {
|
|
39
|
+
topLevel: `${CACHE_PREFIX}top-level`,
|
|
40
|
+
searchIndex: `${CACHE_PREFIX}search-index`,
|
|
41
|
+
clipboardEntries: `${CACHE_PREFIX}clipboard-entries`,
|
|
42
|
+
screenshotsEntries: `${CACHE_PREFIX}screenshots-entries`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// State
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const state = {
|
|
49
|
+
activeTab: "clipboard",
|
|
50
|
+
// Tree: top-level entries loaded eagerly, children loaded on expand
|
|
51
|
+
topLevelEntries: [],
|
|
52
|
+
dirChildren: new Map(),
|
|
53
|
+
dirLoading: new Set(),
|
|
54
|
+
expandedPaths: new Set(),
|
|
55
|
+
// Search: flat file list loaded in background via /api/tree
|
|
56
|
+
flatFilePaths: [],
|
|
57
|
+
searchReady: false,
|
|
58
|
+
searchQuery: "",
|
|
59
|
+
selectedPath: "",
|
|
60
|
+
fileRequestId: 0,
|
|
61
|
+
fileAbortController: null,
|
|
62
|
+
clipboardRequestId: 0,
|
|
63
|
+
clipboardAbortController: null,
|
|
64
|
+
clipboardApiMode: "modern",
|
|
65
|
+
pendingUploadFile: null,
|
|
66
|
+
searchDebounceTimer: null,
|
|
67
|
+
// Screenshots
|
|
68
|
+
screenshotEntries: [],
|
|
69
|
+
screenshotsRequestId: 0,
|
|
70
|
+
screenshotsAbortController: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// DOM references
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const $ = (id) => document.getElementById(id);
|
|
77
|
+
const statusEl = $("status");
|
|
78
|
+
const clipboardStatusEl = $("clipboard-status");
|
|
79
|
+
const clipboardGrid = $("clipboard-grid");
|
|
80
|
+
const fileTree = $("file-tree");
|
|
81
|
+
const viewer = $("viewer");
|
|
82
|
+
const viewerTitle = $("viewer-title");
|
|
83
|
+
const searchInput = $("search-input");
|
|
84
|
+
const searchResultsEl = $("search-results");
|
|
85
|
+
const uploadInput = $("upload-input");
|
|
86
|
+
const pasteClipboardBtn = $("paste-clipboard-btn");
|
|
87
|
+
const uploadNameInput = $("upload-name-input");
|
|
88
|
+
const uploadBtn = $("upload-btn");
|
|
89
|
+
const clipboardRefreshBtn = $("clipboard-refresh-btn");
|
|
90
|
+
const treeRefreshBtn = $("tree-refresh-btn");
|
|
91
|
+
const screenshotsGrid = $("screenshots-grid");
|
|
92
|
+
const screenshotsStatusEl = $("screenshots-status");
|
|
93
|
+
const screenshotsRefreshBtn = $("screenshots-refresh-btn");
|
|
94
|
+
const viewerRefreshBtn = $("viewer-refresh-btn");
|
|
95
|
+
const lightbox = $("lightbox");
|
|
96
|
+
const lightboxPanel = $("lightbox-panel");
|
|
97
|
+
const lightboxCloseBtn = $("lightbox-close-btn");
|
|
98
|
+
const lightboxImg = $("lightbox-img");
|
|
99
|
+
const lightboxSvgContainer = $("lightbox-svg-container");
|
|
100
|
+
const themeToggleBtn = $("theme-toggle-btn");
|
|
101
|
+
const hljsThemeLink = $("hljs-theme");
|
|
102
|
+
|
|
103
|
+
// Tab badges
|
|
104
|
+
const tabBadgeClipboard = $("tab-badge-clipboard");
|
|
105
|
+
const tabBadgeFiles = $("tab-badge-files");
|
|
106
|
+
const tabBadgeScreenshots = $("tab-badge-screenshots");
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Markdown + highlight.js integration
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
const md = window.markdownit({
|
|
112
|
+
html: false,
|
|
113
|
+
linkify: true,
|
|
114
|
+
breaks: true,
|
|
115
|
+
highlight: (str, lang) => {
|
|
116
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
117
|
+
try {
|
|
118
|
+
return hljs.highlight(str, { language: lang }).value;
|
|
119
|
+
} catch { /* fall through */ }
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
window.mermaid.initialize({
|
|
126
|
+
startOnLoad: false,
|
|
127
|
+
securityLevel: "strict",
|
|
128
|
+
theme: (localStorage.getItem("workspace-theme") || "dark") === "dark" ? "dark" : "default",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Utility helpers
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
function escapeHtml(value) {
|
|
135
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
136
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatBytes(size) {
|
|
140
|
+
if (size < 1024) return `${size} B`;
|
|
141
|
+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
142
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isMarkdownPath(p) { return /\.(md|markdown|mdx)$/i.test(p); }
|
|
146
|
+
function isImagePath(p) { return /\.(png|jpe?g|gif|webp|svg|bmp|heic|heif|avif)$/i.test(p); }
|
|
147
|
+
function isAbortError(e) { return e instanceof DOMException && e.name === "AbortError"; }
|
|
148
|
+
function toErrorMessage(e) { return e instanceof Error ? e.message : "Unexpected error"; }
|
|
149
|
+
|
|
150
|
+
function extensionToHljsLang(filePath) {
|
|
151
|
+
const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
|
|
152
|
+
return EXTENSION_TO_HLJS_LANG[ext] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getFileIconName(filename, type) {
|
|
156
|
+
if (type === "directory") return "folder";
|
|
157
|
+
if (isImagePath(filename)) return "image";
|
|
158
|
+
if (isMarkdownPath(filename)) return "file-text";
|
|
159
|
+
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
|
|
160
|
+
if (EXTENSION_TO_HLJS_LANG[ext]) return "file-code";
|
|
161
|
+
return "file";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setStatus(message, isError = false) {
|
|
165
|
+
statusEl.textContent = message;
|
|
166
|
+
statusEl.className = isError
|
|
167
|
+
? "text-[11px] text-red-400 font-mono truncate max-w-52"
|
|
168
|
+
: "text-[11px] text-ink-500 font-mono truncate max-w-52";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setClipboardStatus(message, isError = false) {
|
|
172
|
+
clipboardStatusEl.textContent = message;
|
|
173
|
+
clipboardStatusEl.className = isError
|
|
174
|
+
? "shrink-0 px-4 py-1 text-[11px] text-red-400 font-mono min-h-[1.4em]"
|
|
175
|
+
: "shrink-0 px-4 py-1 text-[11px] text-ink-500 font-mono min-h-[1.4em]";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setScreenshotsStatus(message, isError = false) {
|
|
179
|
+
screenshotsStatusEl.textContent = message;
|
|
180
|
+
screenshotsStatusEl.className = isError
|
|
181
|
+
? "shrink-0 px-4 py-1 text-[11px] text-red-400 font-mono min-h-[1.4em]"
|
|
182
|
+
: "shrink-0 px-4 py-1 text-[11px] text-ink-500 font-mono min-h-[1.4em]";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function refreshIcons() {
|
|
186
|
+
try { lucide.createIcons(); } catch { /* icons not ready yet */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Theme toggle
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
function updateThemeIcon(theme) {
|
|
193
|
+
const iconName = theme === "dark" ? "sun" : "moon";
|
|
194
|
+
themeToggleBtn.innerHTML = `<i data-lucide="${iconName}" class="w-4 h-4"></i>`;
|
|
195
|
+
refreshIcons();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Set initial icon to match saved theme
|
|
199
|
+
updateThemeIcon(savedTheme);
|
|
200
|
+
// Set initial hljs theme to match
|
|
201
|
+
if (savedTheme === "light") {
|
|
202
|
+
hljsThemeLink.href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function toggleTheme() {
|
|
206
|
+
const current = document.documentElement.dataset.theme;
|
|
207
|
+
const next = current === "dark" ? "light" : "dark";
|
|
208
|
+
document.documentElement.dataset.theme = next;
|
|
209
|
+
localStorage.setItem("workspace-theme", next);
|
|
210
|
+
|
|
211
|
+
updateThemeIcon(next);
|
|
212
|
+
|
|
213
|
+
// Swap highlight.js theme
|
|
214
|
+
hljsThemeLink.href = next === "dark"
|
|
215
|
+
? "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
|
216
|
+
: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css";
|
|
217
|
+
|
|
218
|
+
// Close lightbox — its cloned SVG won't update with the new theme
|
|
219
|
+
closeLightbox();
|
|
220
|
+
|
|
221
|
+
// Re-render mermaid diagrams with correct built-in theme
|
|
222
|
+
reRenderMermaid(next);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
themeToggleBtn.addEventListener("click", toggleTheme);
|
|
226
|
+
|
|
227
|
+
async function reRenderMermaid(theme) {
|
|
228
|
+
window.mermaid.initialize({
|
|
229
|
+
startOnLoad: false,
|
|
230
|
+
securityLevel: "strict",
|
|
231
|
+
theme: theme === "dark" ? "dark" : "default",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const diagrams = viewer.querySelectorAll("[data-diagram]");
|
|
235
|
+
for (const el of diagrams) {
|
|
236
|
+
const source = el.dataset.diagram;
|
|
237
|
+
const id = "mermaid-" + Math.random().toString(36).slice(2, 8);
|
|
238
|
+
try {
|
|
239
|
+
const { svg } = await window.mermaid.render(id, source);
|
|
240
|
+
el.innerHTML = svg;
|
|
241
|
+
} catch { /* leave as-is on error */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Re-attach click-to-zoom on the new SVGs (old ones were replaced)
|
|
245
|
+
for (const node of viewer.querySelectorAll(".mermaid svg")) {
|
|
246
|
+
node.addEventListener("click", () => openMermaidLightbox(node));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isRouteMissingResponse(response, parseError) {
|
|
251
|
+
if (response.status !== 404) return false;
|
|
252
|
+
const msg = toErrorMessage(parseError);
|
|
253
|
+
return msg.includes("Unexpected response payload") || msg.includes("Cannot POST") || msg.includes("Cannot GET");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function readJsonResponse(response) {
|
|
257
|
+
const raw = await response.text();
|
|
258
|
+
if (!raw) return {};
|
|
259
|
+
try { return JSON.parse(raw); } catch { throw new Error(`Unexpected response payload (HTTP ${response.status})`); }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function readCachedValue(cacheKey) {
|
|
263
|
+
try {
|
|
264
|
+
const raw = localStorage.getItem(cacheKey);
|
|
265
|
+
if (!raw) return null;
|
|
266
|
+
const parsed = JSON.parse(raw);
|
|
267
|
+
if (
|
|
268
|
+
!parsed ||
|
|
269
|
+
typeof parsed !== "object" ||
|
|
270
|
+
!Object.prototype.hasOwnProperty.call(parsed, "value")
|
|
271
|
+
) {
|
|
272
|
+
localStorage.removeItem(cacheKey);
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
return parsed.value;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeCachedValue(cacheKey, value) {
|
|
282
|
+
try {
|
|
283
|
+
localStorage.setItem(cacheKey, JSON.stringify({ value, updatedAt: Date.now() }));
|
|
284
|
+
} catch {
|
|
285
|
+
// Ignore quota/storage errors - cache should never block app behavior.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function clearCachedValue(cacheKey) {
|
|
290
|
+
try {
|
|
291
|
+
localStorage.removeItem(cacheKey);
|
|
292
|
+
} catch {
|
|
293
|
+
// Ignore storage errors.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function appendVersionQuery(url, entry) {
|
|
298
|
+
const version = entry?.modifiedAt ? Date.parse(entry.modifiedAt) : NaN;
|
|
299
|
+
if (!Number.isFinite(version)) return url;
|
|
300
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
301
|
+
return `${url}${separator}v=${version}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Tab navigation
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
const tabButtons = document.querySelectorAll(".tab-btn");
|
|
308
|
+
const tabPanels = document.querySelectorAll(".tab-panel");
|
|
309
|
+
|
|
310
|
+
function switchTab(tabName) {
|
|
311
|
+
state.activeTab = tabName;
|
|
312
|
+
localStorage.setItem("workspace-tab", tabName);
|
|
313
|
+
tabPanels.forEach((p) => p.classList.toggle("hidden", p.id !== `panel-${tabName}`));
|
|
314
|
+
tabButtons.forEach((b) => b.classList.toggle("active", b.dataset.tab === tabName));
|
|
315
|
+
refreshIcons();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
tabButtons.forEach((btn) => {
|
|
319
|
+
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Initialize tab — restore last active or default to clipboard
|
|
323
|
+
switchTab(localStorage.getItem("workspace-tab") || "clipboard");
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Lightbox
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
function openLightbox(src) {
|
|
329
|
+
lightboxSvgContainer.classList.add("hidden");
|
|
330
|
+
lightboxSvgContainer.innerHTML = "";
|
|
331
|
+
lightboxImg.classList.remove("hidden");
|
|
332
|
+
lightboxImg.src = src;
|
|
333
|
+
lightbox.classList.remove("hidden");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function openMermaidLightbox(svgElement) {
|
|
337
|
+
// Hide the <img>, show the SVG container
|
|
338
|
+
lightboxImg.classList.add("hidden");
|
|
339
|
+
lightboxImg.src = "";
|
|
340
|
+
lightboxSvgContainer.innerHTML = "";
|
|
341
|
+
|
|
342
|
+
const clone = svgElement.cloneNode(true);
|
|
343
|
+
// Remove Mermaid's max-width constraint, fill the container
|
|
344
|
+
clone.removeAttribute("style");
|
|
345
|
+
clone.style.width = "100%";
|
|
346
|
+
clone.style.height = "100%";
|
|
347
|
+
lightboxSvgContainer.appendChild(clone);
|
|
348
|
+
lightboxSvgContainer.classList.remove("hidden");
|
|
349
|
+
lightbox.classList.remove("hidden");
|
|
350
|
+
|
|
351
|
+
// Init pan/zoom if svg-pan-zoom is available
|
|
352
|
+
if (typeof svgPanZoom === "function") {
|
|
353
|
+
try {
|
|
354
|
+
svgPanZoom(clone, {
|
|
355
|
+
zoomEnabled: true,
|
|
356
|
+
panEnabled: true,
|
|
357
|
+
controlIconsEnabled: true,
|
|
358
|
+
fit: true,
|
|
359
|
+
center: true,
|
|
360
|
+
minZoom: 0.5,
|
|
361
|
+
maxZoom: 10,
|
|
362
|
+
});
|
|
363
|
+
} catch { /* svg-pan-zoom may fail on some SVGs — leave as-is */ }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function closeLightbox() {
|
|
368
|
+
lightbox.classList.add("hidden");
|
|
369
|
+
lightboxImg.classList.add("hidden");
|
|
370
|
+
lightboxImg.src = "";
|
|
371
|
+
lightboxSvgContainer.classList.add("hidden");
|
|
372
|
+
lightboxSvgContainer.innerHTML = "";
|
|
373
|
+
}
|
|
374
|
+
// Expose globally for any inline onclick references
|
|
375
|
+
window.closeLightbox = closeLightbox;
|
|
376
|
+
|
|
377
|
+
// Close lightbox when clicking anything that isn't the image, SVG, or close button.
|
|
378
|
+
// The panel background (bg-ink-900) looks nearly identical to the backdrop (bg-ink-950/90),
|
|
379
|
+
// so users expect clicks anywhere outside the actual content to dismiss.
|
|
380
|
+
lightbox.addEventListener("click", (e) => {
|
|
381
|
+
const t = e.target;
|
|
382
|
+
if (lightboxCloseBtn.contains(t)) return; // handled below
|
|
383
|
+
if (lightboxImg.contains(t)) return;
|
|
384
|
+
if (lightboxSvgContainer.contains(t)) return;
|
|
385
|
+
closeLightbox();
|
|
386
|
+
});
|
|
387
|
+
lightboxCloseBtn.addEventListener("click", closeLightbox);
|
|
388
|
+
document.addEventListener("keydown", (e) => {
|
|
389
|
+
if (e.key === "Escape" && !lightbox.classList.contains("hidden")) closeLightbox();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Tree: load top-level eagerly, children on demand
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
async function loadTopLevel(options = {}) {
|
|
396
|
+
const preferCache = options.preferCache !== false;
|
|
397
|
+
const cachedEntries = preferCache ? readCachedValue(CACHE_KEYS.topLevel) : null;
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
setStatus("Loading...");
|
|
401
|
+
if (cachedEntries && Array.isArray(cachedEntries)) {
|
|
402
|
+
state.topLevelEntries = cachedEntries;
|
|
403
|
+
tabBadgeFiles.textContent = `${state.topLevelEntries.length}`;
|
|
404
|
+
renderTree();
|
|
405
|
+
setStatus(`${state.topLevelEntries.length} cached entries (refreshing...)`);
|
|
406
|
+
} else {
|
|
407
|
+
fileTree.innerHTML = '<div class="flex items-center justify-center py-8 text-ink-500 text-sm"><i data-lucide="loader" class="w-4 h-4 mr-2 animate-spin"></i>Loading...</div>';
|
|
408
|
+
refreshIcons();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const res = await fetch("/api/list?path=");
|
|
412
|
+
const data = await readJsonResponse(res);
|
|
413
|
+
if (!res.ok) throw new Error(data.error || "Unable to load files");
|
|
414
|
+
|
|
415
|
+
state.topLevelEntries = data.entries || [];
|
|
416
|
+
writeCachedValue(CACHE_KEYS.topLevel, state.topLevelEntries);
|
|
417
|
+
tabBadgeFiles.textContent = `${state.topLevelEntries.length}`;
|
|
418
|
+
renderTree();
|
|
419
|
+
setStatus(`${state.topLevelEntries.length} entries`);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (isAbortError(error)) return;
|
|
422
|
+
if (cachedEntries && Array.isArray(cachedEntries)) {
|
|
423
|
+
setStatus(`Using cached entries: ${toErrorMessage(error)}`, true);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
fileTree.innerHTML = `<p class="p-3 text-sm text-red-400">${escapeHtml(toErrorMessage(error))}</p>`;
|
|
427
|
+
setStatus(toErrorMessage(error), true);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Background-load the full tree for search indexing only
|
|
432
|
+
async function loadSearchIndex(options = {}) {
|
|
433
|
+
const preferCache = options.preferCache !== false;
|
|
434
|
+
const cachedIndex = preferCache ? readCachedValue(CACHE_KEYS.searchIndex) : null;
|
|
435
|
+
if (cachedIndex && Array.isArray(cachedIndex.flatFilePaths)) {
|
|
436
|
+
state.flatFilePaths = cachedIndex.flatFilePaths;
|
|
437
|
+
state.searchReady = true;
|
|
438
|
+
if (typeof cachedIndex.totalFiles === "number") {
|
|
439
|
+
tabBadgeFiles.textContent = `${cachedIndex.totalFiles}`;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const res = await fetch("/api/tree");
|
|
445
|
+
const data = await readJsonResponse(res);
|
|
446
|
+
if (!res.ok) return;
|
|
447
|
+
|
|
448
|
+
state.flatFilePaths = [];
|
|
449
|
+
flattenTree(data.root);
|
|
450
|
+
state.searchReady = true;
|
|
451
|
+
tabBadgeFiles.textContent = `${data.totalFiles}`;
|
|
452
|
+
writeCachedValue(CACHE_KEYS.searchIndex, {
|
|
453
|
+
flatFilePaths: state.flatFilePaths,
|
|
454
|
+
totalFiles: data.totalFiles,
|
|
455
|
+
});
|
|
456
|
+
} catch {
|
|
457
|
+
// Search won't work but tree browsing still does
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function flattenTree(node) {
|
|
462
|
+
if (!node.children) {
|
|
463
|
+
if (node.name) state.flatFilePaths.push({ name: node.name, path: node.path, type: node.type });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
for (const child of node.children) {
|
|
467
|
+
flattenTree(child);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Fetch children of a directory on demand
|
|
472
|
+
async function loadDirChildren(dirPath) {
|
|
473
|
+
if (state.dirChildren.has(dirPath) || state.dirLoading.has(dirPath)) return;
|
|
474
|
+
state.dirLoading.add(dirPath);
|
|
475
|
+
renderTree();
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetch(`/api/list?path=${encodeURIComponent(dirPath)}`);
|
|
479
|
+
const data = await readJsonResponse(res);
|
|
480
|
+
if (!res.ok) throw new Error(data.error || "Unable to load directory");
|
|
481
|
+
state.dirChildren.set(dirPath, data.entries || []);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
state.expandedPaths.delete(dirPath);
|
|
484
|
+
setStatus(toErrorMessage(error), true);
|
|
485
|
+
} finally {
|
|
486
|
+
state.dirLoading.delete(dirPath);
|
|
487
|
+
renderTree();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Tree: render
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
function renderTree() {
|
|
495
|
+
fileTree.innerHTML = "";
|
|
496
|
+
if (state.topLevelEntries.length === 0) {
|
|
497
|
+
fileTree.innerHTML = '<p class="p-3 text-sm text-ink-500">No files found.</p>';
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const frag = document.createDocumentFragment();
|
|
501
|
+
for (const entry of state.topLevelEntries) {
|
|
502
|
+
renderTreeNode(entry, 0, frag);
|
|
503
|
+
}
|
|
504
|
+
fileTree.appendChild(frag);
|
|
505
|
+
refreshIcons();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderTreeNode(entry, depth, container) {
|
|
509
|
+
const isDir = entry.type === "directory";
|
|
510
|
+
const isExpanded = state.expandedPaths.has(entry.path);
|
|
511
|
+
const isLoading = state.dirLoading.has(entry.path);
|
|
512
|
+
const isActive = state.selectedPath === entry.path;
|
|
513
|
+
const iconName = getFileIconName(entry.name, entry.type);
|
|
514
|
+
|
|
515
|
+
const row = document.createElement("div");
|
|
516
|
+
row.className = "tree-item";
|
|
517
|
+
row.style.setProperty("--depth", depth);
|
|
518
|
+
|
|
519
|
+
const btn = document.createElement("button");
|
|
520
|
+
btn.type = "button";
|
|
521
|
+
btn.className = `w-full flex items-center gap-1.5 px-2 py-[3px] text-sm rounded-md transition-colors group ${
|
|
522
|
+
isActive ? "bg-brand/10 text-brand font-medium" : "text-ink-300 hover:bg-ink-800"
|
|
523
|
+
}`;
|
|
524
|
+
|
|
525
|
+
if (isDir) {
|
|
526
|
+
if (isLoading) {
|
|
527
|
+
btn.innerHTML = `<i data-lucide="loader" class="w-3.5 h-3.5 text-ink-500 shrink-0 animate-spin"></i>`;
|
|
528
|
+
} else {
|
|
529
|
+
btn.innerHTML = `<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-ink-500 shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}"></i>`;
|
|
530
|
+
}
|
|
531
|
+
} else {
|
|
532
|
+
btn.innerHTML = `<span class="w-3.5 shrink-0"></span>`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
btn.innerHTML += `<i data-lucide="${iconName}" class="w-3.5 h-3.5 ${
|
|
536
|
+
isDir ? "text-amber-500" : isActive ? "text-brand" : "text-ink-500"
|
|
537
|
+
} shrink-0"></i>`;
|
|
538
|
+
btn.innerHTML += `<span class="truncate font-mono text-[13px]">${escapeHtml(entry.name)}</span>`;
|
|
539
|
+
|
|
540
|
+
btn.onclick = () => {
|
|
541
|
+
if (isDir) {
|
|
542
|
+
toggleDirectory(entry.path);
|
|
543
|
+
} else {
|
|
544
|
+
openFile(entry.path).catch(handleActionError);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
row.appendChild(btn);
|
|
549
|
+
container.appendChild(row);
|
|
550
|
+
|
|
551
|
+
if (isDir && isExpanded) {
|
|
552
|
+
const children = state.dirChildren.get(entry.path);
|
|
553
|
+
if (children) {
|
|
554
|
+
for (const child of children) {
|
|
555
|
+
renderTreeNode(child, depth + 1, container);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function toggleDirectory(dirPath) {
|
|
562
|
+
if (state.expandedPaths.has(dirPath)) {
|
|
563
|
+
state.expandedPaths.delete(dirPath);
|
|
564
|
+
renderTree();
|
|
565
|
+
} else {
|
|
566
|
+
state.expandedPaths.add(dirPath);
|
|
567
|
+
if (!state.dirChildren.has(dirPath)) {
|
|
568
|
+
loadDirChildren(dirPath);
|
|
569
|
+
} else {
|
|
570
|
+
renderTree();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function expandParentsOf(filePath) {
|
|
576
|
+
const segments = filePath.split("/");
|
|
577
|
+
let running = "";
|
|
578
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
579
|
+
running = running ? `${running}/${segments[i]}` : segments[i];
|
|
580
|
+
state.expandedPaths.add(running);
|
|
581
|
+
if (!state.dirChildren.has(running) && !state.dirLoading.has(running)) {
|
|
582
|
+
loadDirChildren(running);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Search
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
function handleSearch(query) {
|
|
591
|
+
state.searchQuery = query;
|
|
592
|
+
if (!query.trim()) {
|
|
593
|
+
searchResultsEl.classList.add("hidden");
|
|
594
|
+
searchResultsEl.innerHTML = "";
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!state.searchReady) {
|
|
599
|
+
searchResultsEl.innerHTML = '<div class="p-3 text-sm text-ink-500">Search index loading...</div>';
|
|
600
|
+
searchResultsEl.classList.remove("hidden");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const lowerQuery = query.toLowerCase();
|
|
605
|
+
const scored = [];
|
|
606
|
+
for (const entry of state.flatFilePaths) {
|
|
607
|
+
const lowerPath = entry.path.toLowerCase();
|
|
608
|
+
const lowerName = entry.name.toLowerCase();
|
|
609
|
+
if (!lowerPath.includes(lowerQuery)) continue;
|
|
610
|
+
|
|
611
|
+
let score = 0;
|
|
612
|
+
if (lowerName === lowerQuery) score = 3;
|
|
613
|
+
else if (lowerName.includes(lowerQuery)) score = 2;
|
|
614
|
+
else score = 1;
|
|
615
|
+
scored.push({ ...entry, score });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
|
619
|
+
const results = scored.slice(0, SEARCH_MAX_RESULTS);
|
|
620
|
+
|
|
621
|
+
if (results.length === 0) {
|
|
622
|
+
searchResultsEl.innerHTML = '<div class="p-3 text-sm text-ink-500">No matches found</div>';
|
|
623
|
+
searchResultsEl.classList.remove("hidden");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
searchResultsEl.innerHTML = "";
|
|
628
|
+
for (const result of results) {
|
|
629
|
+
const iconName = getFileIconName(result.name, result.type);
|
|
630
|
+
const item = document.createElement("button");
|
|
631
|
+
item.type = "button";
|
|
632
|
+
item.className = "w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-ink-700 transition-colors border-b border-ink-700/50 last:border-0 text-ink-300";
|
|
633
|
+
item.innerHTML = `<i data-lucide="${iconName}" class="w-3.5 h-3.5 text-ink-500 shrink-0"></i>
|
|
634
|
+
<span class="truncate font-mono text-[13px]">${escapeHtml(result.path)}</span>`;
|
|
635
|
+
item.onclick = () => {
|
|
636
|
+
searchInput.value = "";
|
|
637
|
+
searchResultsEl.classList.add("hidden");
|
|
638
|
+
searchResultsEl.innerHTML = "";
|
|
639
|
+
expandParentsOf(result.path);
|
|
640
|
+
if (result.type === "directory") {
|
|
641
|
+
toggleDirectory(result.path);
|
|
642
|
+
} else {
|
|
643
|
+
openFile(result.path).catch(handleActionError);
|
|
644
|
+
}
|
|
645
|
+
renderTree();
|
|
646
|
+
};
|
|
647
|
+
searchResultsEl.appendChild(item);
|
|
648
|
+
}
|
|
649
|
+
searchResultsEl.classList.remove("hidden");
|
|
650
|
+
refreshIcons();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
searchInput.addEventListener("input", () => {
|
|
654
|
+
clearTimeout(state.searchDebounceTimer);
|
|
655
|
+
state.searchDebounceTimer = setTimeout(() => handleSearch(searchInput.value), SEARCH_DEBOUNCE_MS);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
document.addEventListener("click", (e) => {
|
|
659
|
+
if (!searchInput.contains(e.target) && !searchResultsEl.contains(e.target)) {
|
|
660
|
+
searchResultsEl.classList.add("hidden");
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
searchInput.addEventListener("focus", () => {
|
|
664
|
+
if (searchInput.value.trim()) handleSearch(searchInput.value);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
// File preview
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
async function openFile(filePath) {
|
|
671
|
+
const requestId = ++state.fileRequestId;
|
|
672
|
+
if (state.fileAbortController) state.fileAbortController.abort();
|
|
673
|
+
const controller = new AbortController();
|
|
674
|
+
state.fileAbortController = controller;
|
|
675
|
+
|
|
676
|
+
state.selectedPath = filePath;
|
|
677
|
+
localStorage.setItem("workspace-file", filePath);
|
|
678
|
+
viewerRefreshBtn.classList.remove("hidden");
|
|
679
|
+
expandParentsOf(filePath);
|
|
680
|
+
renderTree();
|
|
681
|
+
|
|
682
|
+
viewerTitle.textContent = filePath;
|
|
683
|
+
setStatus(`Opening ${filePath}...`);
|
|
684
|
+
|
|
685
|
+
if (isImagePath(filePath)) {
|
|
686
|
+
if (requestId !== state.fileRequestId) return;
|
|
687
|
+
viewer.innerHTML = `<img alt="${escapeHtml(filePath)}" src="/api/file?path=${encodeURIComponent(filePath)}" class="max-w-full rounded-lg cursor-pointer" onclick="openLightbox(this.src)" />`;
|
|
688
|
+
setStatus(`Image: ${filePath}`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const response = await fetch(`/api/text?path=${encodeURIComponent(filePath)}`, { signal: controller.signal });
|
|
693
|
+
const data = await readJsonResponse(response);
|
|
694
|
+
if (requestId !== state.fileRequestId) return;
|
|
695
|
+
|
|
696
|
+
if (!response.ok) {
|
|
697
|
+
viewer.innerHTML = `<p class="text-red-400 text-sm">${escapeHtml(data.error || "Failed to open file.")}</p>`;
|
|
698
|
+
setStatus("File open failed", true);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (data.binary) {
|
|
703
|
+
viewer.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-ink-500">
|
|
704
|
+
<i data-lucide="file-warning" class="w-8 h-8 mb-2 opacity-40"></i>
|
|
705
|
+
<p class="text-sm mb-2">Binary file</p>
|
|
706
|
+
<a href="/api/file?path=${encodeURIComponent(filePath)}" target="_blank" rel="noopener" class="text-brand text-sm underline">Download / open raw</a>
|
|
707
|
+
</div>`;
|
|
708
|
+
refreshIcons();
|
|
709
|
+
setStatus("Binary file");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (isMarkdownPath(filePath)) {
|
|
714
|
+
await renderMarkdown(data.content, data.truncated, requestId);
|
|
715
|
+
if (requestId !== state.fileRequestId) return;
|
|
716
|
+
setStatus(data.truncated ? "Markdown (truncated)" : "Markdown preview");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const lang = extensionToHljsLang(filePath);
|
|
721
|
+
let highlighted;
|
|
722
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
723
|
+
try {
|
|
724
|
+
highlighted = hljs.highlight(data.content || "", { language: lang }).value;
|
|
725
|
+
} catch { /* fall through */ }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (highlighted) {
|
|
729
|
+
viewer.innerHTML = `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`;
|
|
730
|
+
} else {
|
|
731
|
+
viewer.innerHTML = `<pre><code>${escapeHtml(data.content || "")}</code></pre>`;
|
|
732
|
+
}
|
|
733
|
+
if (data.truncated) {
|
|
734
|
+
viewer.insertAdjacentHTML("beforeend", '<p class="text-xs text-ink-500 mt-2">Preview truncated for large file.</p>');
|
|
735
|
+
}
|
|
736
|
+
setStatus(data.truncated ? "Text (truncated)" : "Text preview");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function renderMarkdown(markdownContent, truncated, requestId) {
|
|
740
|
+
if (requestId !== state.fileRequestId) return;
|
|
741
|
+
|
|
742
|
+
const html = md.render(markdownContent || "");
|
|
743
|
+
viewer.innerHTML = `<article class="markdown-body">${html}</article>${
|
|
744
|
+
truncated ? '<p class="text-xs text-ink-500 mt-3">Preview truncated for large file.</p>' : ""
|
|
745
|
+
}`;
|
|
746
|
+
|
|
747
|
+
const mermaidCodeBlocks = viewer.querySelectorAll("pre > code.language-mermaid");
|
|
748
|
+
for (const codeBlock of mermaidCodeBlocks) {
|
|
749
|
+
const pre = codeBlock.closest("pre");
|
|
750
|
+
if (!pre) continue;
|
|
751
|
+
const chart = document.createElement("div");
|
|
752
|
+
chart.className = "mermaid";
|
|
753
|
+
chart.textContent = codeBlock.textContent || "";
|
|
754
|
+
pre.replaceWith(chart);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const mermaidNodes = viewer.querySelectorAll(".mermaid");
|
|
758
|
+
if (mermaidNodes.length > 0) {
|
|
759
|
+
// Save source BEFORE mermaid.run() replaces textContent with SVG
|
|
760
|
+
const mermaidSources = new Map();
|
|
761
|
+
for (const node of mermaidNodes) {
|
|
762
|
+
mermaidSources.set(node, node.textContent);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
await window.mermaid.run({ nodes: mermaidNodes });
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.error(error);
|
|
769
|
+
if (requestId !== state.fileRequestId) return;
|
|
770
|
+
const warning = document.createElement("p");
|
|
771
|
+
warning.className = "text-xs text-red-400 mt-2";
|
|
772
|
+
warning.textContent = "Mermaid render failed for one or more diagrams.";
|
|
773
|
+
viewer.appendChild(warning);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Persist source as data attribute for theme re-rendering
|
|
777
|
+
for (const [node, source] of mermaidSources) {
|
|
778
|
+
node.dataset.diagram = source;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Add click-to-zoom on rendered SVGs
|
|
782
|
+
for (const node of viewer.querySelectorAll(".mermaid svg")) {
|
|
783
|
+
node.addEventListener("click", () => openMermaidLightbox(node));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
// Clipboard images
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
function clipboardImageUrl(entry) {
|
|
792
|
+
if (state.clipboardApiMode === "legacy") {
|
|
793
|
+
return appendVersionQuery(`/api/file?path=${encodeURIComponent(entry.path)}`, entry);
|
|
794
|
+
}
|
|
795
|
+
return appendVersionQuery(`/api/clipboard/file?name=${encodeURIComponent(entry.name)}`, entry);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function renderClipboardImages(imageEntries) {
|
|
799
|
+
clipboardGrid.innerHTML = "";
|
|
800
|
+
if (imageEntries.length === 0) {
|
|
801
|
+
clipboardGrid.innerHTML = '<p class="text-sm text-ink-500 col-span-full py-8 text-center">No images in .clipboard yet.</p>';
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const entry of imageEntries) {
|
|
806
|
+
const container = document.createElement("div");
|
|
807
|
+
container.className = "relative group border border-ink-800 rounded-lg bg-ink-900 p-1.5 flex flex-col gap-1 hover:border-ink-600 transition-colors";
|
|
808
|
+
|
|
809
|
+
const imgBtn = document.createElement("button");
|
|
810
|
+
imgBtn.type = "button";
|
|
811
|
+
imgBtn.className = "block w-full";
|
|
812
|
+
const imageUrl = clipboardImageUrl(entry);
|
|
813
|
+
imgBtn.innerHTML = `<img alt="${escapeHtml(entry.name)}" src="${imageUrl}" loading="lazy" class="w-full h-24 object-cover rounded border border-ink-800 bg-ink-950" />`;
|
|
814
|
+
imgBtn.onclick = () => openLightbox(imageUrl);
|
|
815
|
+
|
|
816
|
+
const footer = document.createElement("div");
|
|
817
|
+
footer.className = "flex items-center gap-1 min-w-0";
|
|
818
|
+
footer.innerHTML = `<span class="text-[11px] font-mono text-ink-500 truncate flex-1">${escapeHtml(entry.name)}</span>`;
|
|
819
|
+
|
|
820
|
+
const copyBtn = document.createElement("button");
|
|
821
|
+
copyBtn.type = "button";
|
|
822
|
+
copyBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-brand rounded transition-colors";
|
|
823
|
+
copyBtn.title = "Copy path";
|
|
824
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
825
|
+
copyBtn.onclick = async (e) => {
|
|
826
|
+
e.stopPropagation();
|
|
827
|
+
try {
|
|
828
|
+
await navigator.clipboard.writeText(`.clipboard/${entry.name}`);
|
|
829
|
+
copyBtn.innerHTML = `<i data-lucide="check" class="w-3 h-3 text-green-400"></i>`;
|
|
830
|
+
refreshIcons();
|
|
831
|
+
setTimeout(() => {
|
|
832
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
833
|
+
refreshIcons();
|
|
834
|
+
}, 1500);
|
|
835
|
+
} catch {
|
|
836
|
+
setClipboardStatus("Copy failed — clipboard access denied.", true);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
footer.appendChild(copyBtn);
|
|
840
|
+
|
|
841
|
+
const deleteBtn = document.createElement("button");
|
|
842
|
+
deleteBtn.type = "button";
|
|
843
|
+
deleteBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-red-400 rounded transition-colors";
|
|
844
|
+
deleteBtn.title = "Delete image";
|
|
845
|
+
deleteBtn.innerHTML = `<i data-lucide="trash-2" class="w-3 h-3"></i>`;
|
|
846
|
+
deleteBtn.onclick = async (e) => {
|
|
847
|
+
e.stopPropagation();
|
|
848
|
+
const confirmed = window.confirm(`Delete .clipboard/${entry.name}?`);
|
|
849
|
+
if (!confirmed) return;
|
|
850
|
+
try {
|
|
851
|
+
await deleteClipboardImage(entry.name);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
setClipboardStatus(toErrorMessage(error), true);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
footer.appendChild(deleteBtn);
|
|
857
|
+
|
|
858
|
+
container.appendChild(imgBtn);
|
|
859
|
+
container.appendChild(footer);
|
|
860
|
+
clipboardGrid.appendChild(container);
|
|
861
|
+
}
|
|
862
|
+
refreshIcons();
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async function fetchClipboardEntriesModern(signal) {
|
|
866
|
+
const response = await fetch("/api/clipboard/list", { signal });
|
|
867
|
+
let data;
|
|
868
|
+
try { data = await readJsonResponse(response); } catch (error) {
|
|
869
|
+
if (isRouteMissingResponse(response, error)) throw new Error("Clipboard API not available");
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
872
|
+
if (!response.ok) throw new Error(data.error || "Unable to load .clipboard");
|
|
873
|
+
return data.entries || [];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function fetchClipboardEntriesLegacy(signal) {
|
|
877
|
+
const response = await fetch("/api/list?path=.clipboard", { signal });
|
|
878
|
+
const data = await readJsonResponse(response);
|
|
879
|
+
if (!response.ok) throw new Error(data.error || "Unable to load .clipboard");
|
|
880
|
+
return (data.entries || []).filter((e) => e.type === "file");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function loadClipboardImages(options = {}) {
|
|
884
|
+
const preferCache = options.preferCache !== false;
|
|
885
|
+
const requestId = ++state.clipboardRequestId;
|
|
886
|
+
if (state.clipboardAbortController) state.clipboardAbortController.abort();
|
|
887
|
+
const controller = new AbortController();
|
|
888
|
+
state.clipboardAbortController = controller;
|
|
889
|
+
const cachedEntries = preferCache ? readCachedValue(CACHE_KEYS.clipboardEntries) : null;
|
|
890
|
+
|
|
891
|
+
try {
|
|
892
|
+
setClipboardStatus("Loading...");
|
|
893
|
+
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
894
|
+
state.clipboardApiMode = cachedEntries.mode === "legacy" ? "legacy" : "modern";
|
|
895
|
+
const cachedImageEntries = cachedEntries.entries.filter((e) => isImagePath(e.path || e.name));
|
|
896
|
+
tabBadgeClipboard.textContent = cachedImageEntries.length ? `${cachedImageEntries.length}` : "";
|
|
897
|
+
renderClipboardImages(cachedImageEntries);
|
|
898
|
+
setClipboardStatus(`${cachedImageEntries.length} cached image(s) (refreshing...)`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
let entries;
|
|
902
|
+
let mode = "modern";
|
|
903
|
+
try {
|
|
904
|
+
entries = await fetchClipboardEntriesModern(controller.signal);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (toErrorMessage(error) === "Clipboard API not available") {
|
|
907
|
+
entries = await fetchClipboardEntriesLegacy(controller.signal);
|
|
908
|
+
mode = "legacy";
|
|
909
|
+
} else throw error;
|
|
910
|
+
}
|
|
911
|
+
if (requestId !== state.clipboardRequestId) return;
|
|
912
|
+
state.clipboardApiMode = mode;
|
|
913
|
+
const imageEntries = entries.filter((e) => isImagePath(e.path || e.name));
|
|
914
|
+
writeCachedValue(CACHE_KEYS.clipboardEntries, { mode, entries: imageEntries });
|
|
915
|
+
tabBadgeClipboard.textContent = imageEntries.length ? `${imageEntries.length}` : "";
|
|
916
|
+
renderClipboardImages(imageEntries);
|
|
917
|
+
setClipboardStatus(mode === "legacy"
|
|
918
|
+
? `${imageEntries.length} image(s) — legacy API`
|
|
919
|
+
: `${imageEntries.length} image(s)`);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (isAbortError(error)) return;
|
|
922
|
+
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
923
|
+
setClipboardStatus(`Using cached images: ${toErrorMessage(error)}`, true);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
setClipboardStatus(toErrorMessage(error), true);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
// Screenshots
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
function screenshotImageUrl(entry) {
|
|
934
|
+
return appendVersionQuery(`/api/screenshots/file?name=${encodeURIComponent(entry.name)}`, entry);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function renderScreenshots(entries) {
|
|
938
|
+
screenshotsGrid.innerHTML = "";
|
|
939
|
+
if (entries.length === 0) {
|
|
940
|
+
screenshotsGrid.innerHTML = '<p class="text-sm text-ink-500 col-span-full py-8 text-center">No screenshots in .playwright-mcp/ yet.</p>';
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
for (const entry of entries) {
|
|
945
|
+
const container = document.createElement("div");
|
|
946
|
+
container.className = "relative group border border-ink-800 rounded-lg bg-ink-900 p-1.5 flex flex-col gap-1 hover:border-ink-600 transition-colors";
|
|
947
|
+
|
|
948
|
+
const imgBtn = document.createElement("button");
|
|
949
|
+
imgBtn.type = "button";
|
|
950
|
+
imgBtn.className = "block w-full";
|
|
951
|
+
const imageUrl = screenshotImageUrl(entry);
|
|
952
|
+
imgBtn.innerHTML = `<img alt="${escapeHtml(entry.name)}" src="${imageUrl}" loading="lazy" class="w-full h-32 object-cover rounded border border-ink-800 bg-ink-950" />`;
|
|
953
|
+
imgBtn.onclick = () => openLightbox(imageUrl);
|
|
954
|
+
|
|
955
|
+
const footer = document.createElement("div");
|
|
956
|
+
footer.className = "flex items-center gap-1 min-w-0";
|
|
957
|
+
|
|
958
|
+
// Parse timestamp from filename (page-YYYY-MM-DDTHH-MM-SS-mmmZ.png)
|
|
959
|
+
const tsMatch = entry.name.match(/(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
|
960
|
+
const timeLabel = tsMatch ? `${tsMatch[1]} ${tsMatch[2]}:${tsMatch[3]}:${tsMatch[4]}` : entry.name;
|
|
961
|
+
|
|
962
|
+
footer.innerHTML = `<span class="text-[11px] font-mono text-ink-500 truncate flex-1">${escapeHtml(timeLabel)}</span>`;
|
|
963
|
+
|
|
964
|
+
if (entry.size) {
|
|
965
|
+
footer.innerHTML += `<span class="text-[10px] font-mono text-ink-600 shrink-0">${formatBytes(entry.size)}</span>`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const copyBtn = document.createElement("button");
|
|
969
|
+
copyBtn.type = "button";
|
|
970
|
+
copyBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-brand rounded transition-colors ml-1";
|
|
971
|
+
copyBtn.title = "Copy path";
|
|
972
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
973
|
+
copyBtn.onclick = async (e) => {
|
|
974
|
+
e.stopPropagation();
|
|
975
|
+
try {
|
|
976
|
+
await navigator.clipboard.writeText(`.playwright-mcp/${entry.name}`);
|
|
977
|
+
copyBtn.innerHTML = `<i data-lucide="check" class="w-3 h-3 text-green-400"></i>`;
|
|
978
|
+
refreshIcons();
|
|
979
|
+
setTimeout(() => {
|
|
980
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
981
|
+
refreshIcons();
|
|
982
|
+
}, 1500);
|
|
983
|
+
} catch {
|
|
984
|
+
setScreenshotsStatus("Copy failed — clipboard access denied.", true);
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
footer.appendChild(copyBtn);
|
|
988
|
+
|
|
989
|
+
const deleteBtn = document.createElement("button");
|
|
990
|
+
deleteBtn.type = "button";
|
|
991
|
+
deleteBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-red-400 rounded transition-colors";
|
|
992
|
+
deleteBtn.title = "Delete image";
|
|
993
|
+
deleteBtn.innerHTML = `<i data-lucide="trash-2" class="w-3 h-3"></i>`;
|
|
994
|
+
deleteBtn.onclick = async (e) => {
|
|
995
|
+
e.stopPropagation();
|
|
996
|
+
const confirmed = window.confirm(`Delete .playwright-mcp/${entry.name}?`);
|
|
997
|
+
if (!confirmed) return;
|
|
998
|
+
try {
|
|
999
|
+
await deleteScreenshotImage(entry.name);
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
setScreenshotsStatus(toErrorMessage(error), true);
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
footer.appendChild(deleteBtn);
|
|
1005
|
+
|
|
1006
|
+
container.appendChild(imgBtn);
|
|
1007
|
+
container.appendChild(footer);
|
|
1008
|
+
screenshotsGrid.appendChild(container);
|
|
1009
|
+
}
|
|
1010
|
+
refreshIcons();
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function loadScreenshots(options = {}) {
|
|
1014
|
+
const preferCache = options.preferCache !== false;
|
|
1015
|
+
const requestId = ++state.screenshotsRequestId;
|
|
1016
|
+
if (state.screenshotsAbortController) state.screenshotsAbortController.abort();
|
|
1017
|
+
const controller = new AbortController();
|
|
1018
|
+
state.screenshotsAbortController = controller;
|
|
1019
|
+
const cachedEntries = preferCache ? readCachedValue(CACHE_KEYS.screenshotsEntries) : null;
|
|
1020
|
+
|
|
1021
|
+
try {
|
|
1022
|
+
setScreenshotsStatus("Loading...");
|
|
1023
|
+
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
1024
|
+
state.screenshotEntries = cachedEntries.entries;
|
|
1025
|
+
tabBadgeScreenshots.textContent = cachedEntries.entries.length ? `${cachedEntries.entries.length}` : "";
|
|
1026
|
+
renderScreenshots(cachedEntries.entries);
|
|
1027
|
+
setScreenshotsStatus(`${cachedEntries.entries.length} cached screenshot(s) (refreshing...)`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const res = await fetch("/api/screenshots/list", { signal: controller.signal });
|
|
1031
|
+
let data;
|
|
1032
|
+
try { data = await readJsonResponse(res); } catch (error) {
|
|
1033
|
+
if (isRouteMissingResponse(res, error)) {
|
|
1034
|
+
// Server doesn't have screenshots endpoint yet — show empty
|
|
1035
|
+
if (requestId !== state.screenshotsRequestId) return;
|
|
1036
|
+
tabBadgeScreenshots.textContent = "";
|
|
1037
|
+
renderScreenshots([]);
|
|
1038
|
+
setScreenshotsStatus("Screenshots API not available");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
throw error;
|
|
1042
|
+
}
|
|
1043
|
+
if (!res.ok) throw new Error(data.error || "Unable to load screenshots");
|
|
1044
|
+
if (requestId !== state.screenshotsRequestId) return;
|
|
1045
|
+
|
|
1046
|
+
const entries = data.entries || [];
|
|
1047
|
+
state.screenshotEntries = entries;
|
|
1048
|
+
writeCachedValue(CACHE_KEYS.screenshotsEntries, { entries });
|
|
1049
|
+
tabBadgeScreenshots.textContent = entries.length ? `${entries.length}` : "";
|
|
1050
|
+
renderScreenshots(entries);
|
|
1051
|
+
setScreenshotsStatus(`${entries.length} screenshot(s)`);
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if (isAbortError(error)) return;
|
|
1054
|
+
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
1055
|
+
setScreenshotsStatus(`Using cached screenshots: ${toErrorMessage(error)}`, true);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
setScreenshotsStatus(toErrorMessage(error), true);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function deleteClipboardImage(name) {
|
|
1063
|
+
setClipboardStatus(`Deleting ${name}...`);
|
|
1064
|
+
const response = await fetch(`/api/clipboard/file?name=${encodeURIComponent(name)}`, {
|
|
1065
|
+
method: "DELETE",
|
|
1066
|
+
});
|
|
1067
|
+
const data = await readJsonResponse(response);
|
|
1068
|
+
if (!response.ok) {
|
|
1069
|
+
throw new Error(data.error || "Delete failed");
|
|
1070
|
+
}
|
|
1071
|
+
clearCachedValue(CACHE_KEYS.clipboardEntries);
|
|
1072
|
+
await loadClipboardImages({ preferCache: false });
|
|
1073
|
+
setClipboardStatus(`Deleted ${name}.`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function deleteScreenshotImage(name) {
|
|
1077
|
+
setScreenshotsStatus(`Deleting ${name}...`);
|
|
1078
|
+
const response = await fetch(`/api/screenshots/file?name=${encodeURIComponent(name)}`, {
|
|
1079
|
+
method: "DELETE",
|
|
1080
|
+
});
|
|
1081
|
+
const data = await readJsonResponse(response);
|
|
1082
|
+
if (!response.ok) {
|
|
1083
|
+
throw new Error(data.error || "Delete failed");
|
|
1084
|
+
}
|
|
1085
|
+
clearCachedValue(CACHE_KEYS.screenshotsEntries);
|
|
1086
|
+
await loadScreenshots({ preferCache: false });
|
|
1087
|
+
setScreenshotsStatus(`Deleted ${name}.`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ---------------------------------------------------------------------------
|
|
1091
|
+
// Upload logic
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
function normalizeSuggestedFilename(originalName) {
|
|
1094
|
+
const trimmed = (originalName || "").trim();
|
|
1095
|
+
const hasDot = trimmed.lastIndexOf(".") > 0;
|
|
1096
|
+
const extension = hasDot ? trimmed.slice(trimmed.lastIndexOf(".")).toLowerCase() : "";
|
|
1097
|
+
const safeExtension = ALLOWED_UPLOAD_EXTENSIONS.has(extension) ? extension : ".png";
|
|
1098
|
+
const rawStem = hasDot ? trimmed.slice(0, trimmed.lastIndexOf(".")) : trimmed;
|
|
1099
|
+
const safeStem = rawStem.replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
1100
|
+
return `${safeStem || "image"}${safeExtension}`;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function extensionFromMimeType(mimeType) { return IMAGE_MIME_TO_EXTENSION[mimeType] || ".png"; }
|
|
1104
|
+
|
|
1105
|
+
function ensureClipboardFileHasName(file) {
|
|
1106
|
+
if (file.name && file.name.trim()) return file;
|
|
1107
|
+
const ext = extensionFromMimeType(file.type);
|
|
1108
|
+
return new File([file], `clipboard-${Date.now()}${ext}`, { type: file.type || "image/png" });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function setPendingUploadFile(file, sourceLabel) {
|
|
1112
|
+
const namedFile = ensureClipboardFileHasName(file);
|
|
1113
|
+
state.pendingUploadFile = namedFile;
|
|
1114
|
+
if (!uploadNameInput.value.trim()) {
|
|
1115
|
+
uploadNameInput.value = normalizeSuggestedFilename(namedFile.name);
|
|
1116
|
+
}
|
|
1117
|
+
setClipboardStatus(`Selected ${namedFile.name} from ${sourceLabel}. Set filename, then tap Upload.`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function validateUploadFilename(fileName) {
|
|
1121
|
+
if (!fileName) return "Filename is required.";
|
|
1122
|
+
if (/\s/.test(fileName)) return "No spaces allowed.";
|
|
1123
|
+
if (fileName === "." || fileName === ".." || fileName.startsWith(".")) return "Invalid filename.";
|
|
1124
|
+
if (!/^[A-Za-z0-9._-]+$/.test(fileName)) return "Use letters, numbers, dot, underscore, dash only.";
|
|
1125
|
+
const ext = fileName.slice(fileName.lastIndexOf(".")).toLowerCase();
|
|
1126
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) return "Unsupported image extension.";
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function handleUploadSelection() {
|
|
1131
|
+
const selectedFile = uploadInput.files?.[0];
|
|
1132
|
+
if (!selectedFile) { state.pendingUploadFile = null; return; }
|
|
1133
|
+
setPendingUploadFile(selectedFile, "file picker");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function pasteImageFromClipboard() {
|
|
1137
|
+
if (!navigator.clipboard || typeof navigator.clipboard.read !== "function") {
|
|
1138
|
+
throw new Error("Clipboard paste not supported in this browser.");
|
|
1139
|
+
}
|
|
1140
|
+
const items = await navigator.clipboard.read();
|
|
1141
|
+
for (const item of items) {
|
|
1142
|
+
const imageType = item.types.find((t) => t.startsWith("image/"));
|
|
1143
|
+
if (!imageType) continue;
|
|
1144
|
+
const blob = await item.getType(imageType);
|
|
1145
|
+
const ext = extensionFromMimeType(imageType);
|
|
1146
|
+
const file = new File([blob], `clipboard-${Date.now()}${ext}`, { type: imageType });
|
|
1147
|
+
setPendingUploadFile(file, "clipboard");
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
throw new Error("Clipboard does not contain an image.");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function uploadPendingImage() {
|
|
1154
|
+
if (!state.pendingUploadFile) throw new Error("Choose an image first.");
|
|
1155
|
+
const requestedName = uploadNameInput.value.trim();
|
|
1156
|
+
const filenameError = validateUploadFilename(requestedName);
|
|
1157
|
+
if (filenameError) throw new Error(filenameError);
|
|
1158
|
+
|
|
1159
|
+
setStatus(`Uploading ${requestedName}...`);
|
|
1160
|
+
const createBody = () => {
|
|
1161
|
+
const fd = new FormData();
|
|
1162
|
+
fd.append("file", state.pendingUploadFile);
|
|
1163
|
+
return fd;
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
let data;
|
|
1167
|
+
try {
|
|
1168
|
+
const primaryRes = await fetch(`/api/clipboard/upload?name=${encodeURIComponent(requestedName)}`, { method: "POST", body: createBody() });
|
|
1169
|
+
try { data = await readJsonResponse(primaryRes); } catch (error) {
|
|
1170
|
+
if (isRouteMissingResponse(primaryRes, error)) throw new Error("Clipboard API not available");
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
if (!primaryRes.ok) throw new Error(data.error || "Upload failed");
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (toErrorMessage(error) !== "Clipboard API not available") throw error;
|
|
1176
|
+
const legacyRes = await fetch(`/api/upload?name=${encodeURIComponent(requestedName)}`, { method: "POST", body: createBody() });
|
|
1177
|
+
data = await readJsonResponse(legacyRes);
|
|
1178
|
+
if (!legacyRes.ok) throw new Error(data.error || "Upload failed");
|
|
1179
|
+
state.clipboardApiMode = "legacy";
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
setClipboardStatus(`Uploaded ${requestedName}.`);
|
|
1183
|
+
state.pendingUploadFile = null;
|
|
1184
|
+
uploadInput.value = "";
|
|
1185
|
+
uploadNameInput.value = "";
|
|
1186
|
+
clearCachedValue(CACHE_KEYS.clipboardEntries);
|
|
1187
|
+
await loadClipboardImages({ preferCache: false });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ---------------------------------------------------------------------------
|
|
1191
|
+
// Error handlers
|
|
1192
|
+
// ---------------------------------------------------------------------------
|
|
1193
|
+
function handleActionError(error) {
|
|
1194
|
+
if (isAbortError(error)) return;
|
|
1195
|
+
setStatus(toErrorMessage(error), true);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function handleUploadError(error) {
|
|
1199
|
+
if (isAbortError(error)) return;
|
|
1200
|
+
const msg = toErrorMessage(error);
|
|
1201
|
+
setClipboardStatus(msg, true);
|
|
1202
|
+
setStatus(msg, true);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// ---------------------------------------------------------------------------
|
|
1206
|
+
// Event bindings
|
|
1207
|
+
// ---------------------------------------------------------------------------
|
|
1208
|
+
uploadInput.addEventListener("change", handleUploadSelection);
|
|
1209
|
+
pasteClipboardBtn.onclick = () => pasteImageFromClipboard().catch(handleUploadError);
|
|
1210
|
+
uploadBtn.onclick = () => uploadPendingImage().catch(handleUploadError);
|
|
1211
|
+
clipboardRefreshBtn.onclick = () => {
|
|
1212
|
+
clearCachedValue(CACHE_KEYS.clipboardEntries);
|
|
1213
|
+
loadClipboardImages({ preferCache: false }).catch(handleActionError);
|
|
1214
|
+
};
|
|
1215
|
+
treeRefreshBtn.onclick = () => {
|
|
1216
|
+
clearCachedValue(CACHE_KEYS.topLevel);
|
|
1217
|
+
clearCachedValue(CACHE_KEYS.searchIndex);
|
|
1218
|
+
state.dirChildren.clear();
|
|
1219
|
+
state.expandedPaths.clear();
|
|
1220
|
+
loadTopLevel({ preferCache: false }).catch(handleActionError);
|
|
1221
|
+
loadSearchIndex({ preferCache: false });
|
|
1222
|
+
};
|
|
1223
|
+
screenshotsRefreshBtn.onclick = () => {
|
|
1224
|
+
clearCachedValue(CACHE_KEYS.screenshotsEntries);
|
|
1225
|
+
loadScreenshots({ preferCache: false }).catch(handleActionError);
|
|
1226
|
+
};
|
|
1227
|
+
viewerRefreshBtn.onclick = () => {
|
|
1228
|
+
if (state.selectedPath) openFile(state.selectedPath).catch(handleActionError);
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
// ---------------------------------------------------------------------------
|
|
1232
|
+
// Init
|
|
1233
|
+
// ---------------------------------------------------------------------------
|
|
1234
|
+
Promise.all([loadClipboardImages(), loadTopLevel(), loadScreenshots()]).then(() => {
|
|
1235
|
+
// Restore last opened file after tree is loaded
|
|
1236
|
+
const lastFile = localStorage.getItem("workspace-file");
|
|
1237
|
+
if (lastFile) openFile(lastFile).catch(handleActionError);
|
|
1238
|
+
}).catch(handleActionError);
|
|
1239
|
+
// Load search index in background — not blocking initial render
|
|
1240
|
+
loadSearchIndex();
|