@haowjy/remote-workspace 0.1.2 → 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 +16 -9
- package/dist/launcher.js +112 -12
- package/dist/server.js +141 -111
- 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
|
-
- Hide and block access to
|
|
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.
|
|
@@ -62,6 +64,8 @@ cd /path/to/your/repo && pnpm dev
|
|
|
62
64
|
```bash
|
|
63
65
|
pnpm dev -- config /path/to/config
|
|
64
66
|
pnpm dev -- port 18111
|
|
67
|
+
pnpm dev -- always-hidden .git,.env,.secrets
|
|
68
|
+
pnpm dev -- image-dirs .clipboard,.playwright-mcp
|
|
65
69
|
pnpm dev -- install
|
|
66
70
|
pnpm dev -- no-serve
|
|
67
71
|
pnpm dev -- password your-password
|
|
@@ -76,6 +80,8 @@ pnpm dev -- password your-password --funnel
|
|
|
76
80
|
- `REMOTE_WS_MAX_UPLOAD_BYTES` (default `26214400`)
|
|
77
81
|
- `REMOTE_WS_PASSWORD` (optional, enables HTTP Basic Auth when set)
|
|
78
82
|
- `REMOTE_WS_CONFIG_FILE` (optional config file path override)
|
|
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`)
|
|
79
85
|
- `REPO_ROOT` (injected by launcher script)
|
|
80
86
|
|
|
81
87
|
Password config file format (default: repo root `.remote-workspace.conf`):
|
|
@@ -102,8 +108,9 @@ Config file selection precedence:
|
|
|
102
108
|
- `POST /api/clipboard/upload` always writes to `REPO_ROOT/.clipboard`
|
|
103
109
|
- `DELETE /api/clipboard/file?name=<filename>` deletes one image in `REPO_ROOT/.clipboard`
|
|
104
110
|
- `.clipboard` panel uses dedicated clipboard endpoints (`/api/clipboard/upload`, `/api/clipboard/list`, `/api/clipboard/file`)
|
|
105
|
-
- Main repository browser
|
|
111
|
+
- Main repository browser blocks always-hidden path segments (`.git` + optional configured segments)
|
|
106
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`)
|
|
107
114
|
- Accepted upload types are images only (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `heic`, `heif`, `avif`)
|
|
108
115
|
- Clipboard panel supports both file picker and `Paste From Clipboard` button (when browser clipboard image API is available)
|
|
109
116
|
- Upload requires `name` query parameter (filename is user-controlled)
|
|
@@ -111,16 +118,16 @@ Config file selection precedence:
|
|
|
111
118
|
- Multipart field names accepted: `file` (current UI) and `files` (legacy cached UI compatibility)
|
|
112
119
|
- Legacy alias: `/api/upload` is still accepted for older cached clients
|
|
113
120
|
|
|
114
|
-
##
|
|
121
|
+
## Folder Image Gallery
|
|
115
122
|
|
|
116
|
-
-
|
|
117
|
-
- `GET /api/
|
|
118
|
-
-
|
|
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.
|
|
119
126
|
|
|
120
127
|
## Caching
|
|
121
128
|
|
|
122
|
-
- The browser now caches image bytes (`/api/clipboard/file`,
|
|
123
|
-
- 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.
|
|
124
131
|
- Refresh buttons bypass local metadata cache and force a new server fetch.
|
|
125
132
|
|
|
126
133
|
## Tailscale
|
package/dist/launcher.js
CHANGED
|
@@ -14,6 +14,8 @@ Options:
|
|
|
14
14
|
--repo-root <path> Repository root to expose (default: REPO_ROOT or cwd)
|
|
15
15
|
--config <path> Config file path (default uses precedence search)
|
|
16
16
|
--port <port> Listen port (default: REMOTE_WS_PORT or 18080)
|
|
17
|
+
--always-hidden <csv> Extra always-hidden path segments (comma-separated)
|
|
18
|
+
--image-dirs <csv> Visible image folders (repo-relative, comma-separated)
|
|
17
19
|
--install Force dependency install before start
|
|
18
20
|
--skip-install Skip install check even if node_modules is missing
|
|
19
21
|
--no-serve Skip tailscale serve setup
|
|
@@ -27,6 +29,8 @@ Examples:
|
|
|
27
29
|
pnpm dev -- --repo-root /path/to/repo
|
|
28
30
|
pnpm dev -- --config ~/.config/remote-workspace/config
|
|
29
31
|
pnpm dev -- --port 18111
|
|
32
|
+
pnpm dev -- --always-hidden .git,.env,.secrets
|
|
33
|
+
pnpm dev -- --image-dirs .clipboard,.playwright-mcp
|
|
30
34
|
pnpm dev -- --password
|
|
31
35
|
pnpm dev -- --password mypass --serve
|
|
32
36
|
pnpm dev -- --password mypass --funnel
|
|
@@ -47,6 +51,60 @@ function parsePort(rawPort) {
|
|
|
47
51
|
}
|
|
48
52
|
return value;
|
|
49
53
|
}
|
|
54
|
+
function parseAlwaysHiddenCsv(rawValue, sourceLabel) {
|
|
55
|
+
const segments = rawValue
|
|
56
|
+
.split(",")
|
|
57
|
+
.map((value) => value.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
if (segments.length === 0) {
|
|
60
|
+
fail(`Invalid ${sourceLabel} value: provide at least one segment.`);
|
|
61
|
+
}
|
|
62
|
+
const normalizedSegments = new Set();
|
|
63
|
+
for (const segment of segments) {
|
|
64
|
+
if (segment.includes("/") || segment.includes("\\") || segment.includes("\u0000")) {
|
|
65
|
+
fail(`Invalid ${sourceLabel} segment "${segment}": segments must not contain path separators or null bytes.`);
|
|
66
|
+
}
|
|
67
|
+
normalizedSegments.add(segment);
|
|
68
|
+
}
|
|
69
|
+
return Array.from(normalizedSegments).join(",");
|
|
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
|
+
}
|
|
50
108
|
function parseConfigPassword(configFilePath) {
|
|
51
109
|
if (!existsSync(configFilePath)) {
|
|
52
110
|
return "";
|
|
@@ -100,6 +158,8 @@ function parseArgs(argv) {
|
|
|
100
158
|
let workspacePassword = "";
|
|
101
159
|
let configFileFromArg = null;
|
|
102
160
|
let configFile = "";
|
|
161
|
+
let alwaysHidden = null;
|
|
162
|
+
let imageDirs = null;
|
|
103
163
|
for (let i = 0; i < argv.length; i += 1) {
|
|
104
164
|
const arg = argv[i];
|
|
105
165
|
switch (arg) {
|
|
@@ -130,6 +190,24 @@ function parseArgs(argv) {
|
|
|
130
190
|
i += 1;
|
|
131
191
|
break;
|
|
132
192
|
}
|
|
193
|
+
case "--always-hidden": {
|
|
194
|
+
const next = argv[i + 1];
|
|
195
|
+
if (!next || next.startsWith("--")) {
|
|
196
|
+
fail("Missing value for --always-hidden.");
|
|
197
|
+
}
|
|
198
|
+
alwaysHidden = parseAlwaysHiddenCsv(next, "--always-hidden");
|
|
199
|
+
i += 1;
|
|
200
|
+
break;
|
|
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
|
+
}
|
|
133
211
|
case "--install":
|
|
134
212
|
forceInstall = true;
|
|
135
213
|
break;
|
|
@@ -229,6 +307,8 @@ function parseArgs(argv) {
|
|
|
229
307
|
passwordEnabled,
|
|
230
308
|
workspacePassword,
|
|
231
309
|
configFile,
|
|
310
|
+
alwaysHidden,
|
|
311
|
+
imageDirs,
|
|
232
312
|
};
|
|
233
313
|
}
|
|
234
314
|
function hasCommand(commandName) {
|
|
@@ -291,25 +371,45 @@ function main() {
|
|
|
291
371
|
if (args.passwordEnabled) {
|
|
292
372
|
console.log("Auth: Basic Auth enabled");
|
|
293
373
|
}
|
|
374
|
+
if (args.alwaysHidden !== null) {
|
|
375
|
+
console.log(`Always hidden segments: .git + ${args.alwaysHidden}`);
|
|
376
|
+
}
|
|
377
|
+
else if (process.env.REMOTE_WS_ALWAYS_HIDDEN) {
|
|
378
|
+
console.log(`Always hidden segments: .git + ${process.env.REMOTE_WS_ALWAYS_HIDDEN}`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
console.log("Always hidden segments: .git");
|
|
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
|
+
}
|
|
294
392
|
console.log("");
|
|
393
|
+
const childEnv = {
|
|
394
|
+
...process.env,
|
|
395
|
+
REPO_ROOT: args.repoRoot,
|
|
396
|
+
REMOTE_WS_PORT: String(args.port),
|
|
397
|
+
REMOTE_WS_PASSWORD: args.workspacePassword,
|
|
398
|
+
};
|
|
399
|
+
if (args.alwaysHidden !== null) {
|
|
400
|
+
childEnv.REMOTE_WS_ALWAYS_HIDDEN = args.alwaysHidden;
|
|
401
|
+
}
|
|
402
|
+
if (args.imageDirs !== null) {
|
|
403
|
+
childEnv.REMOTE_WS_IMAGE_DIRS = args.imageDirs;
|
|
404
|
+
}
|
|
295
405
|
const child = runBuiltServer
|
|
296
406
|
? spawn("node", [builtServerPath], {
|
|
297
407
|
stdio: "inherit",
|
|
298
|
-
env:
|
|
299
|
-
...process.env,
|
|
300
|
-
REPO_ROOT: args.repoRoot,
|
|
301
|
-
REMOTE_WS_PORT: String(args.port),
|
|
302
|
-
REMOTE_WS_PASSWORD: args.workspacePassword,
|
|
303
|
-
},
|
|
408
|
+
env: childEnv,
|
|
304
409
|
})
|
|
305
410
|
: spawn("pnpm", ["--dir", appDir, "dev:server"], {
|
|
306
411
|
stdio: "inherit",
|
|
307
|
-
env:
|
|
308
|
-
...process.env,
|
|
309
|
-
REPO_ROOT: args.repoRoot,
|
|
310
|
-
REMOTE_WS_PORT: String(args.port),
|
|
311
|
-
REMOTE_WS_PASSWORD: args.workspacePassword,
|
|
312
|
-
},
|
|
412
|
+
env: childEnv,
|
|
313
413
|
});
|
|
314
414
|
child.on("exit", (code, signal) => {
|
|
315
415
|
if (signal) {
|
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);
|
|
@@ -23,6 +24,64 @@ function parseIntegerFromEnv(envName, fallbackValue, options) {
|
|
|
23
24
|
}
|
|
24
25
|
return parsedValue;
|
|
25
26
|
}
|
|
27
|
+
function assertValidHiddenSegment(segment, sourceName) {
|
|
28
|
+
if (segment.includes("/") || segment.includes("\\") || segment.includes("\u0000")) {
|
|
29
|
+
throw new Error(`Invalid ${sourceName} segment "${segment}": segments must not contain path separators or null bytes`);
|
|
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
|
+
}
|
|
55
|
+
function parseAlwaysHiddenSegments(rawValue) {
|
|
56
|
+
const segments = new Set([".git"]);
|
|
57
|
+
if (rawValue === undefined) {
|
|
58
|
+
return segments;
|
|
59
|
+
}
|
|
60
|
+
for (const rawSegment of rawValue.split(",")) {
|
|
61
|
+
const segment = rawSegment.trim();
|
|
62
|
+
if (!segment) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
assertValidHiddenSegment(segment, "REMOTE_WS_ALWAYS_HIDDEN");
|
|
66
|
+
segments.add(segment);
|
|
67
|
+
}
|
|
68
|
+
return segments;
|
|
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
|
+
}
|
|
26
85
|
const REPO_ROOT = path.resolve(process.env.REPO_ROOT ?? process.cwd());
|
|
27
86
|
const HOST = "127.0.0.1";
|
|
28
87
|
const PORT = parseIntegerFromEnv("REMOTE_WS_PORT", 18080, { min: 1, max: 65535 });
|
|
@@ -35,10 +94,8 @@ const MAX_UPLOAD_BYTES = parseIntegerFromEnv("REMOTE_WS_MAX_UPLOAD_BYTES", 26_21
|
|
|
35
94
|
const MAX_TREE_ENTRIES = parseIntegerFromEnv("REMOTE_WS_MAX_TREE_ENTRIES", 5000, {
|
|
36
95
|
min: 1,
|
|
37
96
|
});
|
|
38
|
-
const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
|
|
39
97
|
const CLIPBOARD_DIRECTORY_PATH = path.resolve(REPO_ROOT, CLIPBOARD_DIRECTORY_NAME);
|
|
40
|
-
const
|
|
41
|
-
const SCREENSHOTS_DIRECTORY_PATH = path.resolve(REPO_ROOT, SCREENSHOTS_DIRECTORY_NAME);
|
|
98
|
+
const IMAGE_DIRECTORY_PATHS = parseImageDirectoryPaths(process.env.REMOTE_WS_IMAGE_DIRS);
|
|
42
99
|
const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
43
100
|
".png",
|
|
44
101
|
".jpg",
|
|
@@ -54,6 +111,7 @@ const ALLOWED_IMAGE_EXTENSIONS = new Set([
|
|
|
54
111
|
const IMAGE_CACHE_CONTROL = "private, max-age=60, stale-while-revalidate=300";
|
|
55
112
|
const METADATA_CACHE_CONTROL = "private, max-age=10, stale-while-revalidate=30";
|
|
56
113
|
const BASIC_AUTH_PASSWORD = process.env.REMOTE_WS_PASSWORD ?? "";
|
|
114
|
+
const ALWAYS_HIDDEN_SEGMENTS = parseAlwaysHiddenSegments(process.env.REMOTE_WS_ALWAYS_HIDDEN);
|
|
57
115
|
const AUTH_WINDOW_MS = 10 * 60 * 1000;
|
|
58
116
|
const AUTH_MAX_ATTEMPTS = 20;
|
|
59
117
|
const AUTH_BLOCK_MS = 15 * 60 * 1000;
|
|
@@ -219,14 +277,27 @@ function toRepoRelativePath(absPath) {
|
|
|
219
277
|
}
|
|
220
278
|
return relative.split(path.sep).join("/");
|
|
221
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
|
+
}
|
|
222
290
|
function isHiddenRepoRelativePath(repoRelativePath) {
|
|
223
291
|
if (!repoRelativePath) {
|
|
224
292
|
return false;
|
|
225
293
|
}
|
|
226
|
-
return repoRelativePath
|
|
294
|
+
return repoRelativePath
|
|
295
|
+
.split("/")
|
|
296
|
+
.some((segment) => ALWAYS_HIDDEN_SEGMENTS.has(segment));
|
|
227
297
|
}
|
|
228
298
|
function isBlockedHiddenRepoRelativePath(repoRelativePath) {
|
|
229
|
-
return isHiddenRepoRelativePath(repoRelativePath)
|
|
299
|
+
return (isHiddenRepoRelativePath(repoRelativePath) &&
|
|
300
|
+
!isConfiguredImageDirectoryPath(repoRelativePath));
|
|
230
301
|
}
|
|
231
302
|
function parseGitIgnoredStdout(stdout) {
|
|
232
303
|
if (!stdout) {
|
|
@@ -257,9 +328,11 @@ async function assertPathAccessible(absPath, options) {
|
|
|
257
328
|
throw new Error("Hidden paths are not accessible");
|
|
258
329
|
}
|
|
259
330
|
if (!options?.allowGitIgnored && repoRelativePath) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
+
}
|
|
263
336
|
}
|
|
264
337
|
}
|
|
265
338
|
}
|
|
@@ -514,15 +587,69 @@ function sortTree(node) {
|
|
|
514
587
|
sortTree(child);
|
|
515
588
|
}
|
|
516
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
|
+
}
|
|
517
643
|
app.get("/api/tree", async (_req, res) => {
|
|
518
644
|
try {
|
|
519
645
|
const { stdout } = await execFileAsync("git", ["-C", REPO_ROOT, "ls-files", "--cached", "--others", "--exclude-standard"], { maxBuffer: 10 * 1024 * 1024 });
|
|
520
|
-
const
|
|
646
|
+
const gitPaths = String(stdout)
|
|
521
647
|
.split(/\r?\n/)
|
|
522
648
|
.map((line) => line.trim())
|
|
523
649
|
.filter(Boolean);
|
|
524
|
-
|
|
525
|
-
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));
|
|
526
653
|
const truncated = visiblePaths.length > MAX_TREE_ENTRIES;
|
|
527
654
|
const paths = truncated ? visiblePaths.slice(0, MAX_TREE_ENTRIES) : visiblePaths;
|
|
528
655
|
const root = buildTreeFromPaths(paths);
|
|
@@ -573,7 +700,8 @@ app.get("/api/list", async (req, res) => {
|
|
|
573
700
|
}
|
|
574
701
|
const ignoredPathSet = await getGitIgnoredPathSet(candidates.map((candidate) => candidate.childRepoRelativePath));
|
|
575
702
|
for (const candidate of candidates) {
|
|
576
|
-
if (ignoredPathSet.has(candidate.childRepoRelativePath)
|
|
703
|
+
if (ignoredPathSet.has(candidate.childRepoRelativePath) &&
|
|
704
|
+
!isConfiguredImageDirectoryPath(candidate.childRepoRelativePath)) {
|
|
577
705
|
skippedIgnored += 1;
|
|
578
706
|
continue;
|
|
579
707
|
}
|
|
@@ -715,105 +843,6 @@ app.delete("/api/clipboard/file", async (req, res) => {
|
|
|
715
843
|
res.status(400).json({ error: message });
|
|
716
844
|
}
|
|
717
845
|
});
|
|
718
|
-
app.get("/api/screenshots/list", async (_req, res) => {
|
|
719
|
-
try {
|
|
720
|
-
const dirEntries = await fs.readdir(SCREENSHOTS_DIRECTORY_PATH, {
|
|
721
|
-
withFileTypes: true,
|
|
722
|
-
}).catch(() => []);
|
|
723
|
-
const entries = [];
|
|
724
|
-
for (const dirEntry of dirEntries) {
|
|
725
|
-
if (!dirEntry.isFile())
|
|
726
|
-
continue;
|
|
727
|
-
const extension = path.extname(dirEntry.name).toLowerCase();
|
|
728
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension))
|
|
729
|
-
continue;
|
|
730
|
-
const absPath = path.join(SCREENSHOTS_DIRECTORY_PATH, dirEntry.name);
|
|
731
|
-
let stats;
|
|
732
|
-
try {
|
|
733
|
-
stats = await fs.stat(absPath);
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
continue;
|
|
737
|
-
}
|
|
738
|
-
entries.push({
|
|
739
|
-
name: dirEntry.name,
|
|
740
|
-
path: `${SCREENSHOTS_DIRECTORY_NAME}/${dirEntry.name}`,
|
|
741
|
-
type: "file",
|
|
742
|
-
size: stats.size,
|
|
743
|
-
modifiedAt: stats.mtime.toISOString(),
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
entries.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
|
|
747
|
-
setMetadataCacheHeaders(res);
|
|
748
|
-
res.json({
|
|
749
|
-
directory: SCREENSHOTS_DIRECTORY_NAME,
|
|
750
|
-
entries,
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
catch (error) {
|
|
754
|
-
const message = error instanceof Error ? error.message : "Unable to list screenshots";
|
|
755
|
-
res.status(400).json({ error: message });
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
app.get("/api/screenshots/file", async (req, res) => {
|
|
759
|
-
try {
|
|
760
|
-
const requestedName = getSingleQueryValue(req.query.name);
|
|
761
|
-
if (!requestedName) {
|
|
762
|
-
res.status(400).json({ error: "Missing ?name=..." });
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
|
|
766
|
-
const stats = await fs.stat(absPath);
|
|
767
|
-
if (!stats.isFile()) {
|
|
768
|
-
res.status(400).json({ error: "Not a file" });
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const realPath = await fs.realpath(absPath);
|
|
772
|
-
const relativeToDirectory = path.relative(SCREENSHOTS_DIRECTORY_PATH, realPath);
|
|
773
|
-
if (relativeToDirectory.startsWith("..") ||
|
|
774
|
-
path.isAbsolute(relativeToDirectory)) {
|
|
775
|
-
res.status(400).json({ error: "Path escapes target directory" });
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
if (setImageCacheHeaders(req, res, stats)) {
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
const mimeType = mimeLookup(absPath) || "application/octet-stream";
|
|
782
|
-
res.setHeader("Content-Type", mimeType);
|
|
783
|
-
res.setHeader("Content-Length", String(stats.size));
|
|
784
|
-
await pipeline(createReadStream(absPath), res);
|
|
785
|
-
}
|
|
786
|
-
catch (error) {
|
|
787
|
-
const message = error instanceof Error ? error.message : "Unable to stream screenshot";
|
|
788
|
-
if (!res.headersSent) {
|
|
789
|
-
res.status(400).json({ error: message });
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
res.destroy();
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
app.delete("/api/screenshots/file", async (req, res) => {
|
|
796
|
-
try {
|
|
797
|
-
const requestedName = getSingleQueryValue(req.query.name);
|
|
798
|
-
if (!requestedName) {
|
|
799
|
-
res.status(400).json({ error: "Missing ?name=..." });
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
|
|
803
|
-
await fs.unlink(absPath);
|
|
804
|
-
res.setHeader("Cache-Control", "no-store");
|
|
805
|
-
res.status(204).end();
|
|
806
|
-
}
|
|
807
|
-
catch (error) {
|
|
808
|
-
const nodeError = error;
|
|
809
|
-
if (nodeError.code === "ENOENT") {
|
|
810
|
-
res.status(404).json({ error: "File not found" });
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
const message = error instanceof Error ? error.message : "Unable to delete screenshot";
|
|
814
|
-
res.status(400).json({ error: message });
|
|
815
|
-
}
|
|
816
|
-
});
|
|
817
846
|
app.get("/api/text", async (req, res) => {
|
|
818
847
|
try {
|
|
819
848
|
const requestedPath = getSingleQueryValue(req.query.path);
|
|
@@ -907,6 +936,7 @@ app.use((error, _req, res, _next) => {
|
|
|
907
936
|
app.listen(PORT, HOST, () => {
|
|
908
937
|
console.log(`[remote-workspace] root: ${REPO_ROOT}`);
|
|
909
938
|
console.log(`[remote-workspace] http://${HOST}:${PORT}`);
|
|
939
|
+
console.log(`[remote-workspace] image directories: ${IMAGE_DIRECTORY_PATHS.join(", ")}`);
|
|
910
940
|
if (BASIC_AUTH_PASSWORD.length > 0) {
|
|
911
941
|
console.log("[remote-workspace] basic auth: enabled");
|
|
912
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>
|