@haowjy/remote-workspace 0.1.0 → 0.1.2

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/dist/launcher.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  const currentFilePath = fileURLToPath(import.meta.url);
7
7
  const currentDirectoryPath = path.dirname(currentFilePath);
8
8
  const appDir = path.resolve(currentDirectoryPath, "..");
9
+ const builtServerPath = path.resolve(currentDirectoryPath, "server.js");
9
10
  function printUsage() {
10
11
  console.log(`Usage: pnpm dev -- [options]
11
12
 
@@ -249,23 +250,26 @@ function runSync(commandName, args) {
249
250
  }
250
251
  function main() {
251
252
  const args = parseArgs(process.argv.slice(2));
252
- if (!hasCommand("pnpm")) {
253
- fail("pnpm is required but not installed.");
254
- }
253
+ const runBuiltServer = existsSync(builtServerPath);
255
254
  if (args.serveEnabled) {
256
255
  if (!hasCommand("tailscale")) {
257
- fail("tailscale is not installed, but serve mode is enabled.\nUse local-only mode: pnpm dev -- --no-serve (or pnpm dev -- --password <pwd>).\nOr install Tailscale, run 'tailscale up', then restart with --serve.");
256
+ fail("tailscale is not installed, but serve mode is enabled.\nUse local-only mode with --no-serve (or --password <pwd>).\nOr install Tailscale, run 'tailscale up', then restart with --serve.");
258
257
  }
259
258
  runSyncOrFail("tailscale", ["status"], "tailscale is installed but not connected.\nRun 'tailscale up' and retry --serve, or use local-only mode with --no-serve.");
260
259
  }
261
- if (!existsSync(path.join(appDir, "package.json"))) {
262
- fail(`remote-workspace app not found at: ${appDir}`);
263
- }
264
- if (args.forceInstall) {
265
- runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
266
- }
267
- else if (!args.skipInstall && !existsSync(path.join(appDir, "node_modules"))) {
268
- runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
260
+ if (!runBuiltServer) {
261
+ if (!hasCommand("pnpm")) {
262
+ fail("pnpm is required but not installed.");
263
+ }
264
+ if (!existsSync(path.join(appDir, "package.json"))) {
265
+ fail(`remote-workspace app not found at: ${appDir}`);
266
+ }
267
+ if (args.forceInstall) {
268
+ runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
269
+ }
270
+ else if (!args.skipInstall && !existsSync(path.join(appDir, "node_modules"))) {
271
+ runSyncOrFail("pnpm", ["--dir", appDir, "install"], "Dependency install failed.");
272
+ }
269
273
  }
270
274
  if (args.serveEnabled) {
271
275
  console.log(`Configuring tailscale serve (https:443 -> 127.0.0.1:${args.port})`);
@@ -288,15 +292,25 @@ function main() {
288
292
  console.log("Auth: Basic Auth enabled");
289
293
  }
290
294
  console.log("");
291
- const child = spawn("pnpm", ["--dir", appDir, "dev:server"], {
292
- stdio: "inherit",
293
- env: {
294
- ...process.env,
295
- REPO_ROOT: args.repoRoot,
296
- REMOTE_WS_PORT: String(args.port),
297
- REMOTE_WS_PASSWORD: args.workspacePassword,
298
- },
299
- });
295
+ const child = runBuiltServer
296
+ ? spawn("node", [builtServerPath], {
297
+ 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
+ },
304
+ })
305
+ : spawn("pnpm", ["--dir", appDir, "dev:server"], {
306
+ 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
+ },
313
+ });
300
314
  child.on("exit", (code, signal) => {
301
315
  if (signal) {
302
316
  process.kill(process.pid, signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haowjy/remote-workspace",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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>