@haowjy/remote-workspace 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
13
  - Hide and block access to configured path segments (always hides `.git`)
14
+ - Allow specific gitignored/hidden image folders (for example `.playwright-mcp`) via config
13
15
  - Dedicated collapsible `.clipboard` panel for upload + quick image viewing
14
16
 
15
17
  This app is intentionally **no text editing** to keep remote access simple and lower risk.
@@ -63,6 +65,7 @@ cd /path/to/your/repo && pnpm dev
63
65
  pnpm dev -- config /path/to/config
64
66
  pnpm dev -- port 18111
65
67
  pnpm dev -- always-hidden .git,.env,.secrets
68
+ pnpm dev -- image-dirs .clipboard,.playwright-mcp
66
69
  pnpm dev -- install
67
70
  pnpm dev -- no-serve
68
71
  pnpm dev -- password your-password
@@ -78,6 +81,7 @@ pnpm dev -- password your-password --funnel
78
81
  - `REMOTE_WS_PASSWORD` (optional, enables HTTP Basic Auth when set)
79
82
  - `REMOTE_WS_CONFIG_FILE` (optional config file path override)
80
83
  - `REMOTE_WS_ALWAYS_HIDDEN` (optional comma-separated extra hidden path segments)
84
+ - `REMOTE_WS_IMAGE_DIRS` (optional comma-separated repo-relative folders to expose for image browsing; default `.clipboard`)
81
85
  - `REPO_ROOT` (injected by launcher script)
82
86
 
83
87
  Password config file format (default: repo root `.remote-workspace.conf`):
@@ -106,6 +110,7 @@ Config file selection precedence:
106
110
  - `.clipboard` panel uses dedicated clipboard endpoints (`/api/clipboard/upload`, `/api/clipboard/list`, `/api/clipboard/file`)
107
111
  - Main repository browser blocks always-hidden path segments (`.git` + optional configured segments)
108
112
  - Gitignored paths are hidden/blocked (for example `node_modules/`, build artifacts, local secrets)
113
+ - `REMOTE_WS_IMAGE_DIRS` / `--image-dirs` lets you allow specific hidden/gitignored folders in Files view (for example `.playwright-mcp`)
109
114
  - Accepted upload types are images only (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `heic`, `heif`, `avif`)
110
115
  - Clipboard panel supports both file picker and `Paste From Clipboard` button (when browser clipboard image API is available)
111
116
  - Upload requires `name` query parameter (filename is user-controlled)
@@ -113,16 +118,16 @@ Config file selection precedence:
113
118
  - Multipart field names accepted: `file` (current UI) and `files` (legacy cached UI compatibility)
114
119
  - Legacy alias: `/api/upload` is still accepted for older cached clients
115
120
 
116
- ## Screenshots
121
+ ## Folder Image Gallery
117
122
 
