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 +7 -0
- package/lib/os-user-diagnostics-worker.js +18 -0
- package/lib/os-user-diagnostics.js +312 -0
- package/lib/os-users.js +55 -22
- package/lib/public/css/filebrowser.css +83 -19
- package/lib/public/index.html +22 -16
- package/lib/public/modules/filebrowser-tabs.js +42 -3
- package/lib/public/modules/filebrowser.js +78 -9
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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 " +
|
|
510
|
-
return { error: "Failed to create Linux user " +
|
|
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:
|
|
1007
|
-
|
|
1008
|
-
|
|
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:
|
|
1015
|
-
.file-viewer-
|
|
1016
|
-
.file-viewer-tab
|
|
1017
|
-
.file-viewer-tab
|
|
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-
|
|
1022
|
-
|
|
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-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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 */
|
package/lib/public/index.html
CHANGED
|
@@ -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-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
<
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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