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.
- package/dist/assets/{BossLogsModal-ed7L3S97.js → BossLogsModal-B4p16n9B.js} +1 -1
- package/dist/assets/{BossSpawnModal-C-KkKRgI.js → BossSpawnModal-BHQ7sxeu.js} +1 -1
- package/dist/assets/{ControlsModal-DBJPuS6z.js → ControlsModal-BcP5-m0h.js} +1 -1
- package/dist/assets/{DockerLogsModal-D8lPR5BA.js → DockerLogsModal-C_Gd6YLn.js} +1 -1
- package/dist/assets/{EmbeddedEditor-D5opmQLi.js → EmbeddedEditor-GHVeXIn5.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-CGhj8CNw.js → GmailOAuthSetup-VjAKcJf9.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-Br7IfgCD.js → GoogleOAuthSetup-DFbaN0Ry.js} +1 -1
- package/dist/assets/{IframeModal-EOV4pjTD.js → IframeModal-BaoVOZTf.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BJED0ToF.js → IntegrationsPanel-Cg4TxOfh.js} +2 -2
- package/dist/assets/{LogViewerModal-BY1nfMIb.js → LogViewerModal-CN2GYpgr.js} +1 -1
- package/dist/assets/{MonitoringModal-C88vTxw-.js → MonitoringModal-oN_JjoC2.js} +1 -1
- package/dist/assets/{PM2LogsModal-CVbr9x3j.js → PM2LogsModal-BOQ1Im-i.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Du4e4ebk.js → RestoreArchivedAreaModal-DYcjqjbA.js} +1 -1
- package/dist/assets/{Scene2DCanvas-DW6-BjZg.js → Scene2DCanvas-CsBA5ERQ.js} +1 -1
- package/dist/assets/{SceneManager-DFaHLv0b.js → SceneManager-BqVfBAQc.js} +1 -1
- package/dist/assets/{SkillsPanel-R6txFGjx.js → SkillsPanel-CtfixcgQ.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-Cd7eklY5.js → SlackMultiInstanceSetup-3nGO_xs6.js} +1 -1
- package/dist/assets/{SpawnModal-qJUC6gUU.js → SpawnModal-BvUHN-Q1.js} +1 -1
- package/dist/assets/{StatisticsModal-BVacMzYw.js → StatisticsModal-DIx8l3F0.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-BM4JvNyT.js → SubordinateAssignmentModal-Bw-V4i4-.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-CHhTlUF3.js → TriggerManagerPanel-C2Mz8wVK.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-C_V9Xmy8.js → WorkflowEditorPanel-C2RHV8Dx.js} +1 -1
- package/dist/assets/{area-logos-CcnztkrB.js → area-logos-aaW_jqzI.js} +1 -1
- package/dist/assets/{index-BvfNZfX7.js → index-B4JG--zt.js} +2 -2
- package/dist/assets/{index-DKbbtwcq.js → index-BGz62M0g.js} +1 -1
- package/dist/assets/{index-47gQB8Y5.js → index-BIUEhCXu.js} +1 -1
- package/dist/assets/{index-CF55uz6s.js → index-BT7u2ftY.js} +1 -1
- package/dist/assets/{index-DwcZd659.js → index-BvR0it5l.js} +5 -5
- package/dist/assets/{index-DXbpyN-P.js → index-BzCUPOjQ.js} +1 -1
- package/dist/assets/{index-BvlPsF-d.js → index-CmGwjdPh.js} +1 -1
- package/dist/assets/{index-CMP6SUq-.js → index-D6r1xAEg.js} +1 -1
- package/dist/assets/{index-Bd05QMt_.js → index-DidVGiaJ.js} +2 -2
- package/dist/assets/{main-Db4or1Jx.js → main-rDQDtv_U.js} +115 -115
- package/dist/assets/{web-BsXkVdvj.js → web-B5Ma_-eu.js} +1 -1
- package/dist/assets/{web-BnBWe9ee.js → web-DojkIxYY.js} +1 -1
- package/dist/assets/{web-BahzYw5F.js → web-Dy1328BB.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/src/packages/server/routes/files.js +297 -92
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cO as a}from"./main-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
286
|
-
* 7. area-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
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) {
|