tide-commander 1.136.1 → 1.136.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{BossLogsModal-ed7L3S97.js → BossLogsModal-B4p16n9B.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-C-KkKRgI.js → BossSpawnModal-BHQ7sxeu.js} +1 -1
  3. package/dist/assets/{ControlsModal-DBJPuS6z.js → ControlsModal-BcP5-m0h.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-D8lPR5BA.js → DockerLogsModal-C_Gd6YLn.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-D5opmQLi.js → EmbeddedEditor-GHVeXIn5.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-CGhj8CNw.js → GmailOAuthSetup-VjAKcJf9.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-Br7IfgCD.js → GoogleOAuthSetup-DFbaN0Ry.js} +1 -1
  8. package/dist/assets/{IframeModal-EOV4pjTD.js → IframeModal-BaoVOZTf.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BJED0ToF.js → IntegrationsPanel-Cg4TxOfh.js} +2 -2
  10. package/dist/assets/{LogViewerModal-BY1nfMIb.js → LogViewerModal-CN2GYpgr.js} +1 -1
  11. package/dist/assets/{MonitoringModal-C88vTxw-.js → MonitoringModal-oN_JjoC2.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-CVbr9x3j.js → PM2LogsModal-BOQ1Im-i.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-Du4e4ebk.js → RestoreArchivedAreaModal-DYcjqjbA.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-DW6-BjZg.js → Scene2DCanvas-CsBA5ERQ.js} +1 -1
  15. package/dist/assets/{SceneManager-DFaHLv0b.js → SceneManager-BqVfBAQc.js} +1 -1
  16. package/dist/assets/{SkillsPanel-R6txFGjx.js → SkillsPanel-CtfixcgQ.js} +1 -1
  17. package/dist/assets/{SlackMultiInstanceSetup-Cd7eklY5.js → SlackMultiInstanceSetup-3nGO_xs6.js} +1 -1
  18. package/dist/assets/{SpawnModal-qJUC6gUU.js → SpawnModal-BvUHN-Q1.js} +1 -1
  19. package/dist/assets/{StatisticsModal-BVacMzYw.js → StatisticsModal-DIx8l3F0.js} +1 -1
  20. package/dist/assets/{SubordinateAssignmentModal-BM4JvNyT.js → SubordinateAssignmentModal-Bw-V4i4-.js} +1 -1
  21. package/dist/assets/{TriggerManagerPanel-CHhTlUF3.js → TriggerManagerPanel-C2Mz8wVK.js} +1 -1
  22. package/dist/assets/{WorkflowEditorPanel-C_V9Xmy8.js → WorkflowEditorPanel-C2RHV8Dx.js} +1 -1
  23. package/dist/assets/{area-logos-CcnztkrB.js → area-logos-aaW_jqzI.js} +1 -1
  24. package/dist/assets/{index-BvfNZfX7.js → index-B4JG--zt.js} +2 -2
  25. package/dist/assets/{index-DKbbtwcq.js → index-BGz62M0g.js} +1 -1
  26. package/dist/assets/{index-47gQB8Y5.js → index-BIUEhCXu.js} +1 -1
  27. package/dist/assets/{index-CF55uz6s.js → index-BT7u2ftY.js} +1 -1
  28. package/dist/assets/{index-DwcZd659.js → index-BvR0it5l.js} +5 -5
  29. package/dist/assets/{index-DXbpyN-P.js → index-BzCUPOjQ.js} +1 -1
  30. package/dist/assets/{index-BvlPsF-d.js → index-CmGwjdPh.js} +1 -1
  31. package/dist/assets/{index-CMP6SUq-.js → index-D6r1xAEg.js} +1 -1
  32. package/dist/assets/{index-Bd05QMt_.js → index-DidVGiaJ.js} +2 -2
  33. package/dist/assets/{main-Db4or1Jx.js → main-rDQDtv_U.js} +115 -115
  34. package/dist/assets/{web-BsXkVdvj.js → web-B5Ma_-eu.js} +1 -1
  35. package/dist/assets/{web-BnBWe9ee.js → web-DojkIxYY.js} +1 -1
  36. package/dist/assets/{web-BahzYw5F.js → web-Dy1328BB.js} +1 -1
  37. package/dist/index.html +1 -1
  38. package/dist/src/packages/server/routes/files.js +297 -92
  39. package/package.json +1 -1
