@haowjy/remote-workspace 0.1.1 → 0.1.3

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
@@ -9,7 +9,7 @@ Mobile-friendly read/upload web workspace for this repository.
9
9
  - Delete images from `.clipboard/` and `.playwright-mcp/` from the UI
10
10
  - Preview text files and images
11
11
  - Render Markdown with Mermaid diagrams
12
- - Hide and block access to dotfiles/dot-directories (for example `.env`, `.git`)
12
+ - Hide and block access to configured path segments (always hides `.git`)
13
13
  - Dedicated collapsible `.clipboard` panel for upload + quick image viewing
14
14
 
15
15
  This app is intentionally **no text editing** to keep remote access simple and lower risk.
@@ -62,6 +62,7 @@ cd /path/to/your/repo && pnpm dev
62
62
  ```bash
63
63
  pnpm dev -- config /path/to/config
64
64
  pnpm dev -- port 18111
65
+ pnpm dev -- always-hidden .git,.env,.secrets
65
66
  pnpm dev -- install
66
67
  pnpm dev -- no-serve
67
68
  pnpm dev -- password your-password
@@ -76,6 +77,7 @@ pnpm dev -- password your-password --funnel
76
77
  - `REMOTE_WS_MAX_UPLOAD_BYTES` (default `26214400`)
77
78
  - `REMOTE_WS_PASSWORD` (optional, enables HTTP Basic Auth when set)
78
79
  - `REMOTE_WS_CONFIG_FILE` (optional config file path override)
80
+ - `REMOTE_WS_ALWAYS_HIDDEN` (optional comma-separated extra hidden path segments)
79
81
  - `REPO_ROOT` (injected by launcher script)
80
82
 
81
83
  Password config file format (default: repo root `.remote-workspace.conf`):
@@ -102,7 +104,7 @@ Config file selection precedence:
102
104
  - `POST /api/clipboard/upload` always writes to `REPO_ROOT/.clipboard`
103
105
  - `DELETE /api/clipboard/file?name=<filename>` deletes one image in `REPO_ROOT/.clipboard`
104
106
  - `.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
107
+ - Main repository browser blocks always-hidden path segments (`.git` + optional configured segments)
106
108
  - Gitignored paths are hidden/blocked (for example `node_modules/`, build artifacts, local secrets)
107
109
  - Accepted upload types are images only (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `heic`, `heif`, `avif`)
108
110
  - Clipboard panel supports both file picker and `Paste From Clipboard` button (when browser clipboard image API is available)
package/dist/launcher.js CHANGED
@@ -14,6 +14,7 @@ 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)
17
18
  --install Force dependency install before start
18
19
  --skip-install Skip install check even if node_modules is missing
19
20
  --no-serve Skip tailscale serve setup
@@ -27,6 +28,7 @@ Examples:
27
28
  pnpm dev -- --repo-root /path/to/repo
28
29
  pnpm dev -- --config ~/.config/remote-workspace/config
29
30
  pnpm dev -- --port 18111
31
+ pnpm dev -- --always-hidden .git,.env,.secrets
30
32
  pnpm dev -- --password
31
33
  pnpm dev -- --password mypass --serve
32
34
  pnpm dev -- --password mypass --funnel
@@ -47,6 +49,23 @@ function parsePort(rawPort) {
47
49
  }
48
50
  return value;
49
51
  }
