@pixelbyte-software/pixcode 1.53.18 → 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/index.html CHANGED
@@ -35,7 +35,7 @@
35
35
 
36
36
  <!-- Prevent zoom on iOS -->
37
37
  <meta name="format-detection" content="telephone=no" />
38
- <script type="module" crossorigin src="/assets/index-D1Nd_kpU.js"></script>
38
+ <script type="module" crossorigin src="/assets/index-CvIHrHIG.js"></script>
39
39
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-DB6V5Fl1.js">
40
40
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CIYNS698.js">
41
41
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-C7tpxJl7.js">
@@ -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
- if (eventType === 'addDir' || eventType === 'unlinkDir') {
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 all connected clients about project changes, scoped to their access.
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
- if (client.readyState === WebSocket.OPEN) {
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: 10, // Reasonable depth limit
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: 10,
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
- try {
4192
- const entries = await fsPromises.readdir(dir, { withFileTypes: true });
4193
- for (const entry of entries) {
4194
- const fullPath = path.join(dir, entry.name);
4195
- if (entry.isDirectory()) {
4196
- const found = await findSessionFile(fullPath);
4197
- if (found)
4198
- return found;
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
- else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
4201
- return fullPath;
4298
+ finally {
4299
+ await dirHandle.close().catch(() => undefined);
4202
4300
  }
4203
4301
  }
4204
- }
4205
- catch (error) {
4206
- // Skip directories we can't read
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
- // Read and parse the Codex JSONL file
4215
- let fileContent;
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
- fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
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 lines = fileContent.trim().split('\n');
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
- // Find the latest token_count event with info (scan from end)
4229
- for (let i = lines.length - 1; i >= 0; i--) {
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(lines[i]);
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
- // Read and parse the JSONL file
4276
- let fileContent;
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
- fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
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 lines = fileContent.trim().split('\n');
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
- // Find the latest assistant message with usage data (scan from end)
4293
- for (let i = lines.length - 1; i >= 0; i--) {
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(lines[i]);
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
- async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
4412
- // Using fsPromises from import
4413
- const items = [];
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 entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
4416
- for (const entry of entries) {
4417
- // Debug: log all entries including hidden files
4418
- // Skip heavy build directories and VCS directories
4419
- if (entry.name === 'node_modules' ||
4420
- entry.name === 'dist' ||
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
- items.push(item);
4561
+ }
4562
+ finally {
4563
+ await dir.close().catch(() => undefined);
4465
4564
  }
4466
4565
  }
4467
4566
  catch (error) {
4468
- // Only log non-permission errors to avoid spam
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;