@@ -1 +1 @@
1
- import{cO as a}from"./main-Db4or1Jx.js";import{ImpactStyle as i,NotificationType as r}from"./index-BvfNZfX7.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{cO as a}from"./main-rDQDtv_U.js";import{ImpactStyle as i,NotificationType as r}from"./index-B4JG--zt.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
@@ -1 +1 @@
1
- import{cO as t}from"./main-Db4or1Jx.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
1
+ import{cO as t}from"./main-rDQDtv_U.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class o extends t{constructor(){super(),this.handleVisibilityChange=()=>{const e={isActive:document.hidden!==!0};this.notifyListeners("appStateChange",e),document.hidden?this.notifyListeners("pause",null):this.notifyListeners("resume",null)},document.addEventListener("visibilitychange",this.handleVisibilityChange,!1)}exitApp(){throw this.unimplemented("Not implemented on web.")}async getInfo(){throw this.unimplemented("Not implemented on web.")}async getLaunchUrl(){return{url:""}}async getState(){return{isActive:document.hidden!==!0}}async minimizeApp(){throw this.unimplemented("Not implemented on web.")}async toggleBackButtonHandler(){throw this.unimplemented("Not implemented on web.")}async getAppLanguage(){return{value:navigator.language.split("-")[0].toLowerCase()}}}export{o as AppWeb};
@@ -1 +1 @@
1
- import{cO as s}from"./main-Db4or1Jx.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{cO as s}from"./main-rDQDtv_U.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-Db4or1Jx.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-rDQDtv_U.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
28
  <link rel="stylesheet" crossorigin href="/assets/main-Ba3wdQK_.css">
@@ -274,6 +274,60 @@ function findBySuffixMatch(rawPath, walkRoot) {
274
274
  }
275
275
  return null;
276
276
  }
277
+ // The suffix walk skips node_modules for cost, but locally linked / installed
278
+ // packages (e.g. a monorepo's own `tide-api`) legitimately live there — so a
279
+ // path like `tide-api/src/api-core.js` can only be found under a node_modules
280
+ // tree. Instead of walking all of node_modules (huge), we locate WHERE the
281
+ // node_modules dirs are (shallow — they sit at workspace roots, and we never
282
+ // descend INTO one) and then probe the requested path's tail-slices under each.
283
+ const NODE_MODULES_SCAN_MAX_DEPTH = 4;
284
+ const NODE_MODULES_MAX_DIRS = 24;
285
+ function collectNodeModulesDirs(base, depth, out) {
286
+ if (out.length >= NODE_MODULES_MAX_DIRS || depth > NODE_MODULES_SCAN_MAX_DEPTH)
287
+ return;
288
+ let entries;
289
+ try {
290
+ entries = fs.readdirSync(base, { withFileTypes: true });
291
+ }
292
+ catch {
293
+ return;
294
+ }
295
+ for (const e of entries) {
296
+ if (!e.isDirectory())
297
+ continue;
298
+ if (e.name === 'node_modules') {
299
+ out.push(path.join(base, e.name));
300
+ if (out.length >= NODE_MODULES_MAX_DIRS)
301
+ return;
302
+ continue; // don't recurse into node_modules; tail-slice probing covers depth
303
+ }
304
+ if (e.name.startsWith('.') || SUFFIX_WALK_IGNORE.has(e.name))
305
+ continue;
306
+ collectNodeModulesDirs(path.join(base, e.name), depth + 1, out);
307
+ if (out.length >= NODE_MODULES_MAX_DIRS)
308
+ return;
309
+ }
310
+ }
311
+ // Probe the requested path (and each of its tail-slices) under every
312
+ // node_modules dir found beneath `base`. Uses the shared tryCandidate so hits
313
+ // are validated + recorded in `tried`. Returns the first existing file.
314
+ function findInNodeModules(rawPath, base, tryCandidate) {
315
+ if (!fs.existsSync(base))
316
+ return null;
317
+ const segments = rawPath.replace(/^\/+/, '').split(path.sep).filter(Boolean);
318
+ if (segments.length === 0)
319
+ return null;
320
+ const dirs = [];
321
+ collectNodeModulesDirs(base, 0, dirs);
322
+ for (const nm of dirs) {
323
+ for (let i = 0; i < segments.length; i++) {
324
+ const hit = tryCandidate(path.join(nm, ...segments.slice(i)));
325
+ if (hit)
326
+ return hit;
327
+ }
328
+ }
329
+ return null;
330
+ }
277
331
  /**
278
332
  * Resolve a requested file path to an existing file on disk, with fallbacks.
279
333
  * Tries (in order):
@@ -282,8 +336,10 @@ function findBySuffixMatch(rawPath, walkRoot) {
282
336
  * 3. parent-walk — tail slices anchored at baseDir AND each ancestor up to /
283
337
  * 4. git-root — tail slices anchored at the git toplevel from baseDir
284
338
  * 5. suffix-match — depth-limited walk of baseDir, unique trailing-segment match
285
- * 6. area-root verbatim join against each user-configured area directory
286
- * 7. area-suffix-match same depth-limited walk but rooted at each area directory
339
+ * 6. node-modules-match tail slices probed under each node_modules dir beneath baseDir
340
+ * 7. area-root verbatim join against each user-configured area directory
341
+ * 8. area-suffix-match — same depth-limited walk but rooted at each area directory
342
+ * 9. node-modules-match — same node_modules probe but rooted at each area directory
287
343
  * On miss, returns the absolute path requested AND the list of paths tried so
288
344
  * the caller can surface a clear, debuggable error.
289
345
  */
