@pixelbyte-software/pixcode 1.53.17 → 1.53.19
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/assets/{index-ByaiDQ05.js → index-CvIHrHIG.js} +195 -184
- package/dist/assets/index-DvudRU5X.css +32 -0
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +246 -99
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/projects.js +96 -78
- package/dist-server/server/projects.js.map +1 -1
- package/dist-server/server/routes/git.js +43 -29
- package/dist-server/server/routes/git.js.map +1 -1
- package/package.json +2 -1
- package/scripts/smoke/backend-resource-bounds.mjs +91 -0
- package/scripts/smoke/mobile-ux.mjs +28 -2
- package/server/index.js +262 -99
- package/server/projects.js +100 -76
- package/server/routes/git.js +42 -30
- package/dist/assets/index-nJQ4Q6hZ.css +0 -32
|
@@ -8,6 +8,7 @@ import os from 'os';
|
|
|
8
8
|
import http from 'http';
|
|
9
9
|
import net from 'node:net';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import readline from 'node:readline';
|
|
11
12
|
import { createRequire } from 'node:module';
|
|
12
13
|
import { spawn } from 'child_process';
|
|
13
14
|
import express from 'express';
|
|
@@ -36,6 +37,7 @@ const DAEMON_COMMAND_CONTEXT = {
|
|
|
36
37
|
cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
|
|
37
38
|
nodeExecPath: process.execPath,
|
|
38
39
|
};
|
|
40
|
+
const JSONL_STREAM_LINE_MAX_CHARS = 1024 * 1024;
|
|
39
41
|
function resolveMonacoAssetsPath() {
|
|
40
42
|
const appParent = path.dirname(APP_ROOT);
|
|
41
43
|
const appGrandparent = path.dirname(appParent);
|
|
@@ -566,9 +568,25 @@ const WATCHER_IGNORED_PATTERNS = [
|
|
|
566
568
|
'**/node_modules/**',
|
|
567
569
|
'**/.git/**',
|
|
568
570
|
'**/dist/**',
|
|
571
|
+
'**/dist-server/**',
|
|
569
572
|
'**/build/**',
|
|
573
|
+
'**/out/**',
|
|
574
|
+
'**/target/**',
|
|
575
|
+
'**/vendor/**',
|
|
576
|
+
'**/prebuilts/**',
|
|
577
|
+
'**/.repo/**',
|
|
578
|
+
'**/.gradle/**',
|
|
579
|
+
'**/.next/**',
|
|
580
|
+
'**/.nuxt/**',
|
|
581
|
+
'**/.svelte-kit/**',
|
|
582
|
+
'**/.turbo/**',
|
|
583
|
+
'**/.cache/**',
|
|
584
|
+
'**/.venv/**',
|
|
585
|
+
'**/venv/**',
|
|
586
|
+
'**/coverage/**',
|
|
570
587
|
'**/*.tmp',
|
|
571
588
|
'**/*.swp',
|
|
589
|
+
'**/*.log',
|
|
572
590
|
'**/.DS_Store'
|
|
573
591
|
];
|
|
574
592
|
// Debounce chokidar events before rescanning all provider project trees.
|
|
@@ -579,8 +597,10 @@ const WATCHER_IGNORED_PATTERNS = [
|
|
|
579
597
|
// a full chat reply into ~1 scan while still feeling responsive when
|
|
580
598
|
// the user flips to the projects list.
|
|
581
599
|
const WATCHER_DEBOUNCE_MS = Number.parseInt(process.env.PIXCODE_PROJECT_WATCH_DEBOUNCE_MS || '', 10) || 10000;
|
|
600
|
+
const PROVIDER_WATCHER_DEPTH = Number.parseInt(process.env.PIXCODE_PROVIDER_WATCH_DEPTH || '', 10) || 8;
|
|
582
601
|
let projectsWatchers = [];
|
|
583
602
|
let projectsWatcherDebounceTimer = null;
|
|
603
|
+
let pendingProjectsWatcherRefresh = null;
|
|
584
604
|
const connectedClients = new Set();
|
|
585
605
|
let isGetProjectsRunning = false; // Flag to prevent reentrant calls
|
|
586
606
|
const STARTUP_INPUT_READY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -613,37 +633,51 @@ async function setupProjectsWatcher() {
|
|
|
613
633
|
}
|
|
614
634
|
}));
|
|
615
635
|
projectsWatchers = [];
|
|
636
|
+
const sendToOpenClient = (client, payload) => {
|
|
637
|
+
if (client.readyState !== WebSocket.OPEN) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
if (client.bufferedAmount > SHELL_WS_BACKPRESSURE_LIMIT) {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
client.send(JSON.stringify(payload));
|
|
644
|
+
return true;
|
|
645
|
+
};
|
|
616
646
|
const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
|
|
647
|
+
pendingProjectsWatcherRefresh = { eventType, filePath, provider, rootPath };
|
|
617
648
|
if (projectsWatcherDebounceTimer) {
|
|
618
649
|
clearTimeout(projectsWatcherDebounceTimer);
|
|
619
650
|
}
|
|
620
651
|
projectsWatcherDebounceTimer = setTimeout(async () => {
|
|
621
652
|
// Prevent reentrant calls
|
|
622
653
|
if (isGetProjectsRunning) {
|
|
654
|
+
projectsWatcherDebounceTimer = setTimeout(() => {
|
|
655
|
+
const pending = pendingProjectsWatcherRefresh;
|
|
656
|
+
if (pending) {
|
|
657
|
+
debouncedUpdate(pending.eventType, pending.filePath, pending.provider, pending.rootPath);
|
|
658
|
+
}
|
|
659
|
+
}, WATCHER_DEBOUNCE_MS);
|
|
623
660
|
return;
|
|
624
661
|
}
|
|
625
662
|
try {
|
|
626
663
|
isGetProjectsRunning = true;
|
|
627
|
-
|
|
664
|
+
const pending = pendingProjectsWatcherRefresh || { eventType, filePath, provider, rootPath };
|
|
665
|
+
pendingProjectsWatcherRefresh = null;
|
|
666
|
+
if (pending.eventType === 'addDir' || pending.eventType === 'unlinkDir') {
|
|
628
667
|
clearProjectDirectoryCache();
|
|
629
668
|
}
|
|
630
|
-
// Get updated projects list
|
|
631
|
-
const updatedProjects = await getProjects(broadcastProgress);
|
|
632
669
|
const updatePayload = {
|
|
633
670
|
type: 'projects_updated',
|
|
634
671
|
timestamp: new Date().toISOString(),
|
|
635
|
-
changeType: eventType,
|
|
636
|
-
changedFile: path.relative(rootPath, filePath),
|
|
637
|
-
watchProvider: provider
|
|
672
|
+
changeType: pending.eventType,
|
|
673
|
+
changedFile: path.relative(pending.rootPath, pending.filePath),
|
|
674
|
+
watchProvider: pending.provider,
|
|
675
|
+
invalidated: true,
|
|
638
676
|
};
|
|
639
|
-
// Notify
|
|
677
|
+
// Notify clients that provider metadata changed. Avoid broadcasting the full
|
|
678
|
+
// project/session tree from watcher events; clients can pull when needed.
|
|
640
679
|
connectedClients.forEach(client => {
|
|
641
|
-
|
|
642
|
-
client.send(JSON.stringify({
|
|
643
|
-
...updatePayload,
|
|
644
|
-
projects: filterProjectsForUser(updatedProjects, client.user),
|
|
645
|
-
}));
|
|
646
|
-
}
|
|
680
|
+
sendToOpenClient(client, updatePayload);
|
|
647
681
|
});
|
|
648
682
|
}
|
|
649
683
|
catch (error) {
|
|
@@ -665,7 +699,7 @@ async function setupProjectsWatcher() {
|
|
|
665
699
|
persistent: true,
|
|
666
700
|
ignoreInitial: true, // Don't fire events for existing files on startup
|
|
667
701
|
followSymlinks: false,
|
|
668
|
-
depth:
|
|
702
|
+
depth: PROVIDER_WATCHER_DEPTH,
|
|
669
703
|
awaitWriteFinish: {
|
|
670
704
|
// Raised from (100, 50) to (500, 250). The old settings
|
|
671
705
|
// had chokidar polling every 50ms per in-flight file; an
|
|
@@ -707,6 +741,8 @@ async function setupProjectsWatcher() {
|
|
|
707
741
|
// `project_files_updated` events to subscribed clients only, letting the
|
|
708
742
|
// explorer refresh automatically without HTTP polling.
|
|
709
743
|
const WORKSPACE_WATCHER_DEBOUNCE_MS = 800;
|
|
744
|
+
const WORKSPACE_WATCHER_DEPTH = Number.parseInt(process.env.PIXCODE_WORKSPACE_WATCH_DEPTH || '', 10) || 5;
|
|
745
|
+
const WORKSPACE_WS_BACKPRESSURE_LIMIT = 2 * 1024 * 1024;
|
|
710
746
|
const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 128 * 1024;
|
|
711
747
|
const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 80;
|
|
712
748
|
const WORKSPACE_DIFF_SNAPSHOT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
@@ -914,7 +950,7 @@ async function subscribeToWorkspace(ws, projectName) {
|
|
|
914
950
|
timestamp: new Date().toISOString(),
|
|
915
951
|
});
|
|
916
952
|
entry.subscribers.forEach((client) => {
|
|
917
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
953
|
+
if (client.readyState === WebSocket.OPEN && client.bufferedAmount <= WORKSPACE_WS_BACKPRESSURE_LIMIT) {
|
|
918
954
|
client.send(message);
|
|
919
955
|
}
|
|
920
956
|
});
|
|
@@ -927,7 +963,7 @@ async function subscribeToWorkspace(ws, projectName) {
|
|
|
927
963
|
persistent: true,
|
|
928
964
|
ignoreInitial: true,
|
|
929
965
|
followSymlinks: false,
|
|
930
|
-
depth:
|
|
966
|
+
depth: WORKSPACE_WATCHER_DEPTH,
|
|
931
967
|
awaitWriteFinish: {
|
|
932
968
|
stabilityThreshold: 500,
|
|
933
969
|
pollInterval: 250
|
|
@@ -978,6 +1014,36 @@ const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUF
|
|
|
978
1014
|
const SHELL_INPUT_CHUNK_CHARS = 4096;
|
|
979
1015
|
const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (16 * 1024 * 1024);
|
|
980
1016
|
const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
|
|
1017
|
+
const FILE_TREE_MAX_ITEMS = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ITEMS || '', 10) || 5000;
|
|
1018
|
+
const FILE_TREE_MAX_DIRECTORIES = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_DIRECTORIES || '', 10) || 1200;
|
|
1019
|
+
const FILE_TREE_SCAN_MAX_MS = Number.parseInt(process.env.PIXCODE_FILE_TREE_SCAN_MAX_MS || '', 10) || 4000;
|
|
1020
|
+
const FILE_TREE_MAX_ENTRIES_PER_DIRECTORY = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY || '', 10) || 1500;
|
|
1021
|
+
const FILE_TREE_EXCLUDED_ENTRY_NAMES = new Set([
|
|
1022
|
+
'.cache',
|
|
1023
|
+
'.git',
|
|
1024
|
+
'.gradle',
|
|
1025
|
+
'.hg',
|
|
1026
|
+
'.next',
|
|
1027
|
+
'.nuxt',
|
|
1028
|
+
'.pnpm-store',
|
|
1029
|
+
'.repo',
|
|
1030
|
+
'.svn',
|
|
1031
|
+
'.svelte-kit',
|
|
1032
|
+
'.turbo',
|
|
1033
|
+
'.venv',
|
|
1034
|
+
'build',
|
|
1035
|
+
'coverage',
|
|
1036
|
+
'DerivedData',
|
|
1037
|
+
'dist',
|
|
1038
|
+
'dist-server',
|
|
1039
|
+
'node_modules',
|
|
1040
|
+
'out',
|
|
1041
|
+
'Pods',
|
|
1042
|
+
'prebuilts',
|
|
1043
|
+
'target',
|
|
1044
|
+
'vendor',
|
|
1045
|
+
'venv',
|
|
1046
|
+
]);
|
|
981
1047
|
import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
|
|
982
1048
|
function terminatePtySession(sessionKey, session, reason) {
|
|
983
1049
|
if (!session)
|
|
@@ -3690,11 +3756,22 @@ function handleShellConnection(ws, request) {
|
|
|
3690
3756
|
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
3691
3757
|
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
|
3692
3758
|
existingSession.buffer.forEach(bufferedData => {
|
|
3759
|
+
if (ws.bufferedAmount > SHELL_WS_BACKPRESSURE_LIMIT) {
|
|
3760
|
+
existingSession.droppedOutputBytes = (existingSession.droppedOutputBytes || 0) + Buffer.byteLength(String(bufferedData || ''), 'utf8');
|
|
3761
|
+
return;
|
|
3762
|
+
}
|
|
3693
3763
|
ws.send(JSON.stringify({
|
|
3694
3764
|
type: 'output',
|
|
3695
3765
|
data: bufferedData
|
|
3696
3766
|
}));
|
|
3697
3767
|
});
|
|
3768
|
+
if ((existingSession.droppedOutputBytes || 0) > 0 && ws.bufferedAmount <= SHELL_WS_BACKPRESSURE_LIMIT) {
|
|
3769
|
+
ws.send(JSON.stringify({
|
|
3770
|
+
type: 'output',
|
|
3771
|
+
data: `\r\n\x1b[33m[Pixcode] ${existingSession.droppedOutputBytes} bytes of buffered terminal output were skipped because the browser connection was backpressured.\x1b[0m\r\n`,
|
|
3772
|
+
}));
|
|
3773
|
+
existingSession.droppedOutputBytes = 0;
|
|
3774
|
+
}
|
|
3698
3775
|
}
|
|
3699
3776
|
existingSession.ws = ws;
|
|
3700
3777
|
existingSession.updatedAt = Date.now();
|
|
@@ -4186,24 +4263,45 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4186
4263
|
// Handle Codex sessions
|
|
4187
4264
|
if (provider === 'codex') {
|
|
4188
4265
|
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
4189
|
-
// Find the session file by searching for the session ID
|
|
4266
|
+
// Find the session file by searching for the session ID without
|
|
4267
|
+
// materializing every directory entry in memory.
|
|
4190
4268
|
const findSessionFile = async (dir) => {
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4269
|
+
const pendingDirs = [dir];
|
|
4270
|
+
let visitedDirs = 0;
|
|
4271
|
+
let visitedFiles = 0;
|
|
4272
|
+
const maxDirs = 2500;
|
|
4273
|
+
const maxFiles = 10000;
|
|
4274
|
+
while (pendingDirs.length > 0 && visitedDirs < maxDirs && visitedFiles < maxFiles) {
|
|
4275
|
+
const currentDir = pendingDirs.pop();
|
|
4276
|
+
visitedDirs += 1;
|
|
4277
|
+
try {
|
|
4278
|
+
const dirHandle = await fsPromises.opendir(currentDir);
|
|
4279
|
+
try {
|
|
4280
|
+
for await (const entry of dirHandle) {
|
|
4281
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
4282
|
+
if (entry.isDirectory()) {
|
|
4283
|
+
if (visitedDirs + pendingDirs.length < maxDirs) {
|
|
4284
|
+
pendingDirs.push(fullPath);
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
else {
|
|
4288
|
+
visitedFiles += 1;
|
|
4289
|
+
if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
|
4290
|
+
return fullPath;
|
|
4291
|
+
}
|
|
4292
|
+
if (visitedFiles >= maxFiles) {
|
|
4293
|
+
break;
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4199
4297
|
}
|
|
4200
|
-
|
|
4201
|
-
|
|
4298
|
+
finally {
|
|
4299
|
+
await dirHandle.close().catch(() => undefined);
|
|
4202
4300
|
}
|
|
4203
4301
|
}
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4302
|
+
catch (error) {
|
|
4303
|
+
// Skip directories we can't read
|
|
4304
|
+
}
|
|
4207
4305
|
}
|
|
4208
4306
|
return null;
|
|
4209
4307
|
};
|
|
@@ -4211,10 +4309,10 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4211
4309
|
if (!sessionFilePath) {
|
|
4212
4310
|
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
|
4213
4311
|
}
|
|
4214
|
-
//
|
|
4215
|
-
|
|
4312
|
+
// Stream and parse the Codex JSONL file. Keeping only the latest
|
|
4313
|
+
// token_count event avoids loading very large session logs.
|
|
4216
4314
|
try {
|
|
4217
|
-
|
|
4315
|
+
await fsPromises.access(sessionFilePath);
|
|
4218
4316
|
}
|
|
4219
4317
|
catch (error) {
|
|
4220
4318
|
if (error.code === 'ENOENT') {
|
|
@@ -4222,13 +4320,19 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4222
4320
|
}
|
|
4223
4321
|
throw error;
|
|
4224
4322
|
}
|
|
4225
|
-
const
|
|
4323
|
+
const fileStream = fs.createReadStream(sessionFilePath, { encoding: 'utf8' });
|
|
4324
|
+
const rl = readline.createInterface({
|
|
4325
|
+
input: fileStream,
|
|
4326
|
+
crlfDelay: Infinity,
|
|
4327
|
+
});
|
|
4226
4328
|
let totalTokens = 0;
|
|
4227
4329
|
let contextWindow = 200000; // Default for Codex/OpenAI
|
|
4228
|
-
|
|
4229
|
-
|
|
4330
|
+
for await (const line of rl) {
|
|
4331
|
+
if (!line.trim() || line.length > JSONL_STREAM_LINE_MAX_CHARS) {
|
|
4332
|
+
continue;
|
|
4333
|
+
}
|
|
4230
4334
|
try {
|
|
4231
|
-
const entry = JSON.parse(
|
|
4335
|
+
const entry = JSON.parse(line);
|
|
4232
4336
|
// Codex stores token info in event_msg with type: "token_count"
|
|
4233
4337
|
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
4234
4338
|
const tokenInfo = entry.payload.info;
|
|
@@ -4238,7 +4342,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4238
4342
|
if (tokenInfo.model_context_window) {
|
|
4239
4343
|
contextWindow = tokenInfo.model_context_window;
|
|
4240
4344
|
}
|
|
4241
|
-
break; // Stop after finding the latest token count
|
|
4242
4345
|
}
|
|
4243
4346
|
}
|
|
4244
4347
|
catch (parseError) {
|
|
@@ -4272,10 +4375,10 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4272
4375
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
4273
4376
|
return res.status(400).json({ error: 'Invalid path' });
|
|
4274
4377
|
}
|
|
4275
|
-
//
|
|
4276
|
-
|
|
4378
|
+
// Stream and parse the JSONL file. Keeping only the latest assistant
|
|
4379
|
+
// usage object avoids loading very large session logs.
|
|
4277
4380
|
try {
|
|
4278
|
-
|
|
4381
|
+
await fsPromises.access(jsonlPath);
|
|
4279
4382
|
}
|
|
4280
4383
|
catch (error) {
|
|
4281
4384
|
if (error.code === 'ENOENT') {
|
|
@@ -4283,16 +4386,22 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4283
4386
|
}
|
|
4284
4387
|
throw error; // Re-throw other errors to be caught by outer try-catch
|
|
4285
4388
|
}
|
|
4286
|
-
const
|
|
4389
|
+
const fileStream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
|
|
4390
|
+
const rl = readline.createInterface({
|
|
4391
|
+
input: fileStream,
|
|
4392
|
+
crlfDelay: Infinity,
|
|
4393
|
+
});
|
|
4287
4394
|
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
|
4288
4395
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
|
4289
4396
|
let inputTokens = 0;
|
|
4290
4397
|
let cacheCreationTokens = 0;
|
|
4291
4398
|
let cacheReadTokens = 0;
|
|
4292
|
-
|
|
4293
|
-
|
|
4399
|
+
for await (const line of rl) {
|
|
4400
|
+
if (!line.trim() || line.length > JSONL_STREAM_LINE_MAX_CHARS) {
|
|
4401
|
+
continue;
|
|
4402
|
+
}
|
|
4294
4403
|
try {
|
|
4295
|
-
const entry = JSON.parse(
|
|
4404
|
+
const entry = JSON.parse(line);
|
|
4296
4405
|
// Only count assistant messages which have usage data
|
|
4297
4406
|
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
4298
4407
|
const usage = entry.message.usage;
|
|
@@ -4300,7 +4409,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
4300
4409
|
inputTokens = usage.input_tokens || 0;
|
|
4301
4410
|
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
4302
4411
|
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
4303
|
-
break; // Stop after finding the latest assistant message
|
|
4304
4412
|
}
|
|
4305
4413
|
}
|
|
4306
4414
|
catch (parseError) {
|
|
@@ -4408,68 +4516,107 @@ function permToRwx(perm) {
|
|
|
4408
4516
|
const x = perm & 1 ? 'x' : '-';
|
|
4409
4517
|
return r + w + x;
|
|
4410
4518
|
}
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4519
|
+
function createFileTreeScanContext() {
|
|
4520
|
+
return {
|
|
4521
|
+
startedAt: Date.now(),
|
|
4522
|
+
itemCount: 0,
|
|
4523
|
+
directoryCount: 0,
|
|
4524
|
+
limitReached: false,
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
function hasFileTreeBudget(context) {
|
|
4528
|
+
if (!context || context.limitReached) {
|
|
4529
|
+
return false;
|
|
4530
|
+
}
|
|
4531
|
+
if (context.itemCount >= FILE_TREE_MAX_ITEMS || context.directoryCount >= FILE_TREE_MAX_DIRECTORIES) {
|
|
4532
|
+
context.limitReached = true;
|
|
4533
|
+
return false;
|
|
4534
|
+
}
|
|
4535
|
+
if (Date.now() - context.startedAt > FILE_TREE_SCAN_MAX_MS) {
|
|
4536
|
+
context.limitReached = true;
|
|
4537
|
+
return false;
|
|
4538
|
+
}
|
|
4539
|
+
return true;
|
|
4540
|
+
}
|
|
4541
|
+
function shouldSkipFileTreeEntry(entryName, showHidden) {
|
|
4542
|
+
if (!showHidden && entryName.startsWith('.')) {
|
|
4543
|
+
return true;
|
|
4544
|
+
}
|
|
4545
|
+
return FILE_TREE_EXCLUDED_ENTRY_NAMES.has(entryName)
|
|
4546
|
+
|| entryName.endsWith('.log')
|
|
4547
|
+
|| entryName === '.DS_Store';
|
|
4548
|
+
}
|
|
4549
|
+
async function readDirectoryEntriesBounded(dirPath, context) {
|
|
4550
|
+
const entries = [];
|
|
4414
4551
|
try {
|
|
4415
|
-
const
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
entry.name === 'build' ||
|
|
4422
|
-
entry.name === '.git' ||
|
|
4423
|
-
entry.name === '.svn' ||
|
|
4424
|
-
entry.name === '.hg')
|
|
4425
|
-
continue;
|
|
4426
|
-
const itemPath = path.join(dirPath, entry.name);
|
|
4427
|
-
const item = {
|
|
4428
|
-
name: entry.name,
|
|
4429
|
-
path: itemPath,
|
|
4430
|
-
type: entry.isDirectory() ? 'directory' : 'file'
|
|
4431
|
-
};
|
|
4432
|
-
// Get file stats for additional metadata
|
|
4433
|
-
try {
|
|
4434
|
-
const stats = await fsPromises.stat(itemPath);
|
|
4435
|
-
item.size = stats.size;
|
|
4436
|
-
item.modified = stats.mtime.toISOString();
|
|
4437
|
-
// Convert permissions to rwx format
|
|
4438
|
-
const mode = stats.mode;
|
|
4439
|
-
const ownerPerm = (mode >> 6) & 7;
|
|
4440
|
-
const groupPerm = (mode >> 3) & 7;
|
|
4441
|
-
const otherPerm = mode & 7;
|
|
4442
|
-
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
|
4443
|
-
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
|
4444
|
-
}
|
|
4445
|
-
catch (statError) {
|
|
4446
|
-
// If stat fails, provide default values
|
|
4447
|
-
item.size = 0;
|
|
4448
|
-
item.modified = null;
|
|
4449
|
-
item.permissions = '000';
|
|
4450
|
-
item.permissionsRwx = '---------';
|
|
4451
|
-
}
|
|
4452
|
-
if (entry.isDirectory() && currentDepth < maxDepth) {
|
|
4453
|
-
// Recursively get subdirectories but limit depth
|
|
4454
|
-
try {
|
|
4455
|
-
// Check if we can access the directory before trying to read it
|
|
4456
|
-
await fsPromises.access(item.path, fs.constants.R_OK);
|
|
4457
|
-
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
|
4458
|
-
}
|
|
4459
|
-
catch (e) {
|
|
4460
|
-
// Silently skip directories we can't access (permission denied, etc.)
|
|
4461
|
-
item.children = [];
|
|
4552
|
+
const dir = await fsPromises.opendir(dirPath);
|
|
4553
|
+
try {
|
|
4554
|
+
for await (const entry of dir) {
|
|
4555
|
+
if (!hasFileTreeBudget(context) || entries.length >= FILE_TREE_MAX_ENTRIES_PER_DIRECTORY) {
|
|
4556
|
+
context.limitReached = true;
|
|
4557
|
+
break;
|
|
4462
4558
|
}
|
|
4559
|
+
entries.push(entry);
|
|
4463
4560
|
}
|
|
4464
|
-
|
|
4561
|
+
}
|
|
4562
|
+
finally {
|
|
4563
|
+
await dir.close().catch(() => undefined);
|
|
4465
4564
|
}
|
|
4466
4565
|
}
|
|
4467
4566
|
catch (error) {
|
|
4468
|
-
|
|
4469
|
-
if (error.code !== 'EACCES' && error.code !== 'EPERM') {
|
|
4567
|
+
if (error.code !== 'EACCES' && error.code !== 'EPERM' && error.code !== 'ENOENT') {
|
|
4470
4568
|
console.error('Error reading directory:', error);
|
|
4471
4569
|
}
|
|
4472
4570
|
}
|
|
4571
|
+
return entries;
|
|
4572
|
+
}
|
|
4573
|
+
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true, scanContext = null) {
|
|
4574
|
+
const context = scanContext || createFileTreeScanContext();
|
|
4575
|
+
const items = [];
|
|
4576
|
+
if (!hasFileTreeBudget(context)) {
|
|
4577
|
+
return items;
|
|
4578
|
+
}
|
|
4579
|
+
context.directoryCount += 1;
|
|
4580
|
+
const entries = await readDirectoryEntriesBounded(dirPath, context);
|
|
4581
|
+
for (const entry of entries) {
|
|
4582
|
+
if (!hasFileTreeBudget(context) || shouldSkipFileTreeEntry(entry.name, showHidden)) {
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
const itemPath = path.join(dirPath, entry.name);
|
|
4586
|
+
const item = {
|
|
4587
|
+
name: entry.name,
|
|
4588
|
+
path: itemPath,
|
|
4589
|
+
type: entry.isDirectory() ? 'directory' : 'file'
|
|
4590
|
+
};
|
|
4591
|
+
try {
|
|
4592
|
+
const stats = await fsPromises.stat(itemPath);
|
|
4593
|
+
item.size = stats.size;
|
|
4594
|
+
item.modified = stats.mtime.toISOString();
|
|
4595
|
+
const mode = stats.mode;
|
|
4596
|
+
const ownerPerm = (mode >> 6) & 7;
|
|
4597
|
+
const groupPerm = (mode >> 3) & 7;
|
|
4598
|
+
const otherPerm = mode & 7;
|
|
4599
|
+
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
|
|
4600
|
+
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
|
|
4601
|
+
}
|
|
4602
|
+
catch {
|
|
4603
|
+
item.size = 0;
|
|
4604
|
+
item.modified = null;
|
|
4605
|
+
item.permissions = '000';
|
|
4606
|
+
item.permissionsRwx = '---------';
|
|
4607
|
+
}
|
|
4608
|
+
context.itemCount += 1;
|
|
4609
|
+
if (entry.isDirectory() && currentDepth < maxDepth && hasFileTreeBudget(context)) {
|
|
4610
|
+
try {
|
|
4611
|
+
await fsPromises.access(item.path, fs.constants.R_OK);
|
|
4612
|
+
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden, context);
|
|
4613
|
+
}
|
|
4614
|
+
catch {
|
|
4615
|
+
item.children = [];
|
|
4616
|
+
}
|
|
4617
|
+
}
|
|
4618
|
+
items.push(item);
|
|
4619
|
+
}
|
|
4473
4620
|
return items.sort((a, b) => {
|
|
4474
4621
|
if (a.type !== b.type) {
|
|
4475
4622
|
return a.type === 'directory' ? -1 : 1;
|