clay-server 3.5.1-beta.1 → 3.6.0-beta.1

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/lib/daemon.js CHANGED
@@ -29,6 +29,7 @@ var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, removeFromCl
29
29
  var { createIPCServer } = require("./ipc");
30
30
  var { createServer, generateAuthToken } = require("./server");
31
31
  var osUsersMod = require("./os-users");
32
+ var { scheduleOsUserDiagnosticsAsync } = require("./os-user-diagnostics");
32
33
  var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = osUsersMod;
33
34
  var usersModule = require("./users");
34
35
  var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
@@ -1460,6 +1461,12 @@ function startListening() {
1460
1461
  console.error("[daemon] Startup provisioning error:", provErr.message);
1461
1462
  }
1462
1463
  console.log("[daemon] Startup OS users check complete.");
1464
+
1465
+ // Diagnostics run after provisioning, never affect provisioning or serving.
1466
+ scheduleOsUserDiagnosticsAsync({
1467
+ getUsers: function() { return usersModule.getAllUsers(); },
1468
+ getProjects: function() { return config.projects; },
1469
+ });
1463
1470
  }, 100);
1464
1471
  }
1465
1472
 
@@ -0,0 +1,18 @@
1
+ // Runs blocking OS-user diagnostics outside the daemon process.
2
+
3
+ var diagnostics = require("./os-user-diagnostics");
4
+ var input = "";
5
+
6
+ process.stdin.setEncoding("utf8");
7
+ process.stdin.on("data", function(chunk) {
8
+ input += chunk;
9
+ });
10
+ process.stdin.on("end", function() {
11
+ try {
12
+ var options = JSON.parse(input);
13
+ var result = diagnostics.collectOsUserDiagnostics(options);
14
+ process.stdout.write(JSON.stringify({ summary: diagnostics.summarizeDiagnostics(result) }));
15
+ } catch (e) {
16
+ process.exitCode = 1;
17
+ }
18
+ });
@@ -0,0 +1,312 @@
1
+ // Best-effort, read-only diagnostics for the current OS-user compatibility mode.
2
+
3
+ var fs = require("fs");
4
+ var childProcess = require("child_process");
5
+ var path = require("path");
6
+ var execFileSync = childProcess.execFileSync;
7
+ var osUsers = require("./os-users");
8
+
9
+ function commandAvailable(command, execFile) {
10
+ try {
11
+ execFile(command, ["--help"], { stdio: "ignore", timeout: 5000 });
12
+ return true;
13
+ } catch (e) {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function credentialPaths(home) {
19
+ if (!home) return [];
20
+ return [
21
+ home + "/.claude",
22
+ home + "/.codex",
23
+ home + "/.config/gh",
24
+ home + "/.config/gcloud",
25
+ ];
26
+ }
27
+
28
+ function accessStatus(checks) {
29
+ var names = ["read", "traverse", "write"];
30
+ for (var i = 0; i < names.length; i++) {
31
+ if (!checks[names[i]] || checks[names[i]].status === "unknown") return "unknown";
32
+ }
33
+ for (var j = 0; j < names.length; j++) {
34
+ if (checks[names[j]].status === "deny") return "deny";
35
+ }
36
+ return "allow";
37
+ }
38
+
39
+ function unknownChecks() {
40
+ return {
41
+ read: { status: "unknown" },
42
+ traverse: { status: "unknown" },
43
+ write: { status: "unknown" },
44
+ };
45
+ }
46
+
47
+ function projectAccessScript(projectPath) {
48
+ return [
49
+ "var fs = require('fs');",
50
+ "var target = " + JSON.stringify(projectPath) + ";",
51
+ "var checks = {};",
52
+ "function check(name, mode) {",
53
+ " try { fs.accessSync(target, mode); checks[name] = { status: 'allow' }; }",
54
+ " catch (error) { checks[name] = { status: 'deny', code: String(error && error.code || 'EACCES') }; }",
55
+ "}",
56
+ "check('read', fs.constants.R_OK);",
57
+ "check('traverse', fs.constants.X_OK);",
58
+ "check('write', fs.constants.W_OK);",
59
+ "process.stdout.write(JSON.stringify(checks));",
60
+ ].join(" ");
61
+ }
62
+
63
+ /**
64
+ * Check directory access as the mapped identity without creating or changing
65
+ * any file. The spawn wrapper intentionally follows the runtime's current
66
+ * supplementary-group behavior, including its setpriv fallback.
67
+ */
68
+ function probeMappedProjectAccess(project, user, options) {
69
+ options = options || {};
70
+ var result = {
71
+ projectSlug: project && project.slug || null,
72
+ userId: user && user.id || null,
73
+ result: "unknown",
74
+ checks: unknownChecks(),
75
+ };
76
+ if (!project || !project.path) {
77
+ result.evidence = "project_path_unavailable";
78
+ return result;
79
+ }
80
+ if (!user || !user.linuxUser) {
81
+ result.evidence = "linux_mapping_missing";
82
+ return result;
83
+ }
84
+
85
+ var resolveUser = options.resolveUser || osUsers.resolveOsUserInfo;
86
+ var wrapSpawn = options.wrapSpawn || osUsers.wrapSpawnAsUser;
87
+ var execFile = options.execFile || execFileSync;
88
+ var nodePath = options.nodePath || process.execPath;
89
+ var account;
90
+ try {
91
+ account = resolveUser(user.linuxUser);
92
+ } catch (e) {
93
+ result.evidence = "linux_account_unavailable";
94
+ return result;
95
+ }
96
+ if (!account || account.uid == null || account.gid == null) {
97
+ result.evidence = "linux_account_invalid";
98
+ return result;
99
+ }
100
+
101
+ try {
102
+ var spawn = wrapSpawn(nodePath, ["-e", projectAccessScript(project.path)], {
103
+ encoding: "utf8",
104
+ timeout: 5000,
105
+ stdio: "pipe",
106
+ uid: account.uid,
107
+ gid: account.gid,
108
+ }, options.setprivCheck);
109
+ var output = execFile(spawn.command, spawn.args, spawn.options);
110
+ var checks = JSON.parse(String(output).trim());
111
+ if (!checks || typeof checks !== "object") throw new Error("Invalid access probe result");
112
+ result.checks = checks;
113
+ result.result = accessStatus(checks);
114
+ result.evidence = "mapped_identity_access";
115
+ } catch (e) {
116
+ result.evidence = "probe_unavailable";
117
+ }
118
+ return result;
119
+ }
120
+
121
+ function collectUserDiagnostics(user, options) {
122
+ options = options || {};
123
+ var resolveUser = options.resolveUser || osUsers.resolveOsUserInfo;
124
+ var exists = options.exists || fs.existsSync;
125
+ var execFile = options.execFile || execFileSync;
126
+ var result = {
127
+ userId: user && user.id || null,
128
+ linuxUser: user && user.linuxUser || null,
129
+ mapping: "missing",
130
+ account: "unknown",
131
+ supplementaryGroups: "unknown",
132
+ credentialPaths: [],
133
+ };
134
+ if (!user || !user.linuxUser) return result;
135
+
136
+ var account;
137
+ try {
138
+ account = resolveUser(user.linuxUser);
139
+ result.mapping = "resolved";
140
+ result.account = "present";
141
+ } catch (e) {
142
+ result.mapping = "unavailable";
143
+ result.account = "missing";
144
+ result.evidence = "linux_account_unavailable";
145
+ return result;
146
+ }
147
+
148
+ try {
149
+ var output = execFile("id", ["-Gn", user.linuxUser], { encoding: "utf8", timeout: 5000, stdio: "pipe" });
150
+ result.supplementaryGroups = output.trim() ? output.trim().split(/\s+/) : [];
151
+ } catch (e) {
152
+ result.supplementaryGroups = "unknown";
153
+ }
154
+ var paths = credentialPaths(account.home);
155
+ for (var i = 0; i < paths.length; i++) {
156
+ var present = "unknown";
157
+ try { present = !!exists(paths[i]); } catch (e) {}
158
+ result.credentialPaths.push({ path: paths[i], present: present });
159
+ }
160
+ return result;
161
+ }
162
+
163
+ function collectOsUserDiagnostics(options) {
164
+ options = options || {};
165
+ var users = options.users || [];
166
+ var projects = options.projects || [];
167
+ var probeProjectAccess = options.probeProjectAccess || probeMappedProjectAccess;
168
+ var execFile = options.execFile || execFileSync;
169
+ var result = {
170
+ collectedAt: Date.now(),
171
+ setprivAvailable: commandAvailable("setpriv", execFile),
172
+ users: [],
173
+ projectAccess: [],
174
+ };
175
+ for (var i = 0; i < users.length; i++) {
176
+ try {
177
+ result.users.push(collectUserDiagnostics(users[i], options));
178
+ } catch (e) {
179
+ result.users.push({
180
+ userId: users[i] && users[i].id || null,
181
+ linuxUser: users[i] && users[i].linuxUser || null,
182
+ mapping: "unknown",
183
+ account: "unknown",
184
+ supplementaryGroups: "unknown",
185
+ credentialPaths: [],
186
+ });
187
+ }
188
+ }
189
+ for (var pi = 0; pi < projects.length; pi++) {
190
+ var project = projects[pi];
191
+ for (var ui = 0; ui < users.length; ui++) {
192
+ var user = users[ui];
193
+ if (!project || !user) continue;
194
+ var probe;
195
+ try {
196
+ probe = probeProjectAccess(project, user, options);
197
+ if (!probe || typeof probe !== "object") probe = { result: "unknown", evidence: "invalid_probe_result" };
198
+ } catch (e) {
199
+ probe = { result: "unknown", evidence: "probe_failed" };
200
+ }
201
+ if (!probe.projectSlug) probe.projectSlug = project.slug || null;
202
+ if (!probe.userId) probe.userId = user.id || null;
203
+ if (!probe.result) probe.result = "unknown";
204
+ result.projectAccess.push(probe);
205
+ }
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function summarizeDiagnostics(result) {
211
+ var users = result.users || [];
212
+ var projectAccess = result.projectAccess || [];
213
+ var unavailableMappings = 0;
214
+ var unknownProbes = 0;
215
+ for (var i = 0; i < users.length; i++) {
216
+ if (users[i].mapping !== "resolved") unavailableMappings++;
217
+ }
218
+ for (var j = 0; j < projectAccess.length; j++) {
219
+ if (projectAccess[j].result === "unknown") unknownProbes++;
220
+ }
221
+ return "OS-user diagnostics: " + users.length + " user mappings, " + unavailableMappings + " unavailable, " + projectAccess.length + " project probes, " + unknownProbes + " unavailable";
222
+ }
223
+
224
+ function diagnosticsPayload(options) {
225
+ var users = typeof options.getUsers === "function" ? options.getUsers() : options.users || [];
226
+ var projects = typeof options.getProjects === "function" ? options.getProjects() : options.projects || [];
227
+ return {
228
+ users: users.map(function(user) {
229
+ return { id: user && user.id || null, linuxUser: user && user.linuxUser || null };
230
+ }),
231
+ projects: projects.map(function(project) {
232
+ return { slug: project && project.slug || null, path: project && project.path || null };
233
+ }),
234
+ };
235
+ }
236
+
237
+ function logDiagnosticMessage(logger, level, message) {
238
+ try {
239
+ if (logger && typeof logger[level] === "function") logger[level]("[daemon] " + message);
240
+ } catch (e) {}
241
+ }
242
+
243
+ /**
244
+ * Run blocking host probes in a short-lived child so diagnostics cannot stall
245
+ * the daemon event loop after it starts serving requests.
246
+ */
247
+ function scheduleOsUserDiagnosticsAsync(options) {
248
+ options = options || {};
249
+ var schedule = options.schedule || setTimeout;
250
+ var clearSchedule = options.clearSchedule || clearTimeout;
251
+ var spawn = options.spawn || childProcess.spawn;
252
+ var logger = options.logger || console;
253
+ var timeoutMs = options.timeoutMs || 30000;
254
+ try {
255
+ schedule(function() {
256
+ var child;
257
+ var timeout;
258
+ var output = "";
259
+ var completed = false;
260
+ function finish(level, message) {
261
+ if (completed) return;
262
+ completed = true;
263
+ if (timeout) clearSchedule(timeout);
264
+ logDiagnosticMessage(logger, level, message);
265
+ }
266
+ try {
267
+ child = spawn(options.nodePath || process.execPath, [path.join(__dirname, "os-user-diagnostics-worker.js")], {
268
+ stdio: ["pipe", "pipe", "ignore"],
269
+ });
270
+ if (!child || !child.stdin || !child.stdout || typeof child.once !== "function") {
271
+ finish("warn", "OS-user diagnostics unavailable");
272
+ return;
273
+ }
274
+ child.stdout.on("data", function(chunk) { output += String(chunk); });
275
+ child.once("error", function() { finish("warn", "OS-user diagnostics unavailable"); });
276
+ child.once("close", function(code) {
277
+ if (completed) return;
278
+ if (code !== 0) {
279
+ finish("warn", "OS-user diagnostics unavailable");
280
+ return;
281
+ }
282
+ try {
283
+ var report = JSON.parse(output);
284
+ if (!report || typeof report.summary !== "string") throw new Error("Invalid diagnostics report");
285
+ finish("log", report.summary);
286
+ } catch (e) {
287
+ finish("warn", "OS-user diagnostics unavailable");
288
+ }
289
+ });
290
+ timeout = schedule(function() {
291
+ try { child.kill(); } catch (e) {}
292
+ finish("warn", "OS-user diagnostics timed out");
293
+ }, timeoutMs);
294
+ child.stdin.end(JSON.stringify(diagnosticsPayload(options)));
295
+ } catch (e) {
296
+ try { if (child && typeof child.kill === "function") child.kill(); } catch (killError) {}
297
+ finish("warn", "OS-user diagnostics unavailable");
298
+ }
299
+ }, 0);
300
+ } catch (e) {
301
+ logDiagnosticMessage(logger, "warn", "OS-user diagnostics could not be scheduled");
302
+ }
303
+ }
304
+
305
+ module.exports = {
306
+ collectOsUserDiagnostics: collectOsUserDiagnostics,
307
+ collectUserDiagnostics: collectUserDiagnostics,
308
+ credentialPaths: credentialPaths,
309
+ probeMappedProjectAccess: probeMappedProjectAccess,
310
+ scheduleOsUserDiagnosticsAsync: scheduleOsUserDiagnosticsAsync,
311
+ summarizeDiagnostics: summarizeDiagnostics,
312
+ };
package/lib/os-users.js CHANGED
@@ -54,14 +54,15 @@ function setprivAvailable() {
54
54
  * Node's uid/gid options (primary group only) so behavior degrades safely.
55
55
  * Requires the parent process to be root, which is the case in os-users mode.
56
56
  */
57
- function wrapSpawnAsUser(command, args, options) {
57
+ function wrapSpawnAsUser(command, args, options, setprivCheck) {
58
58
  args = args || [];
59
59
  options = options || {};
60
60
  var uid = options.uid;
61
61
  var gid = options.gid;
62
62
  if (uid == null || gid == null) return { command: command, args: args, options: options };
63
63
 
64
- if (_inheritGroups && setprivAvailable()) {
64
+ var hasSetpriv = typeof setprivCheck === "function" ? setprivCheck() : setprivAvailable();
65
+ if (_inheritGroups && hasSetpriv) {
65
66
  var wrappedArgs = ["--reuid", String(uid), "--regid", String(gid), "--init-groups", "--", command].concat(args);
66
67
  var newOpts = Object.assign({}, options);
67
68
  delete newOpts.uid;
@@ -348,6 +349,39 @@ function toLinuxUsername(clayUsername) {
348
349
  return name;
349
350
  }
350
351
 
352
+ function findAvailableLinuxUsername(baseName, existsFn, rejectedCandidates) {
353
+ var exists = existsFn || linuxUserExists;
354
+ rejectedCandidates = rejectedCandidates || Object.create(null);
355
+ if (!exists(baseName) && !rejectedCandidates[baseName]) return baseName;
356
+ for (var suffixNumber = 2; suffixNumber < 10000; suffixNumber++) {
357
+ var suffix = "-" + suffixNumber;
358
+ var candidate = baseName.substring(0, 32 - suffix.length).replace(/-+$/, "") + suffix;
359
+ if (!exists(candidate) && !rejectedCandidates[candidate]) return candidate;
360
+ }
361
+ throw new Error("Could not allocate a unique Linux username for " + baseName);
362
+ }
363
+
364
+ function allocateLinuxUsername(baseName, existsFn, createUser) {
365
+ var exists = existsFn || linuxUserExists;
366
+ var rejectedCandidates = Object.create(null);
367
+ for (var attempt = 0; attempt < 9999; attempt++) {
368
+ var linuxName = findAvailableLinuxUsername(baseName, exists, rejectedCandidates);
369
+ try {
370
+ createUser(linuxName);
371
+ return linuxName;
372
+ } catch (e) {
373
+ var msg = (e.stderr || e.message || "").trim();
374
+ if (exists(linuxName) || e.status === 9 || /already exists/i.test(msg)) {
375
+ // NSS can lag a successful concurrent useradd, so remember collisions locally.
376
+ rejectedCandidates[linuxName] = true;
377
+ continue;
378
+ }
379
+ throw e;
380
+ }
381
+ }
382
+ throw new Error("Could not allocate a unique Linux username for " + baseName);
383
+ }
384
+
351
385
  /**
352
386
  * Ensure linger is enabled for a Linux user so systemd creates /run/user/<uid>.
353
387
  * Required for CLI tools like gcloud and gh that need XDG_RUNTIME_DIR.
@@ -484,31 +518,28 @@ function installClaudeCli(linuxName) {
484
518
  * Returns { ok: true, linuxUser: "clay-xxx" } or { error: "..." }.
485
519
  */
486
520
  function provisionLinuxUser(clayUsername) {
487
- var linuxName = toLinuxUsername(clayUsername);
488
-
489
- // Handle name collisions by appending a number
490
- if (linuxUserExists(linuxName)) {
491
- // Check if this is a clay-managed user (reuse it)
492
- // Otherwise find an available name
493
- console.log("[os-users] Linux user " + linuxName + " already exists, reusing.");
494
- return { ok: true, linuxUser: linuxName };
495
- }
496
-
521
+ var baseName = toLinuxUsername(clayUsername);
522
+ var linuxName;
497
523
  try {
498
- execFileSync("useradd", ["-m", "-s", "/bin/bash", linuxName], {
499
- encoding: "utf8",
500
- timeout: 15000,
501
- stdio: "pipe",
524
+ linuxName = allocateLinuxUsername(baseName, linuxUserExists, function(candidate) {
525
+ execFileSync("useradd", ["-m", "-s", "/bin/bash", candidate], {
526
+ encoding: "utf8",
527
+ timeout: 15000,
528
+ stdio: "pipe",
529
+ });
502
530
  });
503
- ensureLinger(linuxName);
504
- console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
505
- installClaudeCli(linuxName);
506
- return { ok: true, linuxUser: linuxName };
507
531
  } catch (e) {
508
532
  var msg = (e.stderr || e.message || "").trim();
509
- console.error("[os-users] Failed to provision Linux user " + linuxName + ": " + msg);
510
- return { error: "Failed to create Linux user " + linuxName + ": " + msg };
533
+ console.error("[os-users] Failed to provision Linux user " + baseName + ": " + msg);
534
+ return { error: "Failed to create Linux user " + baseName + ": " + msg };
535
+ }
536
+ if (linuxName !== baseName) {
537
+ console.log("[os-users] Linux user " + baseName + " already exists; provisioning " + linuxName + " instead");
511
538
  }
539
+ ensureLinger(linuxName);
540
+ console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
541
+ installClaudeCli(linuxName);
542
+ return { ok: true, linuxUser: linuxName };
512
543
  }
513
544
 
514
545
  /**
@@ -627,4 +658,6 @@ module.exports = {
627
658
  getLinuxUserHome: getLinuxUserHome,
628
659
  getLinuxUserUid: getLinuxUserUid,
629
660
  isSafeLinuxUsername: isSafeLinuxUsername,
661
+ findAvailableLinuxUsername: findAvailableLinuxUsername,
662
+ allocateLinuxUsername: allocateLinuxUsername,
630
663
  };
@@ -1002,32 +1002,74 @@
1002
1002
  }
1003
1003
 
1004
1004
  .file-viewer-header {
1005
+ display: block;
1006
+ padding: 0;
1007
+ flex-shrink: 0;
1008
+ min-height: 0;
1009
+ background: var(--bg);
1010
+ }
1011
+
1012
+ .file-viewer-tabbar {
1005
1013
  display: flex;
1006
- align-items: center;
1007
- gap: 8px;
1008
- padding: 10px 16px;
1014
+ align-items: stretch;
1015
+ height: 36px;
1016
+ min-width: 0;
1017
+ background: var(--bg-deep);
1009
1018
  border-bottom: 1px solid var(--border-subtle);
1010
- flex-shrink: 0;
1011
- min-height: 44px;
1012
1019
  }
1013
1020
 
1014
- .file-viewer-tabs { display: flex; min-width: 0; flex: 1; overflow-x: auto; gap: 2px; }
1015
- .file-viewer-tab { display: inline-flex; align-items: center; gap: 6px; max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-radius: 5px; padding: 4px 7px; background: transparent; color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; }
1016
- .file-viewer-tab.active { background: var(--sidebar-hover); color: var(--text); }
1017
- .file-viewer-tab-close { font-size: 16px; line-height: 12px; color: var(--text-muted); }
1021
+ .file-viewer-tabs { display: flex; min-width: 0; flex: 1; overflow-x: auto; overflow-y: hidden; gap: 0; scrollbar-width: none; }
1022
+ .file-viewer-tabs::-webkit-scrollbar { display: none; }
1023
+ .file-viewer-tab { position: relative; display: inline-flex; align-items: center; flex: 0 0 auto; gap: 7px; min-width: 108px; max-width: 190px; height: 36px; overflow: hidden; white-space: nowrap; border: 0; border-right: 1px solid var(--border-subtle); border-radius: 0; padding: 0 8px 0 10px; background: var(--bg-alt); color: var(--text-secondary); font: inherit; font-size: 12px; cursor: pointer; }
1024
+ .file-viewer-tab::before { content: ""; position: absolute; inset: 0 0 auto; height: 1px; background: transparent; }
1025
+ .file-viewer-tab:hover { background: var(--bg-hover); color: var(--text); }
1026
+ .file-viewer-tab.active { background: var(--bg); color: var(--text); }
1027
+ .file-viewer-tab.active::before { background: var(--accent); }
1028
+ .file-viewer-tab.preview .file-viewer-tab-label { font-style: italic; }
1029
+ .file-viewer-tab-icon { display: inline-flex; width: 16px; height: 16px; flex: 0 0 16px; }
1030
+ .file-viewer-tab-icon:empty { display: none; }
1031
+ .file-viewer-tab-icon svg { width: 16px; height: 16px; }
1032
+ .file-viewer-tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1033
+ .file-viewer-tab-close { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; margin-left: auto; border-radius: 4px; flex: 0 0 18px; color: var(--text-muted); font-size: 16px; font-style: normal; line-height: 1; opacity: 0; }
1034
+ .file-viewer-tab:hover .file-viewer-tab-close, .file-viewer-tab.active .file-viewer-tab-close { opacity: 1; }
1035
+ .file-viewer-tab-close:hover { background: var(--bg-hover); color: var(--text); }
1036
+
1037
+ .file-viewer-toolbar {
1038
+ display: flex;
1039
+ align-items: center;
1040
+ flex: 0 0 auto;
1041
+ gap: 1px;
1042
+ padding: 0 6px;
1043
+ background: var(--bg-deep);
1044
+ border-left: 1px solid var(--border-subtle);
1045
+ }
1046
+
1047
+ .file-viewer-breadcrumbs {
1048
+ display: flex;
1049
+ align-items: center;
1050
+ gap: 10px;
1051
+ min-height: 30px;
1052
+ padding: 0 10px;
1053
+ border-bottom: 1px solid var(--border-subtle);
1054
+ background: var(--bg);
1055
+ }
1018
1056
 
1019
1057
  .file-viewer-path {
1058
+ display: flex;
1059
+ align-items: center;
1060
+ gap: 3px;
1061
+ min-width: 0;
1020
1062
  flex: 1;
1021
- font-family: "Roboto Mono", monospace;
1022
- font-size: 13px;
1023
- color: var(--text-secondary);
1063
+ font-size: 12px;
1064
+ color: var(--text-muted);
1024
1065
  overflow: hidden;
1025
- text-overflow: ellipsis;
1026
1066
  white-space: nowrap;
1027
- direction: rtl;
1028
- text-align: left;
1029
1067
  }
1030
1068
 
1069
+ .file-viewer-path-segment { flex: 0 0 auto; }
1070
+ .file-viewer-path-segment.current { color: var(--text-secondary); }
1071
+ .file-viewer-path-separator { color: var(--text-dimmer); font-size: 15px; line-height: 1; }
1072
+
1031
1073
  .file-viewer-btn {
1032
1074
  display: flex;
1033
1075
  align-items: center;
@@ -1179,10 +1221,10 @@
1179
1221
  color: var(--text);
1180
1222
  }
1181
1223
 
1182
- #file-viewer.panel-fullscreen .file-viewer-header {
1183
- padding-left: clamp(18px, 3vw, 48px);
1184
- padding-right: clamp(18px, 3vw, 48px);
1185
- background: var(--bg);
1224
+ #file-viewer.panel-fullscreen .file-viewer-breadcrumbs { padding-left: clamp(18px, 3vw, 48px); padding-right: clamp(18px, 3vw, 48px); }
1225
+
1226
+ @media (hover: none) {
1227
+ .file-viewer-tab-close { opacity: 1; }
1186
1228
  }
1187
1229
 
1188
1230
  #file-viewer.panel-fullscreen .file-viewer-body {
@@ -1652,6 +1694,28 @@
1652
1694
  border: 1px solid var(--border-subtle);
1653
1695
  }
1654
1696
 
1697
+ .file-viewer-svg-preview {
1698
+ display: grid;
1699
+ place-items: center;
1700
+ min-height: 100%;
1701
+ padding: clamp(24px, 5vw, 56px);
1702
+ background-color: var(--bg);
1703
+ background-image:
1704
+ linear-gradient(45deg, var(--bg-alt) 25%, transparent 25%),
1705
+ linear-gradient(-45deg, var(--bg-alt) 25%, transparent 25%),
1706
+ linear-gradient(45deg, transparent 75%, var(--bg-alt) 75%),
1707
+ linear-gradient(-45deg, transparent 75%, var(--bg-alt) 75%);
1708
+ background-position: 0 0, 0 10px, 10px -10px, -10px 0;
1709
+ background-size: 20px 20px;
1710
+ }
1711
+
1712
+ .file-viewer-svg-preview img {
1713
+ display: block;
1714
+ max-width: 100%;
1715
+ max-height: calc(100dvh - 150px);
1716
+ filter: drop-shadow(0 8px 24px rgba(var(--shadow-rgb), 0.18));
1717
+ }
1718
+
1655
1719
  /* --- Web Terminal --- */
1656
1720
 
1657
1721
  /* Terminal header with tabs */
@@ -680,22 +680,28 @@
680
680
 
681
681
  <div id="file-viewer" class="hidden">
682
682
  <div class="file-viewer-header">
683
- <div class="file-viewer-tabs" id="file-viewer-tabs" role="tablist"></div>
684
- <span class="file-viewer-path" id="file-viewer-path"></span>
685
- <span class="file-viewer-live-status hidden" id="file-viewer-live-status" aria-live="polite">
686
- <span class="file-viewer-live-dot"></span>
687
- <span class="file-viewer-live-label">Editing</span>
688
- </span>
689
- <button class="file-viewer-btn hidden" id="file-viewer-render" title="Toggle rendered view"><i data-lucide="book-open"></i></button>
690
- <button class="file-viewer-btn hidden" id="file-viewer-pdf" title="Export PDF"><i data-lucide="file-down"></i></button>
691
- <button class="file-viewer-btn hidden" id="file-viewer-slides" title="Present as slides" aria-label="Present Markdown as slides" aria-pressed="false"><i data-lucide="presentation"></i></button>
692
- <button class="file-viewer-btn file-viewer-slide-level hidden" id="file-viewer-slide-level" title="Split slides by heading level" aria-haspopup="menu" aria-expanded="false"><span>H1</span><i data-lucide="chevron-down"></i></button>
693
- <button class="file-viewer-btn hidden" id="file-viewer-history" title="Edit history"><i data-lucide="clock"></i></button>
694
- <button class="file-viewer-btn" id="file-viewer-refresh" title="Refresh"><i data-lucide="refresh-cw"></i></button>
695
- <button class="file-viewer-btn" id="file-viewer-copy" title="Copy contents" aria-label="Copy contents"><i data-lucide="copy"></i></button>
696
- <button class="file-viewer-btn hidden" id="file-viewer-copy-formatted" title="Copy Markdown formatting" aria-label="Copy Markdown formatting"><i data-lucide="clipboard-copy"></i></button>
697
- <button class="file-viewer-btn" id="file-viewer-fullscreen" title="Toggle fullscreen"><i data-lucide="maximize-2"></i></button>
698
- <button class="file-viewer-btn" id="file-viewer-close" title="Close"><i data-lucide="x"></i></button>
683
+ <div class="file-viewer-tabbar">
684
+ <div class="file-viewer-tabs" id="file-viewer-tabs" role="tablist"></div>
685
+ <div class="file-viewer-toolbar" aria-label="Editor actions">
686
+ <button class="file-viewer-btn hidden" id="file-viewer-render" title="Toggle rendered view"><i data-lucide="book-open"></i></button>
687
+ <button class="file-viewer-btn hidden" id="file-viewer-pdf" title="Export PDF"><i data-lucide="file-down"></i></button>
688
+ <button class="file-viewer-btn hidden" id="file-viewer-slides" title="Present as slides" aria-label="Present Markdown as slides" aria-pressed="false"><i data-lucide="presentation"></i></button>
689
+ <button class="file-viewer-btn file-viewer-slide-level hidden" id="file-viewer-slide-level" title="Split slides by heading level" aria-haspopup="menu" aria-expanded="false"><span>H1</span><i data-lucide="chevron-down"></i></button>
690
+ <button class="file-viewer-btn hidden" id="file-viewer-history" title="Edit history"><i data-lucide="clock"></i></button>
691
+ <button class="file-viewer-btn" id="file-viewer-refresh" title="Refresh"><i data-lucide="refresh-cw"></i></button>
692
+ <button class="file-viewer-btn" id="file-viewer-copy" title="Copy contents" aria-label="Copy contents"><i data-lucide="copy"></i></button>
693
+ <button class="file-viewer-btn hidden" id="file-viewer-copy-formatted" title="Copy Markdown formatting" aria-label="Copy Markdown formatting"><i data-lucide="clipboard-copy"></i></button>
694
+ <button class="file-viewer-btn" id="file-viewer-fullscreen" title="Toggle fullscreen"><i data-lucide="maximize-2"></i></button>
695
+ <button class="file-viewer-btn" id="file-viewer-close" title="Close"><i data-lucide="x"></i></button>
696
+ </div>
697
+ </div>
698
+ <div class="file-viewer-breadcrumbs">
699
+ <div class="file-viewer-path" id="file-viewer-path" aria-label="Current file path"></div>
700
+ <span class="file-viewer-live-status hidden" id="file-viewer-live-status" aria-live="polite">
701
+ <span class="file-viewer-live-dot"></span>
702
+ <span class="file-viewer-live-label">Editing</span>
703
+ </span>
704
+ </div>
699
705
  </div>
700
706
  <div class="file-viewer-body" id="file-viewer-body"></div>
701
707
  </div>
@@ -1,5 +1,8 @@
1
+ import { getFileIconSvg } from './fileicons.js';
2
+
1
3
  var tabs = new Map();
2
4
  var focusedPath = null;
5
+ var previewPath = null;
3
6
  var onFocus = null;
4
7
  var onEmpty = null;
5
8
 
@@ -15,14 +18,23 @@ function render() {
15
18
  tabs.forEach(function(value, path) {
16
19
  var tab = document.createElement("button");
17
20
  tab.type = "button";
18
- tab.className = "file-viewer-tab" + (path === focusedPath ? " active" : "");
19
- tab.textContent = label(path);
21
+ tab.className = "file-viewer-tab" + (path === focusedPath ? " active" : "") + (path === previewPath ? " preview" : "");
22
+ tab.setAttribute("role", "tab");
23
+ tab.setAttribute("aria-selected", path === focusedPath ? "true" : "false");
20
24
  tab.title = path;
21
25
  tab.addEventListener("click", function() { focusFileViewerTab(path); });
26
+ var icon = document.createElement("span");
27
+ icon.className = "file-viewer-tab-icon";
28
+ icon.innerHTML = getFileIconSvg(label(path));
29
+ var name = document.createElement("span");
30
+ name.className = "file-viewer-tab-label";
31
+ name.textContent = label(path);
22
32
  var close = document.createElement("span");
23
33
  close.className = "file-viewer-tab-close";
24
34
  close.textContent = "×";
25
35
  close.addEventListener("click", function(event) { event.stopPropagation(); closeFileViewerTab(path); });
36
+ tab.appendChild(icon);
37
+ tab.appendChild(name);
26
38
  tab.appendChild(close);
27
39
  root.appendChild(tab);
28
40
  });
@@ -37,11 +49,37 @@ export function openFileViewerTab(path, data) {
37
49
  if (!path) return false;
38
50
  var exists = tabs.has(path);
39
51
  tabs.set(path, Object.assign(tabs.get(path) || {}, data || {}));
52
+ if (path === previewPath) previewPath = null;
53
+ focusedPath = path;
54
+ render();
55
+ return !exists;
56
+ }
57
+
58
+ export function previewFileViewerTab(path, data) {
59
+ if (!path) return false;
60
+ if (tabs.has(path) && path !== previewPath) {
61
+ focusedPath = path;
62
+ render();
63
+ return false;
64
+ }
65
+ if (previewPath && previewPath !== path) tabs.delete(previewPath);
66
+ var exists = tabs.has(path);
67
+ tabs.set(path, Object.assign(tabs.get(path) || {}, data || {}));
68
+ previewPath = path;
40
69
  focusedPath = path;
41
70
  render();
42
71
  return !exists;
43
72
  }
44
73
 
74
+ export function updateFileViewerTab(path, data) {
75
+ if (!path) return false;
76
+ if (!tabs.has(path)) return false;
77
+ tabs.set(path, Object.assign(tabs.get(path) || {}, data || {}));
78
+ if (focusedPath !== path) return false;
79
+ render();
80
+ return true;
81
+ }
82
+
45
83
  export function focusFileViewerTab(path) {
46
84
  if (!tabs.has(path)) return false;
47
85
  focusedPath = path;
@@ -55,6 +93,7 @@ export function closeFileViewerTab(path) {
55
93
  var paths = Array.from(tabs.keys());
56
94
  var index = paths.indexOf(path);
57
95
  tabs.delete(path);
96
+ if (previewPath === path) previewPath = null;
58
97
  if (focusedPath === path) focusedPath = paths[index + 1] || paths[index - 1] || null;
59
98
  render();
60
99
  if (focusedPath && onFocus) onFocus(focusedPath, tabs.get(focusedPath));
@@ -62,4 +101,4 @@ export function closeFileViewerTab(path) {
62
101
  }
63
102
 
64
103
  export function focusedFileViewerTab() { return focusedPath; }
65
- export function clearFileViewerTabs() { tabs.clear(); focusedPath = null; render(); }
104
+ export function clearFileViewerTabs() { tabs.clear(); focusedPath = null; previewPath = null; render(); }
@@ -9,7 +9,7 @@ import { copyMarkdownFormatting } from './rich-clipboard.js';
9
9
  import { animateMarkdownChange, beginMarkdownPresentation, cancelMarkdownFollow, isFollowingMarkdown } from './markdown-live-edit.js';
10
10
  import { store } from './store.js';
11
11
  import { enterMarkdownSlides, exitMarkdownSlides, handleMarkdownSlideKey, syncMarkdownSlidesButton, toggleMarkdownSlideLevelMenu } from './markdown-slides.js';
12
- import { initFileViewerTabs, openFileViewerTab, closeFileViewerTab, focusedFileViewerTab, clearFileViewerTabs } from './filebrowser-tabs.js';
12
+ import { initFileViewerTabs, openFileViewerTab, previewFileViewerTab, updateFileViewerTab, closeFileViewerTab, focusedFileViewerTab, clearFileViewerTabs } from './filebrowser-tabs.js';
13
13
 
14
14
  var ctx;
15
15
  var showDropHint = function () {};
@@ -18,6 +18,7 @@ var currentContent = null; // last read file content for copy
18
18
  var currentFilePath = null; // path of the currently viewed file
19
19
  var isRendered = false; // markdown render toggle state
20
20
  var currentIsMarkdown = false;
21
+ var currentIsSvg = false;
21
22
  var historyVisible = false;
22
23
  var currentHistoryEntries = [];
23
24
  var pendingNavigate = null; // { sessionLocalId, assistantUuid }
@@ -181,8 +182,12 @@ export function initFileBrowser(_ctx) {
181
182
 
182
183
  // Markdown render toggle
183
184
  document.getElementById("file-viewer-render").addEventListener("click", function () {
184
- if (!currentContent || !currentIsMarkdown) return;
185
+ if (!currentContent || (!currentIsMarkdown && !currentIsSvg)) return;
185
186
  isRendered = !isRendered;
187
+ if (currentIsSvg) {
188
+ renderSvgBody();
189
+ return;
190
+ }
186
191
  if (!isRendered && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
187
192
  renderBody();
188
193
  });
@@ -377,7 +382,11 @@ function kbCollapseOrAscend() {
377
382
  }
378
383
 
379
384
  function kbActivate() {
380
- if (_kbFocused) _kbFocused.click();
385
+ if (!_kbFocused) return;
386
+ if (!isDirRow(_kbFocused) && _kbFocused.dataset.path) {
387
+ openFileViewerTab(_kbFocused.dataset.path);
388
+ }
389
+ _kbFocused.click();
381
390
  }
382
391
 
383
392
  function handleTreeKeyDown(e) {
@@ -500,6 +509,7 @@ export function resetFileBrowser() {
500
509
  currentFilePath = null;
501
510
  isRendered = false;
502
511
  currentIsMarkdown = false;
512
+ currentIsSvg = false;
503
513
  historyVisible = false;
504
514
  currentHistoryEntries = [];
505
515
  pendingNavigate = null;
@@ -541,6 +551,7 @@ export function openFile(filePath, opts) {
541
551
 
542
552
  export function openWorkingTreeDiff(diff) {
543
553
  if (!diff || !diff.path) return;
554
+ openFileViewerTab(diff.path);
544
555
  pendingRenderedOpen = false;
545
556
  pendingOpenMode = diff.binary ? null : {
546
557
  type: "diff",
@@ -801,9 +812,14 @@ function renderFilteredTree(container, tree, depth, query) {
801
812
  var prev = ctx.fileTreeEl.querySelector(".file-tree-item.active");
802
813
  if (prev) prev.classList.remove("active");
803
814
  rowEl.classList.add("active");
815
+ previewFileViewerTab(filePath);
804
816
  requestFileContent(filePath);
805
817
  if (window.innerWidth <= 768) closeSidebar();
806
818
  });
819
+ rowEl.addEventListener("dblclick", function (e) {
820
+ e.stopPropagation();
821
+ openFileViewerTab(filePath);
822
+ });
807
823
  })(entry.path, row);
808
824
 
809
825
  container.appendChild(row);
@@ -1033,12 +1049,17 @@ function renderEntries(container, entries, depth) {
1033
1049
  var prev = ctx.fileTreeEl.querySelector(".file-tree-item.active");
1034
1050
  if (prev) prev.classList.remove("active");
1035
1051
  rowEl.classList.add("active");
1052
+ previewFileViewerTab(filePath);
1036
1053
  requestFileContent(filePath);
1037
1054
  // Mobile: close sidebar
1038
1055
  if (window.innerWidth <= 768) {
1039
1056
  closeSidebar();
1040
1057
  }
1041
1058
  });
1059
+ rowEl.addEventListener("dblclick", function (e) {
1060
+ e.stopPropagation();
1061
+ openFileViewerTab(filePath);
1062
+ });
1042
1063
  })(entry.path, row);
1043
1064
 
1044
1065
  container.appendChild(row);
@@ -1050,7 +1071,7 @@ function renderEntries(container, entries, depth) {
1050
1071
  // --- File viewer ---
1051
1072
 
1052
1073
  function showFileContent(msg) {
1053
- openFileViewerTab(msg.path, { content: msg.content });
1074
+ if (!updateFileViewerTab(msg.path, { content: msg.content })) return;
1054
1075
  var pathEl = document.getElementById("file-viewer-path");
1055
1076
  var bodyEl = document.getElementById("file-viewer-body");
1056
1077
  var renderBtn = document.getElementById("file-viewer-render");
@@ -1064,13 +1085,14 @@ function showFileContent(msg) {
1064
1085
 
1065
1086
  exitMarkdownSlides();
1066
1087
 
1067
- pathEl.textContent = msg.path;
1088
+ renderFileBreadcrumb(pathEl, msg.path);
1068
1089
  var keepRenderState = pendingRefresh && msg.path === currentFilePath;
1069
1090
  var prevRendered = isRendered;
1070
1091
  pendingRefresh = false;
1071
1092
  currentContent = null;
1072
1093
  currentFilePath = msg.path;
1073
1094
  currentIsMarkdown = false;
1095
+ currentIsSvg = false;
1074
1096
  if (!keepRenderState) isRendered = false;
1075
1097
  var requestedExt = msg.path.split(".").pop().toLowerCase();
1076
1098
  if (pendingRenderedOpen && (requestedExt === "md" || requestedExt === "mdx")) {
@@ -1095,23 +1117,27 @@ function showFileContent(msg) {
1095
1117
  currentContent = msg.content;
1096
1118
  var ext = requestedExt;
1097
1119
  currentIsMarkdown = (ext === "md" || ext === "mdx");
1120
+ currentIsSvg = ext === "svg";
1098
1121
  if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
1099
1122
 
1100
- if (currentIsMarkdown) {
1123
+ if (currentIsMarkdown || currentIsSvg) {
1101
1124
  renderBtn.classList.remove("hidden");
1102
- renderBtn.title = "Render markdown";
1103
- copyBtn.title = "Copy Markdown source";
1125
+ renderBtn.title = currentIsSvg ? "Show SVG source" : "Render markdown";
1126
+ copyBtn.title = currentIsSvg ? "Copy SVG source" : "Copy Markdown source";
1104
1127
  } else {
1105
1128
  copyBtn.title = "Copy contents";
1106
1129
  }
1107
1130
 
1108
- // Show raw by default, use renderBody for markdown toggle
1131
+ // Markdown starts as source; SVG starts as a safe image preview.
1109
1132
  if (currentIsMarkdown) {
1110
1133
  var transitionFrom = keepRenderState && prevRendered && previousWasMarkdown &&
1111
1134
  isFollowingMarkdown(msg.path) && pathsReferToSameFile(previousPath, msg.path)
1112
1135
  ? (previousContent == null ? "" : previousContent)
1113
1136
  : null;
1114
1137
  renderBody(transitionFrom, refreshSlideIndex);
1138
+ } else if (currentIsSvg) {
1139
+ if (!keepRenderState) isRendered = true;
1140
+ renderSvgBody();
1115
1141
  } else {
1116
1142
  renderCodeWithLineNumbers(bodyEl, msg.content, ext);
1117
1143
  }
@@ -1158,6 +1184,47 @@ function showFileContent(msg) {
1158
1184
  }
1159
1185
  }
1160
1186
 
1187
+ function renderSvgBody() {
1188
+ var bodyEl = document.getElementById("file-viewer-body");
1189
+ var renderBtn = document.getElementById("file-viewer-render");
1190
+ if (!bodyEl || !currentFilePath || currentContent == null) return;
1191
+ if (!isRendered) {
1192
+ renderCodeWithLineNumbers(bodyEl, currentContent, "svg");
1193
+ renderBtn.classList.remove("active");
1194
+ renderBtn.title = "Show SVG preview";
1195
+ return;
1196
+ }
1197
+ bodyEl.innerHTML = "";
1198
+ var preview = document.createElement("div");
1199
+ preview.className = "file-viewer-svg-preview";
1200
+ var image = document.createElement("img");
1201
+ image.src = "api/file?path=" + encodeURIComponent(currentFilePath);
1202
+ image.alt = currentFilePath;
1203
+ image.draggable = false;
1204
+ preview.appendChild(image);
1205
+ bodyEl.appendChild(preview);
1206
+ renderBtn.classList.add("active");
1207
+ renderBtn.title = "Show SVG source";
1208
+ }
1209
+
1210
+ function renderFileBreadcrumb(root, path) {
1211
+ root.innerHTML = "";
1212
+ root.title = path;
1213
+ var parts = String(path || "").replace(/\\/g, "/").split("/").filter(function(part) { return part; });
1214
+ for (var i = 0; i < parts.length; i++) {
1215
+ if (i > 0) {
1216
+ var separator = document.createElement("span");
1217
+ separator.className = "file-viewer-path-separator";
1218
+ separator.textContent = "›";
1219
+ root.appendChild(separator);
1220
+ }
1221
+ var segment = document.createElement("span");
1222
+ segment.className = "file-viewer-path-segment" + (i === parts.length - 1 ? " current" : "");
1223
+ segment.textContent = parts[i];
1224
+ root.appendChild(segment);
1225
+ }
1226
+ }
1227
+
1161
1228
  function pathsReferToSameFile(left, right) {
1162
1229
  if (!left || !right) return false;
1163
1230
  var a = String(left).replace(/\\/g, "/");
@@ -1454,6 +1521,8 @@ function rerenderFileContent() {
1454
1521
 
1455
1522
  if (currentIsMarkdown) {
1456
1523
  renderBody();
1524
+ } else if (currentIsSvg) {
1525
+ renderSvgBody();
1457
1526
  } else {
1458
1527
  renderCodeWithLineNumbers(bodyEl, currentContent, ext);
1459
1528
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.5.1-beta.1",
3
+ "version": "3.6.0-beta.1",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",