@@ -295,6 +351,17 @@ export function findFileWithFallbacks(rawPath, baseDir) {
295
351
  if (!resolution.ok) {
296
352
  return resolution;
297
353
  }
354
+ // A path that resolves directly to an existing directory is a "browse this
355
+ // folder" request, not a missing file. Short-circuit here so we return a
356
+ // clear directory signal instead of running the whole fallback walk — which
357
+ // rejects the directory at every candidate (tryCandidate skips directories)
358
+ // and would surface dozens of futile "tried" locations before 404-ing.
359
+ try {
360
+ if (fs.existsSync(resolution.path) && fs.statSync(resolution.path).isDirectory()) {
361
+ return { ok: false, status: 400, error: 'Path is a directory', requested: resolution.path };
362
+ }
363
+ }
364
+ catch { /* stat failed (perms/broken symlink) — fall through to normal resolution */ }
298
365
  const tried = [];
299
366
  const seen = new Set();
300
367
  const tryCandidate = (p) => {
@@ -375,7 +442,18 @@ export function findFileWithFallbacks(rawPath, baseDir) {
375
442
  }
376
443
  }
377
444
  }
378
- catch { /* walk failed entirely — fall through to area strategies */ }
445
+ catch { /* walk failed entirely — fall through to node_modules/area strategies */ }
446
+ // node_modules-anchored: the suffix walk above skips node_modules, so a
447
+ // locally-linked package path (e.g. tide-api/src/api-core.js) is only
448
+ // reachable here.
449
+ try {
450
+ const nmHit = findInNodeModules(rawPath, absBase, tryCandidate);
451
+ if (nmHit) {
452
+ rememberResolution(rawPath, nmHit, 'node-modules-match');
453
+ return { ok: true, path: nmHit, strategy: 'node-modules-match' };
454
+ }
455
+ }
456
+ catch { /* scan failed — fall through to area strategies */ }
379
457
  }
380
458
  // Area strategies: try the user's configured area directories. Runs whether
381
459
  // or not baseDir is set — area paths are independent. Capped to keep cold
@@ -415,6 +493,20 @@ export function findFileWithFallbacks(rawPath, baseDir) {
415
493
  }
416
494
  catch { /* walk failed for this area — try next */ }
417
495
  }
