@haowjy/remote-workspace 0.1.3 → 0.1.4
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/README.md +12 -7
- package/dist/launcher.js +62 -0
- package/dist/server.js +117 -110
- package/package.json +1 -1
- package/static/app.js +169 -179
- package/static/index.html +0 -18
package/README.md
CHANGED
|
@@ -6,10 +6,12 @@ Mobile-friendly read/upload web workspace for this repository.
|
|
|
6
6
|
|
|
7
7
|
- Browse repository folders/files
|
|
8
8
|
- Upload images into `.clipboard/` at repo root (single image per upload)
|
|
9
|
-
- Delete images from `.clipboard/`
|
|
9
|
+
- Delete images from `.clipboard/` from the UI
|
|
10
|
+
- Open any folder in the Files tab to view all images in that folder as a grid
|
|
10
11
|
- Preview text files and images
|
|
11
12
|
- Render Markdown with Mermaid diagrams
|
|
12
13
|
- Hide and block access to configured path segments (always hides `.git`)
|
|
14
|
+
- Allow specific gitignored/hidden image folders (for example `.playwright-mcp`) via config
|
|
13
15
|
- Dedicated collapsible `.clipboard` panel for upload + quick image viewing
|
|
14
16
|
|
|
15
17
|
This app is intentionally **no text editing** to keep remote access simple and lower risk.
|
|
@@ -63,6 +65,7 @@ cd /path/to/your/repo && pnpm dev
|
|
|
63
65
|
pnpm dev -- config /path/to/config
|
|
64
66
|
pnpm dev -- port 18111
|
|
65
67
|
pnpm dev -- always-hidden .git,.env,.secrets
|
|
68
|
+
pnpm dev -- image-dirs .clipboard,.playwright-mcp
|
|
66
69
|
pnpm dev -- install
|
|
67
70
|
pnpm dev -- no-serve
|
|
68
71
|
pnpm dev -- password your-password
|
|
@@ -78,6 +81,7 @@ pnpm dev -- password your-password --funnel
|
|
|
78
81
|
- `REMOTE_WS_PASSWORD` (optional, enables HTTP Basic Auth when set)
|
|
79
82
|
- `REMOTE_WS_CONFIG_FILE` (optional config file path override)
|
|
80
83
|
- `REMOTE_WS_ALWAYS_HIDDEN` (optional comma-separated extra hidden path segments)
|
|
84
|
+
- `REMOTE_WS_IMAGE_DIRS` (optional comma-separated repo-relative folders to expose for image browsing; default `.clipboard`)
|
|
81
85
|
- `REPO_ROOT` (injected by launcher script)
|
|
82
86
|
|
|
83
87
|
Password config file format (default: repo root `.remote-workspace.conf`):
|
|
@@ -106,6 +110,7 @@ Config file selection precedence:
|
|
|
106
110
|
- `.clipboard` panel uses dedicated clipboard endpoints (`/api/clipboard/upload`, `/api/clipboard/list`, `/api/clipboard/file`)
|
|
107
111
|
- Main repository browser blocks always-hidden path segments (`.git` + optional configured segments)
|
|
108
112
|
- Gitignored paths are hidden/blocked (for example `node_modules/`, build artifacts, local secrets)
|
|
113
|
+
- `REMOTE_WS_IMAGE_DIRS` / `--image-dirs` lets you allow specific hidden/gitignored folders in Files view (for example `.playwright-mcp`)
|
|
109
114
|
- Accepted upload types are images only (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `heic`, `heif`, `avif`)
|
|
110
115
|
- Clipboard panel supports both file picker and `Paste From Clipboard` button (when browser clipboard image API is available)
|
|
111
116
|
- Upload requires `name` query parameter (filename is user-controlled)
|
|
@@ -113,16 +118,16 @@ Config file selection precedence:
|
|
|
113
118
|
- Multipart field names accepted: `file` (current UI) and `files` (legacy cached UI compatibility)
|
|
114
119
|
- Legacy alias: `/api/upload` is still accepted for older cached clients
|
|
115
120
|
|
|
116
|
-
##
|
|
121
|
+
## Folder Image Gallery
|
|
117
122
|
|
|
118
|
-
-
|
|
119
|
-
- `GET /api/
|
|
120
|
-
-
|
|
123
|
+
- Selecting a directory in Files renders a gallery of image files directly inside that folder.
|
|
124
|
+
- Gallery images stream through `GET /api/file?path=<repo-relative-path>`.
|
|
125
|
+
- This works for normal folders and for configured `REMOTE_WS_IMAGE_DIRS` folders.
|
|
121
126
|
|
|
122
127
|
## Caching
|
|
123
128
|
|
|
124
|
-
- The browser now caches image bytes (`/api/clipboard/file`,
|
|
125
|
-
- The client keeps a small local metadata cache (tree + clipboard
|
|
129
|
+
- The browser now caches image bytes (`/api/clipboard/file`, image responses from `/api/file`) with short-lived cache headers and validators.
|
|
130
|
+
- The client keeps a small local metadata cache (tree + clipboard lists) and hydrates immediately on reload, then refreshes in the background.
|
|
126
131
|
- Refresh buttons bypass local metadata cache and force a new server fetch.
|
|
127
132
|
|
|
128
133
|
## Tailscale
|
package/dist/launcher.js
CHANGED
|
@@ -15,6 +15,7 @@ Options:
|
|
|
15
15
|
--config <path> Config file path (default uses precedence search)
|
|
16
16
|
--port <port> Listen port (default: REMOTE_WS_PORT or 18080)
|
|
17
17
|
--always-hidden <csv> Extra always-hidden path segments (comma-separated)
|
|
18
|
+
--image-dirs <csv> Visible image folders (repo-relative, comma-separated)
|
|
18
19
|
--install Force dependency install before start
|
|
19
20
|
--skip-install Skip install check even if node_modules is missing
|
|
20
21
|
--no-serve Skip tailscale serve setup
|
|
@@ -29,6 +30,7 @@ Examples:
|
|
|
29
30
|
pnpm dev -- --config ~/.config/remote-workspace/config
|
|
30
31
|
pnpm dev -- --port 18111
|
|
31
32
|
pnpm dev -- --always-hidden .git,.env,.secrets
|
|
33
|
+
pnpm dev -- --image-dirs .clipboard,.playwright-mcp
|
|
32
34
|
pnpm dev -- --password
|
|
33
35
|
pnpm dev -- --password mypass --serve
|
|
34
36
|
pnpm dev -- --password mypass --funnel
|
|
@@ -66,6 +68,43 @@ function parseAlwaysHiddenCsv(rawValue, sourceLabel) {
|
|
|
66
68
|
}
|
|
67
69
|
return Array.from(normalizedSegments).join(",");
|
|
68
70
|
}
|
|
71
|
+
function normalizeRepoRelativePath(rawValue, sourceLabel) {
|
|
72
|
+
const trimmed = rawValue.trim();
|
|
73
|
+
if (!trimmed) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
if (trimmed.includes("\u0000")) {
|
|
77
|
+
fail(`Invalid ${sourceLabel} path "${rawValue}": path must not contain null bytes.`);
|
|
78
|
+
}
|
|
79
|
+
const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
80
|
+
if (!normalized || normalized === ".") {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
84
|
+
fail(`Invalid ${sourceLabel} path "${rawValue}": path must stay inside repository.`);
|
|
85
|
+
}
|
|
86
|
+
if (path.posix.isAbsolute(normalized)) {
|
|
87
|
+
fail(`Invalid ${sourceLabel} path "${rawValue}": path must be repo-relative.`);
|
|
88
|
+
}
|
|
89
|
+
if (normalized.split("/").includes(".git")) {
|
|
90
|
+
fail(`Invalid ${sourceLabel} path "${rawValue}": .git cannot be exposed.`);
|
|
91
|
+
}
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
function parseImageDirsCsv(rawValue, sourceLabel) {
|
|
95
|
+
const normalizedPaths = new Set();
|
|
96
|
+
for (const rawPath of rawValue.split(",")) {
|
|
97
|
+
const normalizedPath = normalizeRepoRelativePath(rawPath, sourceLabel);
|
|
98
|
+
if (!normalizedPath) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
normalizedPaths.add(normalizedPath);
|
|
102
|
+
}
|
|
103
|
+
if (normalizedPaths.size === 0) {
|
|
104
|
+
fail(`Invalid ${sourceLabel} value: provide at least one repo-relative path.`);
|
|
105
|
+
}
|
|
106
|
+
return Array.from(normalizedPaths).join(",");
|
|
107
|
+
}
|
|
69
108
|
function parseConfigPassword(configFilePath) {
|
|
70
109
|
if (!existsSync(configFilePath)) {
|
|
71
110
|
return "";
|
|
@@ -120,6 +159,7 @@ function parseArgs(argv) {
|
|
|
120
159
|
let configFileFromArg = null;
|
|
121
160
|
let configFile = "";
|
|
122
161
|
let alwaysHidden = null;
|
|
162
|
+
let imageDirs = null;
|
|
123
163
|
for (let i = 0; i < argv.length; i += 1) {
|
|
124
164
|
const arg = argv[i];
|
|
125
165
|
switch (arg) {
|
|
@@ -159,6 +199,15 @@ function parseArgs(argv) {
|
|
|
159
199
|
i += 1;
|
|
160
200
|
break;
|
|
161
201
|
}
|
|
202
|
+
case "--image-dirs": {
|
|
203
|
+
const next = argv[i + 1];
|
|
204
|
+
if (!next || next.startsWith("--")) {
|
|
205
|
+
fail("Missing value for --image-dirs.");
|
|
206
|
+
}
|
|
207
|
+
imageDirs = parseImageDirsCsv(next, "--image-dirs");
|
|
208
|
+
i += 1;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
162
211
|
case "--install":
|
|
163
212
|
forceInstall = true;
|
|
164
213
|
break;
|
|
@@ -259,6 +308,7 @@ function parseArgs(argv) {
|
|
|
259
308
|
workspacePassword,
|
|
260
309
|
configFile,
|
|
261
310
|
alwaysHidden,
|
|
311
|
+
imageDirs,
|
|
262
312
|
};
|
|
263
313
|
}
|
|
264
314
|
function hasCommand(commandName) {
|
|
@@ -330,6 +380,15 @@ function main() {
|
|
|
330
380
|
else {
|
|
331
381
|
console.log("Always hidden segments: .git");
|
|
332
382
|
}
|
|
383
|
+
if (args.imageDirs !== null) {
|
|
384
|
+
console.log(`Visible image folders: ${args.imageDirs}`);
|
|
385
|
+
}
|
|
386
|
+
else if (process.env.REMOTE_WS_IMAGE_DIRS) {
|
|
387
|
+
console.log(`Visible image folders: ${process.env.REMOTE_WS_IMAGE_DIRS}`);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
console.log("Visible image folders: .clipboard");
|
|
391
|
+
}
|
|
333
392
|
console.log("");
|
|
334
393
|
const childEnv = {
|
|
335
394
|
...process.env,
|
|
@@ -340,6 +399,9 @@ function main() {
|
|
|
340
399
|
if (args.alwaysHidden !== null) {
|
|
341
400
|
childEnv.REMOTE_WS_ALWAYS_HIDDEN = args.alwaysHidden;
|
|
342
401
|
}
|
|
402
|
+
if (args.imageDirs !== null) {
|
|
403
|
+
childEnv.REMOTE_WS_IMAGE_DIRS = args.imageDirs;
|
|
404
|
+
}
|
|
343
405
|
const child = runBuiltServer
|
|
344
406
|
? spawn("node", [builtServerPath], {
|
|
345
407
|
stdio: "inherit",
|
package/dist/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { pipeline } from "node:stream/promises";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
11
|
import { lookup as mimeLookup } from "mime-types";
|
|
12
|
+
const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
|
|
12
13
|
function parseIntegerFromEnv(envName, fallbackValue, options) {
|
|
13
14
|
const rawValue = process.env[envName];
|
|
14
15
|
const parsedValue = Number.parseInt(rawValue ?? String(fallbackValue), 10);
|
|
@@ -28,6 +29,29 @@ function assertValidHiddenSegment(segment, sourceName) {
|
|
|
28
29
|
throw new Error(`Invalid ${sourceName} segment "${segment}": segments must not contain path separators or null bytes`);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
function normalizeRepoRelativeDirectoryPath(rawPath, sourceName) {
|
|
33
|
+
const trimmed = rawPath.trim();
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
if (trimmed.includes("\u0000")) {
|
|
38
|
+
throw new Error(`Invalid ${sourceName} path "${rawPath}": path must not contain null bytes`);
|
|
39
|
+
}
|
|
40
|
+
const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
41
|
+
if (!normalized || normalized === ".") {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
45
|
+
throw new Error(`Invalid ${sourceName} path "${rawPath}": path must stay inside repository`);
|
|
46
|
+
}
|
|
47
|
+
if (path.posix.isAbsolute(normalized)) {
|
|
48
|
+
throw new Error(`Invalid ${sourceName} path "${rawPath}": path must be repo-relative`);
|
|
49
|
+
}
|
|
50
|
+
if (normalized.split("/").includes(".git")) {
|
|
51
|
+
throw new Error(`Invalid ${sourceName} path "${rawPath}": .git cannot be exposed`);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
31
55
|
function parseAlwaysHiddenSegments(rawValue) {
|
|
32
56
|
const segments = new Set([".git"]);
|
|
33
57
|
if (rawValue === undefined) {
|
|
@@ -43,6 +67,21 @@ function parseAlwaysHiddenSegments(rawValue) {
|
|
|
43
67
|
}
|
|
44
68
|
return segments;
|
|
45
69
|
}
|
|
70
|
+
function parseImageDirectoryPaths(rawValue) {
|
|
71
|
+
const parsed = new Set();
|
|
72
|
+
const candidates = rawValue === undefined ? [CLIPBOARD_DIRECTORY_NAME] : rawValue.split(",");
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
const normalizedPath = normalizeRepoRelativeDirectoryPath(candidate, "REMOTE_WS_IMAGE_DIRS");
|
|
75
|
+
if (!normalizedPath) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
parsed.add(normalizedPath);
|
|
79
|
+
}
|
|
80
|
+
if (parsed.size === 0) {
|
|
81
|
+
throw new Error("Invalid REMOTE_WS_IMAGE_DIRS: provide at least one repo-relative path");
|
|
82
|
+
}
|
|
83
|
+
return Array.from(parsed);
|
|
84
|
+
}
|
|
46
85
|
const REPO_ROOT = path.resolve(process.env.REPO_ROOT ?? process.cwd());
|
|
47
86
|
const HOST = "127.0.0.1";
|
|
48
87
|
const PORT = parseIntegerFromEnv("REMOTE_WS_PORT", 18080, { min: 1, max: 65535 });
|
|
@@ -55,10 +94,8 @@ const MAX_UPLOAD_BYTES = parseIntegerFromEnv("REMOTE_WS_MAX_UPLOAD_BYTES", 26_21
|
|
|
55
94
|
const MAX_TREE_ENTRIES = parseIntegerFromEnv("REMOTE_WS_MAX_TREE_ENTRIES", 5000, {
|
|
56
95
|
min: 1,
|
|
57
96
|
});
|
|
58
|
-
const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
|
|
59
97
|
const CLIPBOARD_DIRECTORY_PATH = path.resolve(REPO_ROOT, CLIPBOARD_DIRECTORY_NAME);
|
|
60
|
-
const
|
|
61
|
-
const SCREENSHOTS_DIRECTORY_PATH = path.resolve(REPO_ROOT, SCREENSHOTS_DIRECTORY_NAME);
|
|
98
|
+
const IMAGE_DIRECTORY_PATHS = parseImageDirectoryPaths(process.env.REMOTE_WS_IMAGE_DIRS);
|
|
62
99
|
const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
63
100
|
".png",
|
|
64
101
|
".jpg",
|
|
@@ -240,6 +277,16 @@ function toRepoRelativePath(absPath) {
|
|
|
240
277
|
}
|
|
241
278
|
return relative.split(path.sep).join("/");
|
|
242
279
|
}
|
|
280
|
+
function pathStartsWithDirectory(repoRelativePath, repoRelativeDirectoryPath) {
|
|
281
|
+
return (repoRelativePath === repoRelativeDirectoryPath ||
|
|
282
|
+
repoRelativePath.startsWith(`${repoRelativeDirectoryPath}/`));
|
|
283
|
+
}
|
|
284
|
+
function isConfiguredImageDirectoryPath(repoRelativePath) {
|
|
285
|
+
if (!repoRelativePath) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return IMAGE_DIRECTORY_PATHS.some((directoryPath) => pathStartsWithDirectory(repoRelativePath, directoryPath));
|
|
289
|
+
}
|
|
243
290
|
function isHiddenRepoRelativePath(repoRelativePath) {
|
|
244
291
|
if (!repoRelativePath) {
|
|
245
292
|
return false;
|
|
@@ -249,7 +296,8 @@ function isHiddenRepoRelativePath(repoRelativePath) {
|
|
|
249
296
|
.some((segment) => ALWAYS_HIDDEN_SEGMENTS.has(segment));
|
|
250
297
|
}
|
|
251
298
|
function isBlockedHiddenRepoRelativePath(repoRelativePath) {
|
|
252
|
-
return isHiddenRepoRelativePath(repoRelativePath)
|
|
299
|
+
return (isHiddenRepoRelativePath(repoRelativePath) &&
|
|
300
|
+
!isConfiguredImageDirectoryPath(repoRelativePath));
|
|
253
301
|
}
|
|
254
302
|
function parseGitIgnoredStdout(stdout) {
|
|
255
303
|
if (!stdout) {
|
|
@@ -280,9 +328,11 @@ async function assertPathAccessible(absPath, options) {
|
|
|
280
328
|
throw new Error("Hidden paths are not accessible");
|
|
281
329
|
}
|
|
282
330
|
if (!options?.allowGitIgnored && repoRelativePath) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
331
|
+
if (!isConfiguredImageDirectoryPath(repoRelativePath)) {
|
|
332
|
+
const ignoredPathSet = await getGitIgnoredPathSet([repoRelativePath]);
|
|
333
|
+
if (ignoredPathSet.has(repoRelativePath)) {
|
|
334
|
+
throw new Error("Gitignored paths are not accessible");
|
|
335
|
+
}
|
|
286
336
|
}
|
|
287
337
|
}
|
|
288
338
|
}
|
|
@@ -537,15 +587,69 @@ function sortTree(node) {
|
|
|
537
587
|
sortTree(child);
|
|
538
588
|
}
|
|
539
589
|
}
|
|
590
|
+
async function collectConfiguredImageDirectoryFiles(maxFiles) {
|
|
591
|
+
const collectedPaths = [];
|
|
592
|
+
for (const repoRelativeDirectoryPath of IMAGE_DIRECTORY_PATHS) {
|
|
593
|
+
if (collectedPaths.length >= maxFiles) {
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
const absoluteDirectoryPath = resolveRepoPath(repoRelativeDirectoryPath);
|
|
597
|
+
let stats;
|
|
598
|
+
try {
|
|
599
|
+
stats = await fs.stat(absoluteDirectoryPath);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (!stats.isDirectory()) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const stack = [absoluteDirectoryPath];
|
|
608
|
+
while (stack.length > 0 && collectedPaths.length < maxFiles) {
|
|
609
|
+
const currentDirectoryPath = stack.pop();
|
|
610
|
+
if (!currentDirectoryPath) {
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
let dirEntries;
|
|
614
|
+
try {
|
|
615
|
+
dirEntries = await fs.readdir(currentDirectoryPath, {
|
|
616
|
+
withFileTypes: true,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
for (const dirEntry of dirEntries) {
|
|
623
|
+
if (dirEntry.isSymbolicLink()) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const childPath = path.join(currentDirectoryPath, dirEntry.name);
|
|
627
|
+
if (dirEntry.isDirectory()) {
|
|
628
|
+
stack.push(childPath);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (!dirEntry.isFile()) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
collectedPaths.push(toRepoRelativePath(childPath));
|
|
635
|
+
if (collectedPaths.length >= maxFiles) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return collectedPaths;
|
|
642
|
+
}
|
|
540
643
|
app.get("/api/tree", async (_req, res) => {
|
|
541
644
|
try {
|
|
542
645
|
const { stdout } = await execFileAsync("git", ["-C", REPO_ROOT, "ls-files", "--cached", "--others", "--exclude-standard"], { maxBuffer: 10 * 1024 * 1024 });
|
|
543
|
-
const
|
|
646
|
+
const gitPaths = String(stdout)
|
|
544
647
|
.split(/\r?\n/)
|
|
545
648
|
.map((line) => line.trim())
|
|
546
649
|
.filter(Boolean);
|
|
547
|
-
|
|
548
|
-
const
|
|
650
|
+
const extraVisiblePaths = await collectConfiguredImageDirectoryFiles(MAX_TREE_ENTRIES * 4);
|
|
651
|
+
const allPaths = Array.from(new Set([...gitPaths, ...extraVisiblePaths]));
|
|
652
|
+
const visiblePaths = allPaths.filter((p) => !isBlockedHiddenRepoRelativePath(p));
|
|
549
653
|
const truncated = visiblePaths.length > MAX_TREE_ENTRIES;
|
|
550
654
|
const paths = truncated ? visiblePaths.slice(0, MAX_TREE_ENTRIES) : visiblePaths;
|
|
551
655
|
const root = buildTreeFromPaths(paths);
|
|
@@ -596,7 +700,8 @@ app.get("/api/list", async (req, res) => {
|
|
|
596
700
|
}
|
|
597
701
|
const ignoredPathSet = await getGitIgnoredPathSet(candidates.map((candidate) => candidate.childRepoRelativePath));
|
|
598
702
|
for (const candidate of candidates) {
|
|
599
|
-
if (ignoredPathSet.has(candidate.childRepoRelativePath)
|
|
703
|
+
if (ignoredPathSet.has(candidate.childRepoRelativePath) &&
|
|
704
|
+
!isConfiguredImageDirectoryPath(candidate.childRepoRelativePath)) {
|
|
600
705
|
skippedIgnored += 1;
|
|
601
706
|
continue;
|
|
602
707
|
}
|
|
@@ -738,105 +843,6 @@ app.delete("/api/clipboard/file", async (req, res) => {
|
|
|
738
843
|
res.status(400).json({ error: message });
|
|
739
844
|
}
|
|
740
845
|
});
|
|
741
|
-
app.get("/api/screenshots/list", async (_req, res) => {
|
|
742
|
-
try {
|
|
743
|
-
const dirEntries = await fs.readdir(SCREENSHOTS_DIRECTORY_PATH, {
|
|
744
|
-
withFileTypes: true,
|
|
745
|
-
}).catch(() => []);
|
|
746
|
-
const entries = [];
|
|
747
|
-
for (const dirEntry of dirEntries) {
|
|
748
|
-
if (!dirEntry.isFile())
|
|
749
|
-
continue;
|
|
750
|
-
const extension = path.extname(dirEntry.name).toLowerCase();
|
|
751
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension))
|
|
752
|
-
continue;
|
|
753
|
-
const absPath = path.join(SCREENSHOTS_DIRECTORY_PATH, dirEntry.name);
|
|
754
|
-
let stats;
|
|
755
|
-
try {
|
|
756
|
-
stats = await fs.stat(absPath);
|
|
757
|
-
}
|
|
758
|
-
catch {
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
entries.push({
|
|
762
|
-
name: dirEntry.name,
|
|
763
|
-
path: `${SCREENSHOTS_DIRECTORY_NAME}/${dirEntry.name}`,
|
|
764
|
-
type: "file",
|
|
765
|
-
size: stats.size,
|
|
766
|
-
modifiedAt: stats.mtime.toISOString(),
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
entries.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
|
|
770
|
-
setMetadataCacheHeaders(res);
|
|
771
|
-
res.json({
|
|
772
|
-
directory: SCREENSHOTS_DIRECTORY_NAME,
|
|
773
|
-
entries,
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
catch (error) {
|
|
777
|
-
const message = error instanceof Error ? error.message : "Unable to list screenshots";
|
|
778
|
-
res.status(400).json({ error: message });
|
|
779
|
-
}
|
|
780
|
-
});
|
|
781
|
-
app.get("/api/screenshots/file", async (req, res) => {
|
|
782
|
-
try {
|
|
783
|
-
const requestedName = getSingleQueryValue(req.query.name);
|
|
784
|
-
if (!requestedName) {
|
|
785
|
-
res.status(400).json({ error: "Missing ?name=..." });
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
|
|
789
|
-
const stats = await fs.stat(absPath);
|
|
790
|
-
if (!stats.isFile()) {
|
|
791
|
-
res.status(400).json({ error: "Not a file" });
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
const realPath = await fs.realpath(absPath);
|
|
795
|
-
const relativeToDirectory = path.relative(SCREENSHOTS_DIRECTORY_PATH, realPath);
|
|
796
|
-
if (relativeToDirectory.startsWith("..") ||
|
|
797
|
-
path.isAbsolute(relativeToDirectory)) {
|
|
798
|
-
res.status(400).json({ error: "Path escapes target directory" });
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
if (setImageCacheHeaders(req, res, stats)) {
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
const mimeType = mimeLookup(absPath) || "application/octet-stream";
|
|
805
|
-
res.setHeader("Content-Type", mimeType);
|
|
806
|
-
res.setHeader("Content-Length", String(stats.size));
|
|
807
|
-
await pipeline(createReadStream(absPath), res);
|
|
808
|
-
}
|
|
809
|
-
catch (error) {
|
|
810
|
-
const message = error instanceof Error ? error.message : "Unable to stream screenshot";
|
|
811
|
-
if (!res.headersSent) {
|
|
812
|
-
res.status(400).json({ error: message });
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
res.destroy();
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
app.delete("/api/screenshots/file", async (req, res) => {
|
|
819
|
-
try {
|
|
820
|
-
const requestedName = getSingleQueryValue(req.query.name);
|
|
821
|
-
if (!requestedName) {
|
|
822
|
-
res.status(400).json({ error: "Missing ?name=..." });
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
|
|
826
|
-
await fs.unlink(absPath);
|
|
827
|
-
res.setHeader("Cache-Control", "no-store");
|
|
828
|
-
res.status(204).end();
|
|
829
|
-
}
|
|
830
|
-
catch (error) {
|
|
831
|
-
const nodeError = error;
|
|
832
|
-
if (nodeError.code === "ENOENT") {
|
|
833
|
-
res.status(404).json({ error: "File not found" });
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
const message = error instanceof Error ? error.message : "Unable to delete screenshot";
|
|
837
|
-
res.status(400).json({ error: message });
|
|
838
|
-
}
|
|
839
|
-
});
|
|
840
846
|
app.get("/api/text", async (req, res) => {
|
|
841
847
|
try {
|
|
842
848
|
const requestedPath = getSingleQueryValue(req.query.path);
|
|
@@ -930,6 +936,7 @@ app.use((error, _req, res, _next) => {
|
|
|
930
936
|
app.listen(PORT, HOST, () => {
|
|
931
937
|
console.log(`[remote-workspace] root: ${REPO_ROOT}`);
|
|
932
938
|
console.log(`[remote-workspace] http://${HOST}:${PORT}`);
|
|
939
|
+
console.log(`[remote-workspace] image directories: ${IMAGE_DIRECTORY_PATHS.join(", ")}`);
|
|
933
940
|
if (BASIC_AUTH_PASSWORD.length > 0) {
|
|
934
941
|
console.log("[remote-workspace] basic auth: enabled");
|
|
935
942
|
}
|
package/package.json
CHANGED
package/static/app.js
CHANGED
|
@@ -53,7 +53,6 @@ const CACHE_KEYS = {
|
|
|
53
53
|
topLevel: `${CACHE_PREFIX}top-level`,
|
|
54
54
|
searchIndex: `${CACHE_PREFIX}search-index`,
|
|
55
55
|
clipboardEntries: `${CACHE_PREFIX}clipboard-entries`,
|
|
56
|
-
screenshotsEntries: `${CACHE_PREFIX}screenshots-entries`,
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
// ---------------------------------------------------------------------------
|
|
@@ -73,16 +72,13 @@ const state = {
|
|
|
73
72
|
selectedPath: "",
|
|
74
73
|
fileRequestId: 0,
|
|
75
74
|
fileAbortController: null,
|
|
75
|
+
selectedPathType: "",
|
|
76
76
|
clipboardRequestId: 0,
|
|
77
77
|
clipboardAbortController: null,
|
|
78
78
|
clipboardApiMode: "modern",
|
|
79
79
|
pendingUploadFile: null,
|
|
80
80
|
searchDebounceTimer: null,
|
|
81
81
|
filesMobileMode: "explorer",
|
|
82
|
-
// Screenshots
|
|
83
|
-
screenshotEntries: [],
|
|
84
|
-
screenshotsRequestId: 0,
|
|
85
|
-
screenshotsAbortController: null,
|
|
86
82
|
};
|
|
87
83
|
|
|
88
84
|
// ---------------------------------------------------------------------------
|
|
@@ -106,9 +102,6 @@ const treeRefreshBtn = $("tree-refresh-btn");
|
|
|
106
102
|
const fileTreeContainer = $("file-tree-container");
|
|
107
103
|
const fileViewerContainer = $("file-viewer-container");
|
|
108
104
|
const mobileExplorerBackBtn = $("mobile-explorer-back-btn");
|
|
109
|
-
const screenshotsGrid = $("screenshots-grid");
|
|
110
|
-
const screenshotsStatusEl = $("screenshots-status");
|
|
111
|
-
const screenshotsRefreshBtn = $("screenshots-refresh-btn");
|
|
112
105
|
const viewerRefreshBtn = $("viewer-refresh-btn");
|
|
113
106
|
const lightbox = $("lightbox");
|
|
114
107
|
const lightboxPanel = $("lightbox-panel");
|
|
@@ -121,7 +114,6 @@ const hljsThemeLink = $("hljs-theme");
|
|
|
121
114
|
// Tab badges
|
|
122
115
|
const tabBadgeClipboard = $("tab-badge-clipboard");
|
|
123
116
|
const tabBadgeFiles = $("tab-badge-files");
|
|
124
|
-
const tabBadgeScreenshots = $("tab-badge-screenshots");
|
|
125
117
|
|
|
126
118
|
// ---------------------------------------------------------------------------
|
|
127
119
|
// Markdown + highlight.js integration
|
|
@@ -193,13 +185,6 @@ function setClipboardStatus(message, isError = false) {
|
|
|
193
185
|
: "shrink-0 px-4 py-1 text-[11px] text-ink-500 font-mono min-h-[1.4em]";
|
|
194
186
|
}
|
|
195
187
|
|
|
196
|
-
function setScreenshotsStatus(message, isError = false) {
|
|
197
|
-
screenshotsStatusEl.textContent = message;
|
|
198
|
-
screenshotsStatusEl.className = isError
|
|
199
|
-
? "shrink-0 px-4 py-1 text-[11px] text-red-400 font-mono min-h-[1.4em]"
|
|
200
|
-
: "shrink-0 px-4 py-1 text-[11px] text-ink-500 font-mono min-h-[1.4em]";
|
|
201
|
-
}
|
|
202
|
-
|
|
203
188
|
function refreshIcons() {
|
|
204
189
|
try { lucide.createIcons(); } catch { /* icons not ready yet */ }
|
|
205
190
|
}
|
|
@@ -348,12 +333,14 @@ function syncFilesLayout() {
|
|
|
348
333
|
// ---------------------------------------------------------------------------
|
|
349
334
|
const tabButtons = document.querySelectorAll(".tab-btn");
|
|
350
335
|
const tabPanels = document.querySelectorAll(".tab-panel");
|
|
336
|
+
const VALID_TABS = new Set(["files", "clipboard"]);
|
|
351
337
|
|
|
352
338
|
function switchTab(tabName) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
339
|
+
const nextTab = VALID_TABS.has(tabName) ? tabName : "files";
|
|
340
|
+
state.activeTab = nextTab;
|
|
341
|
+
localStorage.setItem("workspace-tab", nextTab);
|
|
342
|
+
tabPanels.forEach((p) => p.classList.toggle("hidden", p.id !== `panel-${nextTab}`));
|
|
343
|
+
tabButtons.forEach((b) => b.classList.toggle("active", b.dataset.tab === nextTab));
|
|
357
344
|
syncFilesLayout();
|
|
358
345
|
refreshIcons();
|
|
359
346
|
}
|
|
@@ -556,12 +543,12 @@ function renderTreeNode(entry, depth, container) {
|
|
|
556
543
|
const iconName = getFileIconName(entry.name, entry.type);
|
|
557
544
|
|
|
558
545
|
const row = document.createElement("div");
|
|
559
|
-
row.className = "tree-item";
|
|
546
|
+
row.className = "tree-item flex items-center gap-1";
|
|
560
547
|
row.style.setProperty("--depth", depth);
|
|
561
548
|
|
|
562
549
|
const btn = document.createElement("button");
|
|
563
550
|
btn.type = "button";
|
|
564
|
-
btn.className = `w-
|
|
551
|
+
btn.className = `flex-1 min-w-0 flex items-center gap-1.5 px-2 py-[3px] text-sm rounded-md transition-colors group ${
|
|
565
552
|
isActive ? "bg-brand/10 text-brand font-medium" : "text-ink-300 hover:bg-ink-800"
|
|
566
553
|
}`;
|
|
567
554
|
|
|
@@ -589,6 +576,22 @@ function renderTreeNode(entry, depth, container) {
|
|
|
589
576
|
};
|
|
590
577
|
|
|
591
578
|
row.appendChild(btn);
|
|
579
|
+
if (isDir) {
|
|
580
|
+
const openFolderBtn = document.createElement("button");
|
|
581
|
+
openFolderBtn.type = "button";
|
|
582
|
+
openFolderBtn.className = `shrink-0 p-1 rounded-md transition-colors ${
|
|
583
|
+
isActive
|
|
584
|
+
? "text-brand hover:bg-brand/10"
|
|
585
|
+
: "text-ink-500 hover:text-ink-300 hover:bg-ink-800"
|
|
586
|
+
}`;
|
|
587
|
+
openFolderBtn.title = "Open folder images";
|
|
588
|
+
openFolderBtn.innerHTML = '<i data-lucide="eye" class="w-3.5 h-3.5"></i>';
|
|
589
|
+
openFolderBtn.onclick = (event) => {
|
|
590
|
+
event.stopPropagation();
|
|
591
|
+
openDirectory(entry.path).catch(handleActionError);
|
|
592
|
+
};
|
|
593
|
+
row.appendChild(openFolderBtn);
|
|
594
|
+
}
|
|
592
595
|
container.appendChild(row);
|
|
593
596
|
|
|
594
597
|
if (isDir && isExpanded) {
|
|
@@ -615,6 +618,19 @@ function toggleDirectory(dirPath) {
|
|
|
615
618
|
}
|
|
616
619
|
}
|
|
617
620
|
|
|
621
|
+
function ensureDirectoryExpanded(dirPath) {
|
|
622
|
+
if (!state.expandedPaths.has(dirPath)) {
|
|
623
|
+
state.expandedPaths.add(dirPath);
|
|
624
|
+
if (!state.dirChildren.has(dirPath)) {
|
|
625
|
+
loadDirChildren(dirPath);
|
|
626
|
+
} else {
|
|
627
|
+
renderTree();
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
renderTree();
|
|
632
|
+
}
|
|
633
|
+
|
|
618
634
|
function expandParentsOf(filePath) {
|
|
619
635
|
const segments = filePath.split("/");
|
|
620
636
|
let running = "";
|
|
@@ -681,11 +697,7 @@ function handleSearch(query) {
|
|
|
681
697
|
searchResultsEl.innerHTML = "";
|
|
682
698
|
expandParentsOf(result.path);
|
|
683
699
|
if (result.type === "directory") {
|
|
684
|
-
|
|
685
|
-
state.filesMobileMode = "explorer";
|
|
686
|
-
syncFilesLayout();
|
|
687
|
-
}
|
|
688
|
-
toggleDirectory(result.path);
|
|
700
|
+
openDirectory(result.path, { toggleExpand: false }).catch(handleActionError);
|
|
689
701
|
} else {
|
|
690
702
|
openFile(result.path).catch(handleActionError);
|
|
691
703
|
}
|
|
@@ -714,6 +726,128 @@ searchInput.addEventListener("focus", () => {
|
|
|
714
726
|
// ---------------------------------------------------------------------------
|
|
715
727
|
// File preview
|
|
716
728
|
// ---------------------------------------------------------------------------
|
|
729
|
+
function fileImageUrl(entry) {
|
|
730
|
+
return appendVersionQuery(`/api/file?path=${encodeURIComponent(entry.path)}`, entry);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function renderDirectoryImageGallery(directoryPath, entries) {
|
|
734
|
+
if (entries.length === 0) {
|
|
735
|
+
viewer.innerHTML = `<div class="flex flex-col items-center justify-center h-full text-ink-600">
|
|
736
|
+
<i data-lucide="image-off" class="w-10 h-10 mb-2 opacity-40"></i>
|
|
737
|
+
<p class="text-sm">No images directly in <span class="font-mono">${escapeHtml(directoryPath)}/</span></p>
|
|
738
|
+
</div>`;
|
|
739
|
+
refreshIcons();
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
viewer.innerHTML = "";
|
|
744
|
+
const grid = document.createElement("div");
|
|
745
|
+
grid.className = "grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3 content-start";
|
|
746
|
+
|
|
747
|
+
for (const entry of entries) {
|
|
748
|
+
const imageUrl = fileImageUrl(entry);
|
|
749
|
+
const card = document.createElement("div");
|
|
750
|
+
card.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";
|
|
751
|
+
|
|
752
|
+
const imageButton = document.createElement("button");
|
|
753
|
+
imageButton.type = "button";
|
|
754
|
+
imageButton.className = "block w-full";
|
|
755
|
+
imageButton.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" />`;
|
|
756
|
+
imageButton.onclick = () => openLightbox(imageUrl);
|
|
757
|
+
|
|
758
|
+
const footer = document.createElement("div");
|
|
759
|
+
footer.className = "flex items-center gap-1 min-w-0";
|
|
760
|
+
footer.innerHTML = `<span class="text-[11px] font-mono text-ink-500 truncate flex-1">${escapeHtml(entry.name)}</span>`;
|
|
761
|
+
if (entry.size) {
|
|
762
|
+
footer.innerHTML += `<span class="text-[10px] font-mono text-ink-600 shrink-0">${formatBytes(entry.size)}</span>`;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const copyBtn = document.createElement("button");
|
|
766
|
+
copyBtn.type = "button";
|
|
767
|
+
copyBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-brand rounded transition-colors ml-1";
|
|
768
|
+
copyBtn.title = "Copy path";
|
|
769
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
770
|
+
copyBtn.onclick = async (e) => {
|
|
771
|
+
e.stopPropagation();
|
|
772
|
+
try {
|
|
773
|
+
await navigator.clipboard.writeText(entry.path);
|
|
774
|
+
copyBtn.innerHTML = `<i data-lucide="check" class="w-3 h-3 text-green-400"></i>`;
|
|
775
|
+
refreshIcons();
|
|
776
|
+
setTimeout(() => {
|
|
777
|
+
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
778
|
+
refreshIcons();
|
|
779
|
+
}, 1500);
|
|
780
|
+
} catch {
|
|
781
|
+
setStatus("Copy failed — clipboard access denied.", true);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
footer.appendChild(copyBtn);
|
|
785
|
+
|
|
786
|
+
const openBtn = document.createElement("button");
|
|
787
|
+
openBtn.type = "button";
|
|
788
|
+
openBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-brand rounded transition-colors";
|
|
789
|
+
openBtn.title = "Open in file viewer";
|
|
790
|
+
openBtn.innerHTML = `<i data-lucide="maximize-2" class="w-3 h-3"></i>`;
|
|
791
|
+
openBtn.onclick = (e) => {
|
|
792
|
+
e.stopPropagation();
|
|
793
|
+
openFile(entry.path).catch(handleActionError);
|
|
794
|
+
};
|
|
795
|
+
footer.appendChild(openBtn);
|
|
796
|
+
|
|
797
|
+
card.appendChild(imageButton);
|
|
798
|
+
card.appendChild(footer);
|
|
799
|
+
grid.appendChild(card);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
viewer.appendChild(grid);
|
|
803
|
+
refreshIcons();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function openDirectory(dirPath, options = {}) {
|
|
807
|
+
const requestId = ++state.fileRequestId;
|
|
808
|
+
if (state.fileAbortController) state.fileAbortController.abort();
|
|
809
|
+
const controller = new AbortController();
|
|
810
|
+
state.fileAbortController = controller;
|
|
811
|
+
const toggleExpand = options.toggleExpand !== false;
|
|
812
|
+
|
|
813
|
+
state.selectedPath = dirPath;
|
|
814
|
+
state.selectedPathType = "directory";
|
|
815
|
+
viewerRefreshBtn.classList.remove("hidden");
|
|
816
|
+
localStorage.removeItem("workspace-file");
|
|
817
|
+
expandParentsOf(dirPath);
|
|
818
|
+
if (toggleExpand) {
|
|
819
|
+
toggleDirectory(dirPath);
|
|
820
|
+
} else {
|
|
821
|
+
ensureDirectoryExpanded(dirPath);
|
|
822
|
+
}
|
|
823
|
+
renderTree();
|
|
824
|
+
if (!isDesktopLayout()) {
|
|
825
|
+
state.filesMobileMode = "viewer";
|
|
826
|
+
syncFilesLayout();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
viewerTitle.textContent = `${dirPath}/`;
|
|
830
|
+
setStatus(`Listing images in ${dirPath}/...`);
|
|
831
|
+
|
|
832
|
+
const response = await fetch(`/api/list?path=${encodeURIComponent(dirPath)}`, {
|
|
833
|
+
signal: controller.signal,
|
|
834
|
+
});
|
|
835
|
+
const data = await readJsonResponse(response);
|
|
836
|
+
if (requestId !== state.fileRequestId) return;
|
|
837
|
+
|
|
838
|
+
if (!response.ok) {
|
|
839
|
+
viewer.innerHTML = `<p class="text-red-400 text-sm">${escapeHtml(data.error || "Failed to open folder.")}</p>`;
|
|
840
|
+
setStatus("Folder open failed", true);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const imageEntries = (data.entries || []).filter(
|
|
845
|
+
(entry) => entry.type === "file" && isImagePath(entry.path || entry.name),
|
|
846
|
+
);
|
|
847
|
+
renderDirectoryImageGallery(dirPath, imageEntries);
|
|
848
|
+
setStatus(`${imageEntries.length} image(s) in ${dirPath}/`);
|
|
849
|
+
}
|
|
850
|
+
|
|
717
851
|
async function openFile(filePath) {
|
|
718
852
|
const requestId = ++state.fileRequestId;
|
|
719
853
|
if (state.fileAbortController) state.fileAbortController.abort();
|
|
@@ -721,6 +855,7 @@ async function openFile(filePath) {
|
|
|
721
855
|
state.fileAbortController = controller;
|
|
722
856
|
|
|
723
857
|
state.selectedPath = filePath;
|
|
858
|
+
state.selectedPathType = "file";
|
|
724
859
|
localStorage.setItem("workspace-file", filePath);
|
|
725
860
|
viewerRefreshBtn.classList.remove("hidden");
|
|
726
861
|
expandParentsOf(filePath);
|
|
@@ -978,138 +1113,6 @@ async function loadClipboardImages(options = {}) {
|
|
|
978
1113
|
}
|
|
979
1114
|
}
|
|
980
1115
|
|
|
981
|
-
// ---------------------------------------------------------------------------
|
|
982
|
-
// Screenshots
|
|
983
|
-
// ---------------------------------------------------------------------------
|
|
984
|
-
function screenshotImageUrl(entry) {
|
|
985
|
-
return appendVersionQuery(`/api/screenshots/file?name=${encodeURIComponent(entry.name)}`, entry);
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function renderScreenshots(entries) {
|
|
989
|
-
screenshotsGrid.innerHTML = "";
|
|
990
|
-
if (entries.length === 0) {
|
|
991
|
-
screenshotsGrid.innerHTML = '<p class="text-sm text-ink-500 col-span-full py-8 text-center">No screenshots in .playwright-mcp/ yet.</p>';
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
for (const entry of entries) {
|
|
996
|
-
const container = document.createElement("div");
|
|
997
|
-
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";
|
|
998
|
-
|
|
999
|
-
const imgBtn = document.createElement("button");
|
|
1000
|
-
imgBtn.type = "button";
|
|
1001
|
-
imgBtn.className = "block w-full";
|
|
1002
|
-
const imageUrl = screenshotImageUrl(entry);
|
|
1003
|
-
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" />`;
|
|
1004
|
-
imgBtn.onclick = () => openLightbox(imageUrl);
|
|
1005
|
-
|
|
1006
|
-
const footer = document.createElement("div");
|
|
1007
|
-
footer.className = "flex items-center gap-1 min-w-0";
|
|
1008
|
-
|
|
1009
|
-
// Parse timestamp from filename (page-YYYY-MM-DDTHH-MM-SS-mmmZ.png)
|
|
1010
|
-
const tsMatch = entry.name.match(/(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/);
|
|
1011
|
-
const timeLabel = tsMatch ? `${tsMatch[1]} ${tsMatch[2]}:${tsMatch[3]}:${tsMatch[4]}` : entry.name;
|
|
1012
|
-
|
|
1013
|
-
footer.innerHTML = `<span class="text-[11px] font-mono text-ink-500 truncate flex-1">${escapeHtml(timeLabel)}</span>`;
|
|
1014
|
-
|
|
1015
|
-
if (entry.size) {
|
|
1016
|
-
footer.innerHTML += `<span class="text-[10px] font-mono text-ink-600 shrink-0">${formatBytes(entry.size)}</span>`;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
const copyBtn = document.createElement("button");
|
|
1020
|
-
copyBtn.type = "button";
|
|
1021
|
-
copyBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-brand rounded transition-colors ml-1";
|
|
1022
|
-
copyBtn.title = "Copy path";
|
|
1023
|
-
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
1024
|
-
copyBtn.onclick = async (e) => {
|
|
1025
|
-
e.stopPropagation();
|
|
1026
|
-
try {
|
|
1027
|
-
await navigator.clipboard.writeText(`.playwright-mcp/${entry.name}`);
|
|
1028
|
-
copyBtn.innerHTML = `<i data-lucide="check" class="w-3 h-3 text-green-400"></i>`;
|
|
1029
|
-
refreshIcons();
|
|
1030
|
-
setTimeout(() => {
|
|
1031
|
-
copyBtn.innerHTML = `<i data-lucide="copy" class="w-3 h-3"></i>`;
|
|
1032
|
-
refreshIcons();
|
|
1033
|
-
}, 1500);
|
|
1034
|
-
} catch {
|
|
1035
|
-
setScreenshotsStatus("Copy failed — clipboard access denied.", true);
|
|
1036
|
-
}
|
|
1037
|
-
};
|
|
1038
|
-
footer.appendChild(copyBtn);
|
|
1039
|
-
|
|
1040
|
-
const deleteBtn = document.createElement("button");
|
|
1041
|
-
deleteBtn.type = "button";
|
|
1042
|
-
deleteBtn.className = "shrink-0 p-0.5 text-ink-500 hover:text-red-400 rounded transition-colors";
|
|
1043
|
-
deleteBtn.title = "Delete image";
|
|
1044
|
-
deleteBtn.innerHTML = `<i data-lucide="trash-2" class="w-3 h-3"></i>`;
|
|
1045
|
-
deleteBtn.onclick = async (e) => {
|
|
1046
|
-
e.stopPropagation();
|
|
1047
|
-
const confirmed = window.confirm(`Delete .playwright-mcp/${entry.name}?`);
|
|
1048
|
-
if (!confirmed) return;
|
|
1049
|
-
try {
|
|
1050
|
-
await deleteScreenshotImage(entry.name);
|
|
1051
|
-
} catch (error) {
|
|
1052
|
-
setScreenshotsStatus(toErrorMessage(error), true);
|
|
1053
|
-
}
|
|
1054
|
-
};
|
|
1055
|
-
footer.appendChild(deleteBtn);
|
|
1056
|
-
|
|
1057
|
-
container.appendChild(imgBtn);
|
|
1058
|
-
container.appendChild(footer);
|
|
1059
|
-
screenshotsGrid.appendChild(container);
|
|
1060
|
-
}
|
|
1061
|
-
refreshIcons();
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
async function loadScreenshots(options = {}) {
|
|
1065
|
-
const preferCache = options.preferCache !== false;
|
|
1066
|
-
const requestId = ++state.screenshotsRequestId;
|
|
1067
|
-
if (state.screenshotsAbortController) state.screenshotsAbortController.abort();
|
|
1068
|
-
const controller = new AbortController();
|
|
1069
|
-
state.screenshotsAbortController = controller;
|
|
1070
|
-
const cachedEntries = preferCache ? readCachedValue(CACHE_KEYS.screenshotsEntries) : null;
|
|
1071
|
-
|
|
1072
|
-
try {
|
|
1073
|
-
setScreenshotsStatus("Loading...");
|
|
1074
|
-
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
1075
|
-
state.screenshotEntries = cachedEntries.entries;
|
|
1076
|
-
tabBadgeScreenshots.textContent = cachedEntries.entries.length ? `${cachedEntries.entries.length}` : "";
|
|
1077
|
-
renderScreenshots(cachedEntries.entries);
|
|
1078
|
-
setScreenshotsStatus(`${cachedEntries.entries.length} cached screenshot(s) (refreshing...)`);
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const res = await fetch("/api/screenshots/list", { signal: controller.signal });
|
|
1082
|
-
let data;
|
|
1083
|
-
try { data = await readJsonResponse(res); } catch (error) {
|
|
1084
|
-
if (isRouteMissingResponse(res, error)) {
|
|
1085
|
-
// Server doesn't have screenshots endpoint yet — show empty
|
|
1086
|
-
if (requestId !== state.screenshotsRequestId) return;
|
|
1087
|
-
tabBadgeScreenshots.textContent = "";
|
|
1088
|
-
renderScreenshots([]);
|
|
1089
|
-
setScreenshotsStatus("Screenshots API not available");
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
throw error;
|
|
1093
|
-
}
|
|
1094
|
-
if (!res.ok) throw new Error(data.error || "Unable to load screenshots");
|
|
1095
|
-
if (requestId !== state.screenshotsRequestId) return;
|
|
1096
|
-
|
|
1097
|
-
const entries = data.entries || [];
|
|
1098
|
-
state.screenshotEntries = entries;
|
|
1099
|
-
writeCachedValue(CACHE_KEYS.screenshotsEntries, { entries });
|
|
1100
|
-
tabBadgeScreenshots.textContent = entries.length ? `${entries.length}` : "";
|
|
1101
|
-
renderScreenshots(entries);
|
|
1102
|
-
setScreenshotsStatus(`${entries.length} screenshot(s)`);
|
|
1103
|
-
} catch (error) {
|
|
1104
|
-
if (isAbortError(error)) return;
|
|
1105
|
-
if (cachedEntries && Array.isArray(cachedEntries.entries)) {
|
|
1106
|
-
setScreenshotsStatus(`Using cached screenshots: ${toErrorMessage(error)}`, true);
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
setScreenshotsStatus(toErrorMessage(error), true);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
1116
|
async function deleteClipboardImage(name) {
|
|
1114
1117
|
setClipboardStatus(`Deleting ${name}...`);
|
|
1115
1118
|
const response = await fetch(`/api/clipboard/file?name=${encodeURIComponent(name)}`, {
|
|
@@ -1124,20 +1127,6 @@ async function deleteClipboardImage(name) {
|
|
|
1124
1127
|
setClipboardStatus(`Deleted ${name}.`);
|
|
1125
1128
|
}
|
|
1126
1129
|
|
|
1127
|
-
async function deleteScreenshotImage(name) {
|
|
1128
|
-
setScreenshotsStatus(`Deleting ${name}...`);
|
|
1129
|
-
const response = await fetch(`/api/screenshots/file?name=${encodeURIComponent(name)}`, {
|
|
1130
|
-
method: "DELETE",
|
|
1131
|
-
});
|
|
1132
|
-
const data = await readJsonResponse(response);
|
|
1133
|
-
if (!response.ok) {
|
|
1134
|
-
throw new Error(data.error || "Delete failed");
|
|
1135
|
-
}
|
|
1136
|
-
clearCachedValue(CACHE_KEYS.screenshotsEntries);
|
|
1137
|
-
await loadScreenshots({ preferCache: false });
|
|
1138
|
-
setScreenshotsStatus(`Deleted ${name}.`);
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
1130
|
// ---------------------------------------------------------------------------
|
|
1142
1131
|
// Upload logic
|
|
1143
1132
|
// ---------------------------------------------------------------------------
|
|
@@ -1271,12 +1260,13 @@ treeRefreshBtn.onclick = () => {
|
|
|
1271
1260
|
loadTopLevel({ preferCache: false }).catch(handleActionError);
|
|
1272
1261
|
loadSearchIndex({ preferCache: false });
|
|
1273
1262
|
};
|
|
1274
|
-
screenshotsRefreshBtn.onclick = () => {
|
|
1275
|
-
clearCachedValue(CACHE_KEYS.screenshotsEntries);
|
|
1276
|
-
loadScreenshots({ preferCache: false }).catch(handleActionError);
|
|
1277
|
-
};
|
|
1278
1263
|
viewerRefreshBtn.onclick = () => {
|
|
1279
|
-
if (state.selectedPath)
|
|
1264
|
+
if (!state.selectedPath) return;
|
|
1265
|
+
if (state.selectedPathType === "directory") {
|
|
1266
|
+
openDirectory(state.selectedPath, { toggleExpand: false }).catch(handleActionError);
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
openFile(state.selectedPath).catch(handleActionError);
|
|
1280
1270
|
};
|
|
1281
1271
|
if (mobileExplorerBackBtn) {
|
|
1282
1272
|
mobileExplorerBackBtn.onclick = () => {
|
|
@@ -1290,7 +1280,7 @@ syncFilesLayout();
|
|
|
1290
1280
|
// ---------------------------------------------------------------------------
|
|
1291
1281
|
// Init
|
|
1292
1282
|
// ---------------------------------------------------------------------------
|
|
1293
|
-
Promise.all([loadClipboardImages(), loadTopLevel()
|
|
1283
|
+
Promise.all([loadClipboardImages(), loadTopLevel()]).then(() => {
|
|
1294
1284
|
// Restore last opened file after tree is loaded
|
|
1295
1285
|
const lastFile = localStorage.getItem("workspace-file");
|
|
1296
1286
|
if (lastFile) openFile(lastFile).catch(handleActionError);
|
package/static/index.html
CHANGED
|
@@ -142,19 +142,6 @@
|
|
|
142
142
|
</div>
|
|
143
143
|
</section>
|
|
144
144
|
|
|
145
|
-
<!-- Tab: Screenshots -->
|
|
146
|
-
<section id="panel-screenshots" class="tab-panel absolute inset-0 flex flex-col hidden">
|
|
147
|
-
<div class="shrink-0 bg-ink-900/60 border-b border-ink-800 px-4 py-2.5 flex items-center gap-2">
|
|
148
|
-
<span class="text-xs text-ink-500 font-mono">.playwright-mcp/</span>
|
|
149
|
-
<div class="flex-1"></div>
|
|
150
|
-
<button id="screenshots-refresh-btn" type="button" class="p-1.5 text-ink-500 hover:text-ink-300 rounded-lg hover:bg-ink-800 transition-colors" title="Refresh">
|
|
151
|
-
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
|
152
|
-
</button>
|
|
153
|
-
</div>
|
|
154
|
-
<div id="screenshots-status" class="shrink-0 px-4 py-1 text-[11px] text-ink-500 font-mono min-h-[1.4em]"></div>
|
|
155
|
-
<div id="screenshots-grid" class="flex-1 overflow-y-auto overscroll-contain p-3 grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3 content-start"></div>
|
|
156
|
-
</section>
|
|
157
|
-
|
|
158
145
|
<!-- Lightbox overlay -->
|
|
159
146
|
<div id="lightbox" class="hidden absolute inset-0 z-50 bg-ink-950/90">
|
|
160
147
|
<!-- Modal panel -->
|
|
@@ -186,11 +173,6 @@
|
|
|
186
173
|
<span class="text-[10px] font-semibold uppercase tracking-wide">Clipboard</span>
|
|
187
174
|
<span id="tab-badge-clipboard" class="text-[9px] font-mono text-ink-600"></span>
|
|
188
175
|
</button>
|
|
189
|
-
<button type="button" class="tab-btn flex-1 flex flex-col items-center gap-0.5 py-2.5 text-ink-500 transition-colors" data-tab="screenshots">
|
|
190
|
-
<i data-lucide="camera" class="w-5 h-5"></i>
|
|
191
|
-
<span class="text-[10px] font-semibold uppercase tracking-wide">Screenshots</span>
|
|
192
|
-
<span id="tab-badge-screenshots" class="text-[9px] font-mono text-ink-600"></span>
|
|
193
|
-
</button>
|
|
194
176
|
</nav>
|
|
195
177
|
|
|
196
178
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|