@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 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/` and `.playwright-mcp/` from the UI
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 dotfiles/dot-directories (for example `.env`, `.git`)
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 still blocks all hidden paths and gitignored paths
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
- ## Screenshots
121
+ ## Folder Image Gallery
115
122
 
116
- - `GET /api/screenshots/list` lists images in `REPO_ROOT/.playwright-mcp`
117
- - `GET /api/screenshots/file?name=<filename>` streams one screenshot image
118
- - `DELETE /api/screenshots/file?name=<filename>` deletes one screenshot image
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`, `/api/screenshots/file`, image responses from `/api/file`) with short-lived cache headers and validators.
123
- - The client keeps a small local metadata cache (tree + clipboard/screenshot lists) and hydrates immediately on reload, then refreshes in the background.
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 SCREENSHOTS_DIRECTORY_NAME = ".playwright-mcp";
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.split("/").some((segment) => segment.startsWith("."));
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
- const ignoredPathSet = await getGitIgnoredPathSet([repoRelativePath]);
261
- if (ignoredPathSet.has(repoRelativePath)) {
262
- throw new Error("Gitignored paths are not accessible");
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 allPaths = String(stdout)
646
+ const gitPaths = String(stdout)
521
647
  .split(/\r?\n/)
522
648
  .map((line) => line.trim())
523
649
  .filter(Boolean);
524
- // Filter hidden paths
525
- const visiblePaths = allPaths.filter((p) => !isHiddenRepoRelativePath(p));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haowjy/remote-workspace",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Mobile-friendly web workspace for SSH + tmux workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- state.activeTab = tabName;
354
- localStorage.setItem("workspace-tab", tabName);
355
- tabPanels.forEach((p) => p.classList.toggle("hidden", p.id !== `panel-${tabName}`));
356
- tabButtons.forEach((b) => b.classList.toggle("active", b.dataset.tab === tabName));
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-full flex items-center gap-1.5 px-2 py-[3px] text-sm rounded-md transition-colors group ${
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
- if (!isDesktopLayout()) {
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) openFile(state.selectedPath).catch(handleActionError);
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(), loadScreenshots()]).then(() => {
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>