496
+ // node_modules-anchored search within each area dir (same reasoning as the
497
+ // baseDir pass above: locally-linked packages under an area's node_modules).
498
+ for (const { areaId, areaName, dir } of areaDirs) {
499
+ if (!fs.existsSync(dir))
500
+ continue;
501
+ try {
502
+ const nmHit = findInNodeModules(rawPath, dir, tryCandidate);
503
+ if (nmHit) {
504
+ rememberResolution(rawPath, nmHit, 'node-modules-match');
505
+ return { ok: true, path: nmHit, strategy: 'node-modules-match', areaId, areaName };
506
+ }
507
+ }
508
+ catch { /* scan failed for this area — try next */ }
509
+ }
418
510
  return {
419
511
  ok: false,
420
512
  status: 404,
@@ -513,10 +605,10 @@ router.get('/resolve', async (req, res) => {
513
605
  }
514
606
  catch { /* skip */ }
515
607
  }
516
- // Search for files matching the basename (searchFiles may re-find .claude/ files)
608
+ // Search for files matching the basename (searchFilesAsync may re-find .claude/ files)
517
609
  const existingPaths = new Set(results.map(r => r.path));
518
610
  const searchResults = [];
519
- searchFiles(searchRoot, basename, searchResults, 20);
611
+ await searchFilesAsync(searchRoot, basename.toLowerCase(), searchResults, 20);
520
612
  for (const r of searchResults) {
521
613
  if (!existingPaths.has(r.path))
522
614
  results.push(r);
@@ -993,43 +1085,61 @@ router.post('/paste', (req, res) => {
993
1085
  res.status(500).json({ error: err.message });
994
1086
  }
995
1087
  });
996
- // Helper function to search files recursively
997
- function searchFiles(dirPath, query, results, maxResults, depth = 0) {
998
- if (results.length >= maxResults || depth > 10)
1088
+ // Directories that are always skipped during file/content search. Kept as a
1089
+ // Set for O(1) lookups (this is checked once per directory entry during the
1090
+ // tree walk). Union of the old filename + content skip lists.
1091
+ const SEARCH_SKIP_DIRS = new Set([
1092
+ 'node_modules', 'dist', 'build', '.git', '__pycache__',
1093
+ 'venv', '.venv', 'target', 'vendor', '.next', '.cache',
1094
+ ]);
1095
+ const MAX_SEARCH_DEPTH = 10;
1096
+ // Helper function to search files recursively (async / non-blocking).
1097
+ // Uses fs.promises so a deep tree walk never blocks the event loop, which
1098
+ // matters on a shared multi-agent server. `queryLower` must be pre-lowercased.
1099
+ async function searchFilesAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1100
+ if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
999
1101
  return;
1102
+ let entries;
1000
1103
  try {
1001
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1002
- for (const entry of entries) {
1003
- if (results.length >= maxResults)
1004
- break;
1005
- // Skip common non-essential directories
1006
- if (['node_modules', 'dist', '.git', '__pycache__', 'venv', '.venv'].includes(entry.name))
1007
- continue;
1008
- const fullPath = path.join(dirPath, entry.name);
1009
- // Check if name matches query (case-insensitive)
1010
- if (entry.name.toLowerCase().includes(query.toLowerCase())) {
1011
- try {
1012
- const stats = fs.statSync(fullPath);
1013
- results.push({
1014
- name: entry.name,
1015
- path: fullPath,
1016
- isDirectory: entry.isDirectory(),
1017
- size: stats.size,
1018
- extension: entry.isDirectory() ? '' : path.extname(entry.name).toLowerCase(),
1019
- });
1020
- }
1021
- catch {
1022
- // Skip
1023
- }
1104
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
1105
+ }
1106
+ catch {
1107
+ return; // Skip directories we can't read
1108
+ }
1109
+ const subdirs = [];
1110
+ for (const entry of entries) {
1111
+ if (results.length >= maxResults)
1112
+ break;
1113
+ const isDir = entry.isDirectory();
1114
+ // Skip common non-essential directories
1115
+ if (isDir && SEARCH_SKIP_DIRS.has(entry.name))
1116
+ continue;
1117
+ const fullPath = path.join(dirPath, entry.name);
1118
+ // Check if name matches query (case-insensitive)
1119
+ if (entry.name.toLowerCase().includes(queryLower)) {
1120
+ let size = 0;
1121
+ try {
1122
+ size = (await fs.promises.stat(fullPath)).size;
1024
1123
  }
1025
- // Recurse into directories
1026
- if (entry.isDirectory()) {
1027
- searchFiles(fullPath, query, results, maxResults, depth + 1);
1124
+ catch {
1125
+ // Keep size 0 if stat fails (broken symlink etc.) but still surface it
1028
1126
  }
1127
+ results.push({
1128
+ name: entry.name,
1129
+ path: fullPath,
1130
+ isDirectory: isDir,
1131
+ size,
1132
+ extension: isDir ? '' : path.extname(entry.name).toLowerCase(),
1133
+ });
1029
1134
  }
1135
+ if (isDir)
1136
+ subdirs.push(fullPath);
1030
1137
  }
1031
- catch {
1032
- // Skip directories we can't read
1138
+ // Recurse after listing this level so match/recurse order stays bounded.
1139
+ for (const sub of subdirs) {
1140
+ if (results.length >= maxResults)
1141
+ break;
1142
+ await searchFilesAsync(sub, queryLower, results, maxResults, depth + 1);
1033
1143
  }
1034
1144
  }
1035
1145
  // GET /api/files/search - Search for files
@@ -1051,7 +1161,7 @@ router.get('/search', async (req, res) => {
1051
1161
  return;
1052
1162
  }
1053
1163
  const results = [];
1054
- searchFiles(dirPath, query, results, maxResults);
1164
+ await searchFilesAsync(dirPath, query.toLowerCase(), results, maxResults);
1055
1165
  // Sort: files first (more likely what user wants), then by name
1056
1166
  results.sort((a, b) => {
1057
1167
  if (a.isDirectory !== b.isDirectory) {
@@ -1077,67 +1187,155 @@ const TEXT_EXTENSIONS = new Set([
1077
1187
  '.editorconfig', '.prettierrc', '.eslintrc', '.babelrc',
1078
1188
  '.log', '.csv', '.tsv', '.svg', '.vue', '.svelte',
1079
1189
  ]);
1080
- // Helper function to search file contents recursively
1081
- function searchFileContents(dirPath, query, results, maxResults, depth = 0) {
1082
- if (results.length >= maxResults || depth > 10)
1083
- return;
1084
- try {
1085
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1086
- const queryLower = query.toLowerCase();
1087
- for (const entry of entries) {
1088
- if (results.length >= maxResults)
1089
- break;
1090
- // Skip common non-essential directories
1091
- if (['node_modules', 'dist', 'build', '.git', '__pycache__', 'venv', '.venv', 'target', 'vendor'].includes(entry.name))
1092
- continue;
1093
- const fullPath = path.join(dirPath, entry.name);
1094
- if (entry.isDirectory()) {
1095
- // Recurse into directories
1096
- searchFileContents(fullPath, query, results, maxResults, depth + 1);
1097
- }
1098
- else {
1099
- // Check if it's a text file we can search
1100
- const ext = path.extname(entry.name).toLowerCase();
1101
- if (!TEXT_EXTENSIONS.has(ext) && ext !== '')
1190
+ // Max matches surfaced per file (both search paths respect this).
1191
+ const MAX_MATCHES_PER_FILE = 5;
1192
+ // Skip files larger than 1MB for content search.
1193
+ const MAX_CONTENT_FILE_SIZE = 1024 * 1024;
1194
+ // Primary content search: shell out to ripgrep. It runs off the event loop as
1195
+ // a child process (never blocking the server) and is orders of magnitude faster
1196
+ // than a JS tree walk. Resolves { ok:false } when rg is missing or errors, so
1197
+ // the caller can fall back to the pure-JS walker.
1198
+ function searchFileContentsRipgrep(dirPath, query, maxResults) {
1199
+ return new Promise((resolve) => {
1200
+ const args = [
1201
+ '--json',
1202
+ '--ignore-case',
1203
+ '--fixed-strings', // treat the query as a literal substring
1204
+ '--max-count', String(MAX_MATCHES_PER_FILE),
1205
+ '--max-filesize', String(MAX_CONTENT_FILE_SIZE),
1206
+ '--max-depth', String(MAX_SEARCH_DEPTH + 1),
1207
+ '--hidden', // include dotfiles (.env, .github, ...)
1208
+ '--no-ignore', // don't honor .gitignore (match old behavior)
1209
+ '--no-messages', // suppress permission/binary warnings on stderr
1210
+ ...[...SEARCH_SKIP_DIRS].flatMap((d) => ['--glob', `!**/${d}/**`]),
1211
+ '--',
1212
+ query,
1213
+ dirPath,
1214
+ ];
1215
+ let child;
1216
+ try {
1217
+ child = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
1218
+ }
1219
+ catch {
1220
+ resolve({ ok: false, results: [] });
1221
+ return;
1222
+ }
1223
+ const byFile = new Map();
1224
+ let buffer = '';
1225
+ let settled = false;
1226
+ const finish = (ok) => {
1227
+ if (settled)
1228
+ return;
1229
+ settled = true;
1230
+ resolve({ ok, results: [...byFile.values()] });
1231
+ };
1232
+ // Spawn failure (e.g. rg not installed) → fall back to the JS walker.
1233
+ child.on('error', () => finish(false));
1234
+ child.stdout?.setEncoding('utf-8');
1235
+ child.stdout?.on('data', (chunk) => {
1236
+ buffer += chunk;
1237
+ let nl;
1238
+ while ((nl = buffer.indexOf('\n')) !== -1) {
1239
+ const line = buffer.slice(0, nl);
1240
+ buffer = buffer.slice(nl + 1);
1241
+ if (!line)
1102
1242
  continue;
1243
+ let evt;
1103
1244
  try {
1104
- const stats = fs.statSync(fullPath);
1105
- // Skip files larger than 1MB
1106
- if (stats.size > 1024 * 1024)
1107
- continue;
1108
- const content = fs.readFileSync(fullPath, 'utf-8');
1109
- const lines = content.split('\n');
1110
- const matches = [];
1111
- for (let i = 0; i < lines.length && matches.length < 5; i++) {
1112
- const line = lines[i];
1113
- if (line.toLowerCase().includes(queryLower)) {
1114
- matches.push({
1115
- line: i + 1,
1116
- content: line.slice(0, 200), // Truncate long lines
1117
- context: {
1118
- before: i > 0 ? lines[i - 1].slice(0, 100) : '',
1119
- after: i < lines.length - 1 ? lines[i + 1].slice(0, 100) : '',
1120
- },
1121
- });
1122
- }
1123
- }
1124
- if (matches.length > 0) {
1125
- results.push({
1126
- path: fullPath,
1127
- name: entry.name,
1128
- extension: ext,
1129
- matches,
1130
- });
1131
- }
1245
+ evt = JSON.parse(line);
1132
1246
  }
1133
1247
  catch {
1134
- // Skip files we can't read (binary, permission issues)
1248
+ continue;
1249
+ }
1250
+ if (evt.type !== 'match')
1251
+ continue;
1252
+ const filePath = evt.data?.path?.text;
1253
+ if (!filePath)
1254
+ continue;
1255
+ let cm = byFile.get(filePath);
1256
+ if (!cm) {
1257
+ // Cap the number of distinct files we track.
1258
+ if (byFile.size >= maxResults) {
1259
+ child.kill();
1260
+ break;
1261
+ }
1262
+ cm = {
1263
+ path: filePath,
1264
+ name: path.basename(filePath),
1265
+ extension: path.extname(filePath).toLowerCase(),
1266
+ matches: [],
1267
+ };
1268
+ byFile.set(filePath, cm);
1135
1269
  }
1270
+ if (cm.matches.length >= MAX_MATCHES_PER_FILE)
1271
+ continue;
1272
+ const text = (evt.data?.lines?.text ?? '').replace(/\r?\n$/, '');
1273
+ cm.matches.push({
1274
+ line: evt.data?.line_number ?? 0,
1275
+ content: text.slice(0, 200),
1276
+ });
1136
1277
  }
1137
- }
1278
+ });
1279
+ // rg exit codes: 0 = matches, 1 = no matches, 2 = error. Only a real error
1280
+ // (or a kill, code null) that produced nothing should trigger the fallback.
1281
+ child.on('close', (code) => finish(!(code === 2 && byFile.size === 0)));
1282
+ });
1283
+ }
1284
+ // Fallback content search (async / non-blocking) used only when ripgrep is
1285
+ // unavailable. Mirrors the old behavior but with fs.promises so it never
1286
+ // blocks the event loop.
1287
+ async function searchFileContentsAsync(dirPath, queryLower, results, maxResults, depth = 0) {
1288
+ if (results.length >= maxResults || depth > MAX_SEARCH_DEPTH)
1289
+ return;
1290
+ let entries;
1291
+ try {
1292
+ entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
1138
1293
  }
1139
1294
  catch {
1140
- // Skip directories we can't read
1295
+ return; // Skip directories we can't read
1296
+ }
1297
+ const subdirs = [];
1298
+ for (const entry of entries) {
1299
+ if (results.length >= maxResults)
1300
+ break;
1301
+ if (entry.isDirectory()) {
1302
+ if (!SEARCH_SKIP_DIRS.has(entry.name))
1303
+ subdirs.push(path.join(dirPath, entry.name));
1304
+ continue;
1305
+ }
1306
+ // Check if it's a text file we can search
1307
+ const ext = path.extname(entry.name).toLowerCase();
1308
+ if (!TEXT_EXTENSIONS.has(ext) && ext !== '')
1309
+ continue;
1310
+ const fullPath = path.join(dirPath, entry.name);
1311
+ try {
1312
+ const stats = await fs.promises.stat(fullPath);
1313
+ if (stats.size > MAX_CONTENT_FILE_SIZE)
1314
+ continue;
1315
+ const content = await fs.promises.readFile(fullPath, 'utf-8');
1316
+ const lines = content.split('\n');
1317
+ const matches = [];
1318
+ for (let i = 0; i < lines.length && matches.length < MAX_MATCHES_PER_FILE; i++) {
1319
+ const line = lines[i];
1320
+ if (line.toLowerCase().includes(queryLower)) {
1321
+ matches.push({
1322
+ line: i + 1,
1323
+ content: line.slice(0, 200), // Truncate long lines
1324
+ });
1325
+ }
1326
+ }
1327
+ if (matches.length > 0) {
1328
+ results.push({ path: fullPath, name: entry.name, extension: ext, matches });
1329
+ }
1330
+ }
1331
+ catch {
1332
+ // Skip files we can't read (binary, permission issues)
1333
+ }
1334
+ }
1335
+ for (const sub of subdirs) {
1336
+ if (results.length >= maxResults)
1337
+ break;
1338
+ await searchFileContentsAsync(sub, queryLower, results, maxResults, depth + 1);
1141
1339
  }
1142
1340
  }
1143
1341
  // GET /api/files/search-content - Search file contents
@@ -1162,8 +1360,15 @@ router.get('/search-content', async (req, res) => {
1162
1360
  res.status(404).json({ error: 'Directory not found' });
1163
1361
  return;
1164
1362
  }
1165
- const results = [];
1166
- searchFileContents(dirPath, query, results, maxResults);
1363
+ let results = [];
1364
+ const rg = await searchFileContentsRipgrep(dirPath, query, maxResults);
1365
+ if (rg.ok) {
1366
+ results = rg.results;
1367
+ }
1368
+ else {
1369
+ // ripgrep unavailable — fall back to the pure-JS async walker.
1370
+ await searchFileContentsAsync(dirPath, query.toLowerCase(), results, maxResults);
1371
+ }
1167
1372
  res.json({ results });
1168
1373
  }
1169
1374
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tide-commander",
3
- "version": "1.136.1",
3
+ "version": "1.136.2",
4
4
  "description": "Visual multi-agent orchestrator and manager for Claude Code with 3D/2D interface",
5
5
  "repository": {
6
6
  "type": "git",