@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 +4 -2
- package/dist/launcher.js +50 -12
- package/dist/server.js +24 -1
- package/package.json +1 -1
- package/static/app.js +66 -7
- package/static/index.html +41 -15
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
|
|
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
|
|
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
|
|
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
package/static/app.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Theme —
|
|
2
|
+
// Theme — sync with early head initialization
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
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: "
|
|
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:
|
|
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 === "
|
|
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
|
|
323
|
-
switchTab(localStorage.getItem("workspace-tab") || "
|
|
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
|
|
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
|
|
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="
|
|
101
|
-
|
|
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
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
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>
|