118
- - `GET /api/screenshots/list` lists images in `REPO_ROOT/.playwright-mcp`
119
- - `GET /api/screenshots/file?name=<filename>` streams one screenshot image
120
- - `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.
121
126
 
122
127
  ## Caching
123
128
 
124
- - The browser now caches image bytes (`/api/clipboard/file`, `/api/screenshots/file`, image responses from `/api/file`) with short-lived cache headers and validators.
125
- - 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.
126
131
  - Refresh buttons bypass local metadata cache and force a new server fetch.
127
132
 
128
133
  ## Tailscale
package/dist/launcher.js CHANGED
@@ -15,6 +15,7 @@ Options:
15
15
  --config <path> Config file path (default uses precedence search)
16
16
  --port <port> Listen port (default: REMOTE_WS_PORT or 18080)
17
17
  --always-hidden <csv> Extra always-hidden path segments (comma-separated)
18
+ --image-dirs <csv> Visible image folders (repo-relative, comma-separated)
18
19
  --install Force dependency install before start
19
20
  --skip-install Skip install check even if node_modules is missing
20
21
  --no-serve Skip tailscale serve setup
@@ -29,6 +30,7 @@ Examples:
29
30
  pnpm dev -- --config ~/.config/remote-workspace/config
30
31
  pnpm dev -- --port 18111
31
32
  pnpm dev -- --always-hidden .git,.env,.secrets
33
+ pnpm dev -- --image-dirs .clipboard,.playwright-mcp
32
34
  pnpm dev -- --password
33
35
  pnpm dev -- --password mypass --serve
34
36
  pnpm dev -- --password mypass --funnel
@@ -66,6 +68,43 @@ function parseAlwaysHiddenCsv(rawValue, sourceLabel) {
66
68
  }
67
69
  return Array.from(normalizedSegments).join(",");
68
70
  }
71
+ function normalizeRepoRelativePath(rawValue, sourceLabel) {
72
+ const trimmed = rawValue.trim();
73
+ if (!trimmed) {
74
+ return "";
75
+ }
76
+ if (trimmed.includes("\u0000")) {
77
+ fail(`Invalid ${sourceLabel} path "${rawValue}": path must not contain null bytes.`);
78
+ }
79
+ const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")).replace(/^\.\//, "");
80
+ if (!normalized || normalized === ".") {
81
+ return "";
82
+ }
83
+ if (normalized === ".." || normalized.startsWith("../")) {
84
+ fail(`Invalid ${sourceLabel} path "${rawValue}": path must stay inside repository.`);
85
+ }
86
+ if (path.posix.isAbsolute(normalized)) {
87
+ fail(`Invalid ${sourceLabel} path "${rawValue}": path must be repo-relative.`);
88
+ }
89
+ if (normalized.split("/").includes(".git")) {
90
+ fail(`Invalid ${sourceLabel} path "${rawValue}": .git cannot be exposed.`);
91
+ }
92
+ return normalized;
93
+ }
94
+ function parseImageDirsCsv(rawValue, sourceLabel) {
95
+ const normalizedPaths = new Set();
96
+ for (const rawPath of rawValue.split(",")) {
97
+ const normalizedPath = normalizeRepoRelativePath(rawPath, sourceLabel);
98
+ if (!normalizedPath) {
99
+ continue;
100
+ }
101
+ normalizedPaths.add(normalizedPath);
102
+ }
103
+ if (normalizedPaths.size === 0) {
104
+ fail(`Invalid ${sourceLabel} value: provide at least one repo-relative path.`);
105
+ }
106
+ return Array.from(normalizedPaths).join(",");
107
+ }
69
108
  function parseConfigPassword(configFilePath) {
70
109
  if (!existsSync(configFilePath)) {
71
110
  return "";
@@ -120,6 +159,7 @@ function parseArgs(argv) {
120
159
  let configFileFromArg = null;
121
160
  let configFile = "";
122
161
  let alwaysHidden = null;
162
+ let imageDirs = null;
123
163
  for (let i = 0; i < argv.length; i += 1) {
124
164
  const arg = argv[i];
125
165
  switch (arg) {
@@ -159,6 +199,15 @@ function parseArgs(argv) {
159
199
  i += 1;
160
200
  break;
161
201
  }
202
+ case "--image-dirs": {
203
+ const next = argv[i + 1];
204
+ if (!next || next.startsWith("--")) {
205
+ fail("Missing value for --image-dirs.");
206
+ }
207
+ imageDirs = parseImageDirsCsv(next, "--image-dirs");
208
+ i += 1;
209
+ break;
210
+ }
162
211
  case "--install":
163
212
  forceInstall = true;
164
213
  break;
@@ -259,6 +308,7 @@ function parseArgs(argv) {
259
308
  workspacePassword,
260
309
  configFile,
261
310
  alwaysHidden,
311
+ imageDirs,
262
312
  };
263
313
  }
264
314
  function hasCommand(commandName) {
@@ -330,6 +380,15 @@ function main() {
330
380
  else {
331
381
  console.log("Always hidden segments: .git");
332
382
  }
383
+ if (args.imageDirs !== null) {
384
+ console.log(`Visible image folders: ${args.imageDirs}`);
385
+ }
386
+ else if (process.env.REMOTE_WS_IMAGE_DIRS) {
387
+ console.log(`Visible image folders: ${process.env.REMOTE_WS_IMAGE_DIRS}`);
388
+ }
389
+ else {
390
+ console.log("Visible image folders: .clipboard");
391
+ }
333
392
  console.log("");
334
393
  const childEnv = {
335
394
  ...process.env,
@@ -340,6 +399,9 @@ function main() {
340
399
  if (args.alwaysHidden !== null) {
341
400
  childEnv.REMOTE_WS_ALWAYS_HIDDEN = args.alwaysHidden;
342
401
  }
402
+ if (args.imageDirs !== null) {
403
+ childEnv.REMOTE_WS_IMAGE_DIRS = args.imageDirs;
404
+ }
343
405
  const child = runBuiltServer
344
406
  ? spawn("node", [builtServerPath], {
345
407
  stdio: "inherit",
package/dist/server.js CHANGED
@@ -9,6 +9,7 @@ import { pipeline } from "node:stream/promises";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { promisify } from "node:util";
11
11
  import { lookup as mimeLookup } from "mime-types";
12
+ const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
12
13
  function parseIntegerFromEnv(envName, fallbackValue, options) {
13
14
  const rawValue = process.env[envName];
14
15
  const parsedValue = Number.parseInt(rawValue ?? String(fallbackValue), 10);
@@ -28,6 +29,29 @@ function assertValidHiddenSegment(segment, sourceName) {
28
29
  throw new Error(`Invalid ${sourceName} segment "${segment}": segments must not contain path separators or null bytes`);
29
30
  }
30
31
  }
32
+ function normalizeRepoRelativeDirectoryPath(rawPath, sourceName) {
33
+ const trimmed = rawPath.trim();
34
+ if (!trimmed) {
35
+ return "";
36
+ }
37
+ if (trimmed.includes("\u0000")) {
38
+ throw new Error(`Invalid ${sourceName} path "${rawPath}": path must not contain null bytes`);
39
+ }
40
+ const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/")).replace(/^\.\//, "");
41
+ if (!normalized || normalized === ".") {
42
+ return "";
43
+ }
44
+ if (normalized === ".." || normalized.startsWith("../")) {
45
+ throw new Error(`Invalid ${sourceName} path "${rawPath}": path must stay inside repository`);
46
+ }
47
+ if (path.posix.isAbsolute(normalized)) {
48
+ throw new Error(`Invalid ${sourceName} path "${rawPath}": path must be repo-relative`);
49
+ }
50
+ if (normalized.split("/").includes(".git")) {
51
+ throw new Error(`Invalid ${sourceName} path "${rawPath}": .git cannot be exposed`);
52
+ }
53
+ return normalized;
54
+ }
31
55
  function parseAlwaysHiddenSegments(rawValue) {
32
56
  const segments = new Set([".git"]);
33
57
  if (rawValue === undefined) {
@@ -43,6 +67,21 @@ function parseAlwaysHiddenSegments(rawValue) {
43
67
  }
44
68
  return segments;
45
69
  }
70
+ function parseImageDirectoryPaths(rawValue) {
71
+ const parsed = new Set();
72
+ const candidates = rawValue === undefined ? [CLIPBOARD_DIRECTORY_NAME] : rawValue.split(",");
73
+ for (const candidate of candidates) {
74
+ const normalizedPath = normalizeRepoRelativeDirectoryPath(candidate, "REMOTE_WS_IMAGE_DIRS");
75
+ if (!normalizedPath) {
76
+ continue;
77
+ }
78
+ parsed.add(normalizedPath);
79
+ }
80
+ if (parsed.size === 0) {
81
+ throw new Error("Invalid REMOTE_WS_IMAGE_DIRS: provide at least one repo-relative path");
82
+ }
83
+ return Array.from(parsed);
84
+ }
46
85
  const REPO_ROOT = path.resolve(process.env.REPO_ROOT ?? process.cwd());
47
86
  const HOST = "127.0.0.1";
48
87
  const PORT = parseIntegerFromEnv("REMOTE_WS_PORT", 18080, { min: 1, max: 65535 });
@@ -55,10 +94,8 @@ const MAX_UPLOAD_BYTES = parseIntegerFromEnv("REMOTE_WS_MAX_UPLOAD_BYTES", 26_21
55
94
  const MAX_TREE_ENTRIES = parseIntegerFromEnv("REMOTE_WS_MAX_TREE_ENTRIES", 5000, {
56
95
  min: 1,
57
96
  });
58
- const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
59
97
  const CLIPBOARD_DIRECTORY_PATH = path.resolve(REPO_ROOT, CLIPBOARD_DIRECTORY_NAME);
60
- const SCREENSHOTS_DIRECTORY_NAME = ".playwright-mcp";
61
- const SCREENSHOTS_DIRECTORY_PATH = path.resolve(REPO_ROOT, SCREENSHOTS_DIRECTORY_NAME);
98
+ const IMAGE_DIRECTORY_PATHS = parseImageDirectoryPaths(process.env.REMOTE_WS_IMAGE_DIRS);
62
99
  const ALLOWED_IMAGE_EXTENSIONS = new Set([
63
100
  ".png",
64
101
  ".jpg",
@@ -240,6 +277,16 @@ function toRepoRelativePath(absPath) {
240
277
  }
241
278
  return relative.split(path.sep).join("/");
242
279
  }
280
+ function pathStartsWithDirectory(repoRelativePath, repoRelativeDirectoryPath) {
281
+ return (repoRelativePath === repoRelativeDirectoryPath ||
282
+ repoRelativePath.startsWith(`${repoRelativeDirectoryPath}/`));
283
+ }
284
+ function isConfiguredImageDirectoryPath(repoRelativePath) {
285
+ if (!repoRelativePath) {
286
+ return false;
287
+ }
288
+ return IMAGE_DIRECTORY_PATHS.some((directoryPath) => pathStartsWithDirectory(repoRelativePath, directoryPath));
289
+ }
243
290
  function isHiddenRepoRelativePath(repoRelativePath) {
244
291
  if (!repoRelativePath) {
245
292
  return false;
@@ -249,7 +296,8 @@ function isHiddenRepoRelativePath(repoRelativePath) {
249
296
  .some((segment) => ALWAYS_HIDDEN_SEGMENTS.has(segment));
250
297
  }
251
298
  function isBlockedHiddenRepoRelativePath(repoRelativePath) {
252
- return isHiddenRepoRelativePath(repoRelativePath);
299
+ return (isHiddenRepoRelativePath(repoRelativePath) &&
300
+ !isConfiguredImageDirectoryPath(repoRelativePath));
253
301
  }
254
302
  function parseGitIgnoredStdout(stdout) {
255
303
  if (!stdout) {
@@ -280,9 +328,11 @@ async function assertPathAccessible(absPath, options) {
280
328
  throw new Error("Hidden paths are not accessible");
281
329
  }
282
330
  if (!options?.allowGitIgnored && repoRelativePath) {
283
- const ignoredPathSet = await getGitIgnoredPathSet([repoRelativePath]);
284
- if (ignoredPathSet.has(repoRelativePath)) {
285
- 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
+ }
286
336
  }
287
337
  }
288
338
  }
@@ -537,15 +587,69 @@ function sortTree(node) {
537
587
  sortTree(child);
538
588
  }
539
589
  }
590
+ async function collectConfiguredImageDirectoryFiles(maxFiles) {
591
+ const collectedPaths = [];
592
+ for (const repoRelativeDirectoryPath of IMAGE_DIRECTORY_PATHS) {
593
+ if (collectedPaths.length >= maxFiles) {
594
+ break;
595
+ }
596
+ const absoluteDirectoryPath = resolveRepoPath(repoRelativeDirectoryPath);
597
+ let stats;
598
+ try {
599
+ stats = await fs.stat(absoluteDirectoryPath);
600
+ }
601
+ catch {
602
+ continue;
603
+ }
604
+ if (!stats.isDirectory()) {
605
+ continue;
606
+ }
607
+ const stack = [absoluteDirectoryPath];
608
+ while (stack.length > 0 && collectedPaths.length < maxFiles) {
609
+ const currentDirectoryPath = stack.pop();
610
+ if (!currentDirectoryPath) {
611
+ break;
612
+ }
613
+ let dirEntries;
614
+ try {
615
+ dirEntries = await fs.readdir(currentDirectoryPath, {
616
+ withFileTypes: true,
617
+ });
618
+ }
619
+ catch {
620
+ continue;
621
+ }
622
+ for (const dirEntry of dirEntries) {
623
+ if (dirEntry.isSymbolicLink()) {
624
+ continue;
625
+ }
626
+ const childPath = path.join(currentDirectoryPath, dirEntry.name);
627
+ if (dirEntry.isDirectory()) {
628
+ stack.push(childPath);
629
+ continue;
630
+ }
631
+ if (!dirEntry.isFile()) {
632
+ continue;
633
+ }
634
+ collectedPaths.push(toRepoRelativePath(childPath));
635
+ if (collectedPaths.length >= maxFiles) {
636
+ break;
637
+ }
638
+ }
639
+ }
640
+ }
641
+ return collectedPaths;
642
+ }
540
643
  app.get("/api/tree", async (_req, res) => {
541
644
  try {
542
645
  const { stdout } = await execFileAsync("git", ["-C", REPO_ROOT, "ls-files", "--cached", "--others", "--exclude-standard"], { maxBuffer: 10 * 1024 * 1024 });
543
- const allPaths = String(stdout)
646
+ const gitPaths = String(stdout)
544
647
  .split(/\r?\n/)
545
648
  .map((line) => line.trim())
546
649
  .filter(Boolean);
547
- // Filter hidden paths
548
- 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));
549
653
  const truncated = visiblePaths.length > MAX_TREE_ENTRIES;
550
654
  const paths = truncated ? visiblePaths.slice(0, MAX_TREE_ENTRIES) : visiblePaths;
551
655
  const root = buildTreeFromPaths(paths);
@@ -596,7 +700,8 @@ app.get("/api/list", async (req, res) => {
596
700
  }
597
701
  const ignoredPathSet = await getGitIgnoredPathSet(candidates.map((candidate) => candidate.childRepoRelativePath));
598
702
  for (const candidate of candidates) {
599
- if (ignoredPathSet.has(candidate.childRepoRelativePath)) {
703
+ if (ignoredPathSet.has(candidate.childRepoRelativePath) &&
704
+ !isConfiguredImageDirectoryPath(candidate.childRepoRelativePath)) {
600
705
  skippedIgnored += 1;
601
706
  continue;
602
707
  }
@@ -738,105 +843,6 @@ app.delete("/api/clipboard/file", async (req, res) => {
738
843
  res.status(400).json({ error: message });
739
844
  }
740
845
  });
741
- app.get("/api/screenshots/list", async (_req, res) => {
742
- try {
743
- const dirEntries = await fs.readdir(SCREENSHOTS_DIRECTORY_PATH, {
744
- withFileTypes: true,
745
- }).catch(() => []);
746
- const entries = [];
747
- for (const dirEntry of dirEntries) {
748
- if (!dirEntry.isFile())
749
- continue;
750
- const extension = path.extname(dirEntry.name).toLowerCase();
751
- if (!ALLOWED_IMAGE_EXTENSIONS.has(extension))
752
- continue;
753
- const absPath = path.join(SCREENSHOTS_DIRECTORY_PATH, dirEntry.name);
754
- let stats;
755
- try {
756
- stats = await fs.stat(absPath);
757
- }
758
- catch {
759
- continue;
760
- }
761
- entries.push({
762
- name: dirEntry.name,
763
- path: `${SCREENSHOTS_DIRECTORY_NAME}/${dirEntry.name}`,
764
- type: "file",
765
- size: stats.size,
766
- modifiedAt: stats.mtime.toISOString(),
767
- });
768
- }
769
- entries.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
770
- setMetadataCacheHeaders(res);
771
- res.json({
772
- directory: SCREENSHOTS_DIRECTORY_NAME,
773
- entries,
774
- });
775
- }
776
- catch (error) {
777
- const message = error instanceof Error ? error.message : "Unable to list screenshots";
778
- res.status(400).json({ error: message });
779
- }
780
- });
781
- app.get("/api/screenshots/file", async (req, res) => {
782
- try {
783
- const requestedName = getSingleQueryValue(req.query.name);
784
- if (!requestedName) {
785
- res.status(400).json({ error: "Missing ?name=..." });
786
- return;
787
- }
788
- const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
789
- const stats = await fs.stat(absPath);
790
- if (!stats.isFile()) {
791
- res.status(400).json({ error: "Not a file" });
792
- return;
793
- }
794
- const realPath = await fs.realpath(absPath);
795
- const relativeToDirectory = path.relative(SCREENSHOTS_DIRECTORY_PATH, realPath);
796
- if (relativeToDirectory.startsWith("..") ||
797
- path.isAbsolute(relativeToDirectory)) {
798
- res.status(400).json({ error: "Path escapes target directory" });
799
- return;
800
- }
801
- if (setImageCacheHeaders(req, res, stats)) {
802
- return;
803
- }
804
- const mimeType = mimeLookup(absPath) || "application/octet-stream";
805
- res.setHeader("Content-Type", mimeType);
806
- res.setHeader("Content-Length", String(stats.size));
807
- await pipeline(createReadStream(absPath), res);
808
- }
809
- catch (error) {
810
- const message = error instanceof Error ? error.message : "Unable to stream screenshot";
811
- if (!res.headersSent) {
812
- res.status(400).json({ error: message });
813
- return;
814
- }
815
- res.destroy();
816
- }
817
- });
818
- app.delete("/api/screenshots/file", async (req, res) => {
819
- try {
820
- const requestedName = getSingleQueryValue(req.query.name);
821
- if (!requestedName) {
822
- res.status(400).json({ error: "Missing ?name=..." });
823
- return;
824
- }
825
- const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
826
- await fs.unlink(absPath);
827
- res.setHeader("Cache-Control", "no-store");
828
- res.status(204).end();
829
- }
830
- catch (error) {
831
- const nodeError = error;
832
- if (nodeError.code === "ENOENT") {
833
- res.status(404).json({ error: "File not found" });
834
- return;
835
- }
836
- const message = error instanceof Error ? error.message : "Unable to delete screenshot";
837
- res.status(400).json({ error: message });
838
- }
839
- });
840
846
  app.get("/api/text", async (req, res) => {
841
847
  try {
842
848
  const requestedPath = getSingleQueryValue(req.query.path);
@@ -930,6 +936,7 @@ app.use((error, _req, res, _next) => {
930
936
  app.listen(PORT, HOST, () => {
931
937
  console.log(`[remote-workspace] root: ${REPO_ROOT}`);
932
938
  console.log(`[remote-workspace] http://${HOST}:${PORT}`);
939
+ console.log(`[remote-workspace] image directories: ${IMAGE_DIRECTORY_PATHS.join(", ")}`);
933
940
  if (BASIC_AUTH_PASSWORD.length > 0) {
934
941
  console.log("[remote-workspace] basic auth: enabled");
935
942
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haowjy/remote-workspace",
3
- "version": "0.1.3",
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>