52
+ function parseAlwaysHiddenCsv(rawValue, sourceLabel) {
53
+ const segments = rawValue
54
+ .split(",")
55
+ .map((value) => value.trim())
56
+ .filter(Boolean);
57
+ if (segments.length === 0) {
58
+ fail(`Invalid ${sourceLabel} value: provide at least one segment.`);
59
+ }
60
+ const normalizedSegments = new Set();
61
+ for (const segment of segments) {
62
+ if (segment.includes("/") || segment.includes("\\") || segment.includes("\u0000")) {
63
+ fail(`Invalid ${sourceLabel} segment "${segment}": segments must not contain path separators or null bytes.`);
64
+ }
65
+ normalizedSegments.add(segment);
66
+ }
67
+ return Array.from(normalizedSegments).join(",");
68
+ }
50
69
  function parseConfigPassword(configFilePath) {
51
70
  if (!existsSync(configFilePath)) {
52
71
  return "";
@@ -100,6 +119,7 @@ function parseArgs(argv) {
100
119
  let workspacePassword = "";
101
120
  let configFileFromArg = null;
102
121
  let configFile = "";
122
+ let alwaysHidden = null;
103
123
  for (let i = 0; i < argv.length; i += 1) {
104
124
  const arg = argv[i];
105
125
  switch (arg) {
@@ -130,6 +150,15 @@ function parseArgs(argv) {
130
150
  i += 1;
131
151
  break;
132
152
  }
153
+ case "--always-hidden": {
154
+ const next = argv[i + 1];
155
+ if (!next || next.startsWith("--")) {
156
+ fail("Missing value for --always-hidden.");
157
+ }
158
+ alwaysHidden = parseAlwaysHiddenCsv(next, "--always-hidden");
159
+ i += 1;
160
+ break;
161
+ }
133
162
  case "--install":
134
163
  forceInstall = true;
135
164
  break;
@@ -229,6 +258,7 @@ function parseArgs(argv) {
229
258
  passwordEnabled,
230
259
  workspacePassword,
231
260
  configFile,
261
+ alwaysHidden,
232
262
  };
233
263
  }
234
264
  function hasCommand(commandName) {
@@ -291,25 +321,33 @@ function main() {
291
321
  if (args.passwordEnabled) {
292
322
  console.log("Auth: Basic Auth enabled");
293
323
  }
324
+ if (args.alwaysHidden !== null) {
325
+ console.log(`Always hidden segments: .git + ${args.alwaysHidden}`);
326
+ }
327
+ else if (process.env.REMOTE_WS_ALWAYS_HIDDEN) {
328
+ console.log(`Always hidden segments: .git + ${process.env.REMOTE_WS_ALWAYS_HIDDEN}`);
329
+ }
330
+ else {
331
+ console.log("Always hidden segments: .git");
332
+ }
294
333
  console.log("");
334
+ const childEnv = {
335
+ ...process.env,
336
+ REPO_ROOT: args.repoRoot,
337
+ REMOTE_WS_PORT: String(args.port),
338
+ REMOTE_WS_PASSWORD: args.workspacePassword,
339
+ };
340
+ if (args.alwaysHidden !== null) {
341
+ childEnv.REMOTE_WS_ALWAYS_HIDDEN = args.alwaysHidden;
342
+ }
295
343
  const child = runBuiltServer
296
344
  ? spawn("node", [builtServerPath], {
297
345
  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
- },
346
+ env: childEnv,
304
347
  })
305
348
  : spawn("pnpm", ["--dir", appDir, "dev:server"], {
306
349
  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
- },
350
+ env: childEnv,
313
351
  });
314
352
  child.on("exit", (code, signal) => {
315
353
  if (signal) {
package/dist/server.js CHANGED
@@ -23,6 +23,26 @@ function parseIntegerFromEnv(envName, fallbackValue, options) {
23
23
  }
24
24
  return parsedValue;
25
25
  }
26
+ function assertValidHiddenSegment(segment, sourceName) {
27
+ if (segment.includes("/") || segment.includes("\\") || segment.includes("\u0000")) {
28
+ throw new Error(`Invalid ${sourceName} segment "${segment}": segments must not contain path separators or null bytes`);
29
+ }
30
+ }
31
+ function parseAlwaysHiddenSegments(rawValue) {
32
+ const segments = new Set([".git"]);
33
+ if (rawValue === undefined) {
34
+ return segments;
35
+ }
36
+ for (const rawSegment of rawValue.split(",")) {
37
+ const segment = rawSegment.trim();
38
+ if (!segment) {
39
+ continue;
40
+ }
41
+ assertValidHiddenSegment(segment, "REMOTE_WS_ALWAYS_HIDDEN");
42
+ segments.add(segment);
43
+ }
44
+ return segments;
45
+ }
26
46
  const REPO_ROOT = path.resolve(process.env.REPO_ROOT ?? process.cwd());
27
47
  const HOST = "127.0.0.1";
28
48
  const PORT = parseIntegerFromEnv("REMOTE_WS_PORT", 18080, { min: 1, max: 65535 });
@@ -54,6 +74,7 @@ const ALLOWED_IMAGE_EXTENSIONS = new Set([
54
74
  const IMAGE_CACHE_CONTROL = "private, max-age=60, stale-while-revalidate=300";
55
75
  const METADATA_CACHE_CONTROL = "private, max-age=10, stale-while-revalidate=30";
56
76
  const BASIC_AUTH_PASSWORD = process.env.REMOTE_WS_PASSWORD ?? "";
77
+ const ALWAYS_HIDDEN_SEGMENTS = parseAlwaysHiddenSegments(process.env.REMOTE_WS_ALWAYS_HIDDEN);
57
78
  const AUTH_WINDOW_MS = 10 * 60 * 1000;
58
79
  const AUTH_MAX_ATTEMPTS = 20;
59
80
  const AUTH_BLOCK_MS = 15 * 60 * 1000;
@@ -223,7 +244,9 @@ function isHiddenRepoRelativePath(repoRelativePath) {
223
244
  if (!repoRelativePath) {
224
245
  return false;
225
246
  }
226
- return repoRelativePath.split("/").some((segment) => segment.startsWith("."));
247
+ return repoRelativePath
248
+ .split("/")
249
+ .some((segment) => ALWAYS_HIDDEN_SEGMENTS.has(segment));
227
250
  }
228
251
  function isBlockedHiddenRepoRelativePath(repoRelativePath) {
229
252
  return isHiddenRepoRelativePath(repoRelativePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haowjy/remote-workspace",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Mobile-friendly web workspace for SSH + tmux workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/static/app.js CHANGED
@@ -1,8 +1,22 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Theme — initialize before Tailwind processes classes
2
+ // Theme — sync with early head initialization
3
3
  // ---------------------------------------------------------------------------
4
- const savedTheme = localStorage.getItem("workspace-theme") || "dark";
4
+ function resolveSavedTheme() {
5
+ if (window.__workspaceTheme === "dark" || window.__workspaceTheme === "light") {
6
+ return window.__workspaceTheme;
7
+ }
8
+ try {
9
+ const savedTheme = localStorage.getItem("workspace-theme");
10
+ if (savedTheme === "dark" || savedTheme === "light") return savedTheme;
11
+ } catch {
12
+ // Ignore storage access errors.
13
+ }
14
+ return "light";
15
+ }
16
+
17
+ const savedTheme = resolveSavedTheme();
5
18
  document.documentElement.dataset.theme = savedTheme;
19
+ window.__workspaceTheme = savedTheme;
6
20
 
7
21
  // ---------------------------------------------------------------------------
8
22
  // Constants
@@ -46,7 +60,7 @@ const CACHE_KEYS = {
46
60
  // State
47
61
  // ---------------------------------------------------------------------------
48
62
  const state = {
49
- activeTab: "clipboard",
63
+ activeTab: "files",
50
64
  // Tree: top-level entries loaded eagerly, children loaded on expand
51
65
  topLevelEntries: [],
52
66
  dirChildren: new Map(),
@@ -64,6 +78,7 @@ const state = {
64
78
  clipboardApiMode: "modern",
65
79
  pendingUploadFile: null,
66
80
  searchDebounceTimer: null,
81
+ filesMobileMode: "explorer",
67
82
  // Screenshots
68
83
  screenshotEntries: [],
69
84
  screenshotsRequestId: 0,
@@ -88,6 +103,9 @@ const uploadNameInput = $("upload-name-input");
88
103
  const uploadBtn = $("upload-btn");
89
104
  const clipboardRefreshBtn = $("clipboard-refresh-btn");
90
105
  const treeRefreshBtn = $("tree-refresh-btn");
106
+ const fileTreeContainer = $("file-tree-container");
107
+ const fileViewerContainer = $("file-viewer-container");
108
+ const mobileExplorerBackBtn = $("mobile-explorer-back-btn");
91
109
  const screenshotsGrid = $("screenshots-grid");
92
110
  const screenshotsStatusEl = $("screenshots-status");
93
111
  const screenshotsRefreshBtn = $("screenshots-refresh-btn");
@@ -125,7 +143,7 @@ const md = window.markdownit({
125
143
  window.mermaid.initialize({
126
144
  startOnLoad: false,
127
145
  securityLevel: "strict",
128
- theme: (localStorage.getItem("workspace-theme") || "dark") === "dark" ? "dark" : "default",
146
+ theme: savedTheme === "dark" ? "dark" : "default",
129
147
  });
130
148
 
131
149
  // ---------------------------------------------------------------------------
@@ -198,7 +216,9 @@ function updateThemeIcon(theme) {
198
216
  // Set initial icon to match saved theme
199
217
  updateThemeIcon(savedTheme);
200
218
  // Set initial hljs theme to match
201
- if (savedTheme === "light") {
219
+ if (savedTheme === "dark") {
220
+ hljsThemeLink.href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css";
221
+ } else {
202
222
  hljsThemeLink.href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css";
203
223
  }
204
224
 
@@ -207,6 +227,7 @@ function toggleTheme() {
207
227
  const next = current === "dark" ? "light" : "dark";
208
228
  document.documentElement.dataset.theme = next;
209
229
  localStorage.setItem("workspace-theme", next);
230
+ window.__workspaceTheme = next;
210
231
 
211
232
  updateThemeIcon(next);
212
233
 
@@ -301,6 +322,27 @@ function appendVersionQuery(url, entry) {
301
322
  return `${url}${separator}v=${version}`;
302
323
  }
303
324
 
325
+ function isDesktopLayout() {
326
+ return window.matchMedia("(min-width: 1024px)").matches;
327
+ }
328
+
329
+ function syncFilesLayout() {
330
+ if (!fileTreeContainer || !fileViewerContainer) return;
331
+ const desktop = isDesktopLayout();
332
+ if (desktop) {
333
+ fileTreeContainer.classList.remove("hidden");
334
+ fileViewerContainer.classList.remove("hidden");
335
+ if (mobileExplorerBackBtn) mobileExplorerBackBtn.classList.add("hidden");
336
+ return;
337
+ }
338
+ const viewerMode = state.filesMobileMode === "viewer";
339
+ fileTreeContainer.classList.toggle("hidden", viewerMode);
340
+ fileViewerContainer.classList.toggle("hidden", !viewerMode);
341
+ if (mobileExplorerBackBtn) {
342
+ mobileExplorerBackBtn.classList.toggle("hidden", !viewerMode);
343
+ }
344
+ }
345
+
304
346
  // ---------------------------------------------------------------------------
305
347
  // Tab navigation
306
348
  // ---------------------------------------------------------------------------
@@ -312,6 +354,7 @@ function switchTab(tabName) {
312
354
  localStorage.setItem("workspace-tab", tabName);
313
355
  tabPanels.forEach((p) => p.classList.toggle("hidden", p.id !== `panel-${tabName}`));
314
356
  tabButtons.forEach((b) => b.classList.toggle("active", b.dataset.tab === tabName));
357
+ syncFilesLayout();
315
358
  refreshIcons();
316
359
  }
317
360
 
@@ -319,8 +362,8 @@ tabButtons.forEach((btn) => {
319
362
  btn.addEventListener("click", () => switchTab(btn.dataset.tab));
320
363
  });
321
364
 
322
- // Initialize tab — restore last active or default to clipboard
323
- switchTab(localStorage.getItem("workspace-tab") || "clipboard");
365
+ // Initialize tab — restore last active or default to files
366
+ switchTab(localStorage.getItem("workspace-tab") || "files");
324
367
 
325
368
  // ---------------------------------------------------------------------------
326
369
  // Lightbox
@@ -638,6 +681,10 @@ function handleSearch(query) {
638
681
  searchResultsEl.innerHTML = "";
639
682
  expandParentsOf(result.path);
640
683
  if (result.type === "directory") {
684
+ if (!isDesktopLayout()) {
685
+ state.filesMobileMode = "explorer";
686
+ syncFilesLayout();
687
+ }
641
688
  toggleDirectory(result.path);
642
689
  } else {
643
690
  openFile(result.path).catch(handleActionError);
@@ -678,6 +725,10 @@ async function openFile(filePath) {
678
725
  viewerRefreshBtn.classList.remove("hidden");
679
726
  expandParentsOf(filePath);
680
727
  renderTree();
728
+ if (!isDesktopLayout()) {
729
+ state.filesMobileMode = "viewer";
730
+ syncFilesLayout();
731
+ }
681
732
 
682
733
  viewerTitle.textContent = filePath;
683
734
  setStatus(`Opening ${filePath}...`);
@@ -1227,6 +1278,14 @@ screenshotsRefreshBtn.onclick = () => {
1227
1278
  viewerRefreshBtn.onclick = () => {
1228
1279
  if (state.selectedPath) openFile(state.selectedPath).catch(handleActionError);
1229
1280
  };
1281
+ if (mobileExplorerBackBtn) {
1282
+ mobileExplorerBackBtn.onclick = () => {
1283
+ state.filesMobileMode = "explorer";
1284
+ syncFilesLayout();
1285
+ };
1286
+ }
1287
+ window.addEventListener("resize", syncFilesLayout);
1288
+ syncFilesLayout();
1230
1289
 
1231
1290
  // ---------------------------------------------------------------------------
1232
1291
  // Init
package/static/index.html CHANGED
@@ -4,6 +4,19 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
6
  <title>Workspace</title>
7
+ <script>
8
+ (() => {
9
+ let theme = "light";
10
+ try {
11
+ const savedTheme = localStorage.getItem("workspace-theme");
12
+ if (savedTheme === "dark" || savedTheme === "light") theme = savedTheme;
13
+ } catch {
14
+ // Ignore storage errors and keep light default.
15
+ }
16
+ document.documentElement.dataset.theme = theme;
17
+ window.__workspaceTheme = theme;
18
+ })();
19
+ </script>
7
20
  <script src="https://cdn.tailwindcss.com"></script>
8
21
  <script>
9
22
  tailwind.config = {
@@ -29,7 +42,15 @@
29
42
  <link rel="preconnect" href="https://fonts.googleapis.com" />
30
43
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
31
44
  <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
32
- <link id="hljs-theme" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
45
+ <link id="hljs-theme" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
46
+ <script>
47
+ if (window.__workspaceTheme === "dark") {
48
+ const hljsTheme = document.getElementById("hljs-theme");
49
+ if (hljsTheme) {
50
+ hljsTheme.href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css";
51
+ }
52
+ }
53
+ </script>
33
54
  <link rel="stylesheet" href="/styles.css" />
34
55
  </head>
35
56
  <body class="bg-ink-950 text-ink-100 font-sans h-[100dvh] flex flex-col overflow-hidden">
@@ -48,7 +69,7 @@
48
69
  <div class="flex-1 min-h-0 relative">
49
70
 
50
71
  <!-- Tab: Clipboard -->
51
- <section id="panel-clipboard" class="tab-panel absolute inset-0 flex flex-col">
72
+ <section id="panel-clipboard" class="tab-panel absolute inset-0 flex flex-col hidden">
52
73
  <div class="shrink-0 bg-ink-900/60 border-b border-ink-800 px-4 py-3 flex flex-wrap items-center gap-2">
53
74
  <label class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-brand bg-brand-light/10 border border-brand/20 rounded-lg cursor-pointer hover:bg-brand-light/20 transition-colors">
54
75
  <i data-lucide="image-plus" class="w-3.5 h-3.5"></i>
@@ -78,7 +99,7 @@
78
99
  </section>
79
100
 
80
101
  <!-- Tab: Files -->
81
- <section id="panel-files" class="tab-panel absolute inset-0 flex flex-col hidden">
102
+ <section id="panel-files" class="tab-panel absolute inset-0 flex flex-col">
82
103
  <!-- Search bar -->
83
104
  <div class="shrink-0 bg-ink-900/60 border-b border-ink-800 px-4 py-2.5 flex items-center gap-2">
84
105
  <div class="relative flex-1">
@@ -91,20 +112,25 @@
91
112
  />
92
113
  <div id="search-results" class="hidden absolute top-full left-0 right-0 mt-1 bg-ink-800 border border-ink-700 rounded-lg shadow-xl max-h-72 overflow-y-auto z-40"></div>
93
114
  </div>
94
- <button id="tree-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">
115
+ <button id="tree-refresh-btn" type="button" class="hidden lg:inline-flex p-1.5 text-ink-500 hover:text-ink-300 rounded-lg hover:bg-ink-800 transition-colors" title="Refresh">
95
116
  <i data-lucide="refresh-cw" class="w-4 h-4"></i>
96
117
  </button>
97
118
  </div>
98
- <!-- Split: tree + preview -->
119
+ <!-- Split: tree + preview (desktop) / single panel (mobile) -->
99
120
  <div class="flex-1 min-h-0 flex flex-col lg:flex-row">
100
- <div id="file-tree" class="shrink-0 lg:shrink lg:w-72 lg:min-w-[220px] lg:max-w-[360px] h-48 lg:h-auto overflow-y-auto overscroll-contain border-b lg:border-b-0 lg:border-r border-ink-800 px-1 py-1"></div>
101
- <div class="flex-1 min-h-0 flex flex-col">
121
+ <div id="file-tree-container" class="flex-1 min-h-0 lg:flex-none lg:w-72 lg:min-w-[220px] lg:max-w-[360px] border-b lg:border-b-0 lg:border-r border-ink-800">
122
+ <div id="file-tree" class="h-full overflow-y-auto overscroll-contain px-1 py-1"></div>
123
+ </div>
124
+ <div id="file-viewer-container" class="hidden flex flex-1 min-h-0 flex-col lg:flex">
102
125
  <div class="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-ink-800/60">
103
- <i data-lucide="eye" class="w-3.5 h-3.5 text-ink-500"></i>
104
- <span id="viewer-title" class="text-xs font-mono text-ink-400 truncate">Select a file</span>
105
- <button id="viewer-refresh-btn" type="button" class="ml-auto p-1 text-ink-500 hover:text-ink-300 rounded-lg hover:bg-ink-800 transition-colors hidden" title="Refresh file">
126
+ <button id="mobile-explorer-back-btn" type="button" class="hidden lg:hidden p-1 text-ink-500 hover:text-ink-300 rounded-lg hover:bg-ink-800 transition-colors" title="Back to files">
127
+ <i data-lucide="arrow-left" class="w-3.5 h-3.5"></i>
128
+ </button>
129
+ <i data-lucide="eye" class="hidden lg:block w-3.5 h-3.5 text-ink-500"></i>
130
+ <button id="viewer-refresh-btn" type="button" class="order-2 lg:order-none lg:ml-auto p-1 text-ink-500 hover:text-ink-300 rounded-lg hover:bg-ink-800 transition-colors hidden" title="Refresh file">
106
131
  <i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
107
132
  </button>
133
+ <span id="viewer-title" class="order-3 lg:order-none min-w-0 flex-1 text-xs font-mono text-ink-400 truncate">Select a file</span>
108
134
  </div>
109
135
  <div id="viewer" class="flex-1 overflow-y-auto overscroll-contain p-4">
110
136
  <div class="flex flex-col items-center justify-center h-full text-ink-600">
@@ -150,16 +176,16 @@
150
176
 
151
177
  <!-- Bottom tab bar -->
152
178
  <nav class="shrink-0 bg-ink-900 border-t border-ink-800 flex z-30 safe-bottom">
153
- <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="clipboard">
154
- <i data-lucide="clipboard" class="w-5 h-5"></i>
155
- <span class="text-[10px] font-semibold uppercase tracking-wide">Clipboard</span>
156
- <span id="tab-badge-clipboard" class="text-[9px] font-mono text-ink-600"></span>
157
- </button>
158
179
  <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="files">
159
180
  <i data-lucide="folder-tree" class="w-5 h-5"></i>
160
181
  <span class="text-[10px] font-semibold uppercase tracking-wide">Files</span>
161
182
  <span id="tab-badge-files" class="text-[9px] font-mono text-ink-600"></span>
162
183
  </button>
184
+ <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="clipboard">
185
+ <i data-lucide="clipboard" class="w-5 h-5"></i>
186
+ <span class="text-[10px] font-semibold uppercase tracking-wide">Clipboard</span>
187
+ <span id="tab-badge-clipboard" class="text-[9px] font-mono text-ink-600"></span>
188
+ </button>
163
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">
164
190
  <i data-lucide="camera" class="w-5 h-5"></i>
165
191
  <span class="text-[10px] font-semibold uppercase tracking-wide">Screenshots</span>