@jetcrab/pi-desk 1.1.16 → 1.1.20-dev

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 (106) hide show
  1. package/docs/plugins.md +3 -1
  2. package/next.config.ts +1 -0
  3. package/package.json +4 -3
  4. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +1 -1
  5. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-reload.ts +42 -0
  6. package/src/client/l2_biz/workbench/l2-work-session-sidebar.tsx +19 -1
  7. package/src/client/l2_biz/workbench/l2-workbench-chat.ts +45 -0
  8. package/src/client/l2_biz/workbench/l2-workbench.tsx +4 -0
  9. package/src/client/l3_modules/conversation/l3-conversation-markdown.module.css +1 -0
  10. package/src/client/l3_modules/conversation/l3-conversation-markdown.tsx +10 -1
  11. package/src/client/l3_modules/project-files/l3-markdown-preview.tsx +10 -1
  12. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +2 -0
  13. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +171 -229
  14. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +133 -70
  15. package/src/client/l3_modules/project-files/l3-project-reader.ts +4 -3
  16. package/src/client/l3_modules/project-files/views/l3-project-search-controls.tsx +201 -0
  17. package/src/client/l3_modules/project-files/views/l3-project-search-results.tsx +333 -0
  18. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +47 -3
  19. package/src/client/l4_foundation/ui/markdown/l4-markdown-remark.ts +89 -0
  20. package/src/client/l4_foundation/ui/markdown/l4-markdown.module.css +58 -0
  21. package/src/client/l4_foundation/ui/markdown/l4-markdown.tsx +108 -0
  22. package/src/common/l2_biz/work-session/l2-work-session-file-contract.ts +4 -3
  23. package/src/common/l2_biz/workbench/l2-workbench-locale-messages.ts +6 -0
  24. package/src/common/l3_modules/project-files/l3-project-files-contract.ts +2 -3
  25. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +56 -10
  26. package/src/common/l4_foundation/file/l4-project-file-contract.ts +51 -1
  27. package/src/common/l4_foundation/locale/l4-host-error-messages.ts +11 -0
  28. package/src/common/l4_foundation/locale/l4-locale-messages.ts +16 -0
  29. package/src/instrumentation.ts +0 -2
  30. package/src/server/l1_entry/api/l1-project-read-routes.ts +6 -4
  31. package/src/server/l1_entry/api/l1-project-route.ts +9 -8
  32. package/src/server/l1_entry/api/l1-work-session-file-error.ts +23 -0
  33. package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +0 -3
  34. package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +30 -58
  35. package/src/server/l2_biz/work-session/l2-work-session-manage.ts +0 -4
  36. package/src/server/l4_foundation/file/l4-project-file-index.ts +276 -0
  37. package/src/server/l4_foundation/file/l4-project-search.ts +399 -0
  38. package/src/server/l4_foundation/file/l4-ripgrep-search.ts +192 -0
  39. package/src/server/l4_foundation/file/l4-work-session-project-file.ts +8 -332
  40. package/src/server/l4_foundation/pi/l4-pi-package-maintenance.ts +0 -6
  41. package/src/server/l4_foundation/pi/l4-pi-plugin-capability-probe.ts +1 -3
  42. package/src/server/l4_foundation/pi/l4-pi-plugin-probe.ts +1 -3
  43. package/src/server/l4_foundation/pi/l4-pi-runner-process.ts +3 -15
  44. package/src/server/l4_foundation/process/l4-process-tree.js +31 -5
  45. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  46. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  47. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  48. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  49. package/temp/build/pi-desk/release/.next/required-server-files.js +4 -3
  50. package/temp/build/pi-desk/release/.next/required-server-files.json +4 -3
  51. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  52. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  53. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  54. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  57. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  58. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  60. package/temp/build/pi-desk/release/.next/server/app/api/project-files/get/route.js +2 -1
  61. package/temp/build/pi-desk/release/.next/server/app/api/project-files/list/route.js +2 -1
  62. package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
  63. package/temp/build/pi-desk/release/.next/server/app/api/project-files/search/route.js +2 -1
  64. package/temp/build/pi-desk/release/.next/server/app/api/project-git/get/route.js +2 -1
  65. package/temp/build/pi-desk/release/.next/server/app/api/project-git-branches/list/route.js +2 -1
  66. package/temp/build/pi-desk/release/.next/server/app/api/project-git-changes/list/route.js +2 -1
  67. package/temp/build/pi-desk/release/.next/server/app/api/project-git-diff/get/route.js +2 -1
  68. package/temp/build/pi-desk/release/.next/server/app/api/project-git-heads/get/route.js +2 -1
  69. package/temp/build/pi-desk/release/.next/server/app/api/project-git-log/list/route.js +2 -1
  70. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  71. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  72. package/temp/build/pi-desk/release/.next/server/app/page.js +77 -77
  73. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  74. package/temp/build/pi-desk/release/.next/server/chunks/{33658.js → 10881.js} +8 -8
  75. package/temp/build/pi-desk/release/.next/server/chunks/23054.js +1 -1
  76. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +3 -3
  77. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +5 -5
  78. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +2 -4
  79. package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
  80. package/temp/build/pi-desk/release/.next/server/chunks/70780.js +1 -0
  81. package/temp/build/pi-desk/release/.next/server/chunks/74528.js +1 -1
  82. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +1 -1
  83. package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
  84. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  85. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  86. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  87. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  88. package/temp/build/pi-desk/release/.next/static/chunks/27611-0890482fe76bd9d6.js +238 -0
  89. package/temp/build/pi-desk/release/.next/static/chunks/{31087-ba426d09d6f68bfd.js → 31087-d8e5765d541d5c26.js} +1 -1
  90. package/temp/build/pi-desk/release/.next/static/chunks/{49516-cc4dcd84d64ab4fe.js → 51474-8dc714cb76b2e60a.js} +2 -2
  91. package/temp/build/pi-desk/release/.next/static/chunks/77592-5bef6acb8e7c4b96.js +13 -0
  92. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-a50c456648537afb.js +3 -0
  93. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-0069319a4fb97211.js → page-0789c43551b29dd2.js} +4 -4
  94. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-40a782fc8a3535b9.js → webpack-d7248206b7fc7ba8.js} +1 -1
  95. package/temp/build/pi-desk/release/.next/static/css/250a6b93cc2222d1.css +1 -0
  96. package/temp/build/pi-desk/release/.next/static/css/6dbae2922f88ca31.css +1 -0
  97. package/temp/build/pi-desk/release/.next/static/css/a4cf28fd76c70165.css +1 -0
  98. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  99. package/temp/build/pi-desk/release/.next/server/chunks/8584.js +0 -2
  100. package/temp/build/pi-desk/release/.next/static/chunks/39654-b5fedd762ea0312e.js +0 -238
  101. package/temp/build/pi-desk/release/.next/static/chunks/75130-d2ca74894aff8085.js +0 -13
  102. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-c0e1c5cef8620cf8.js +0 -3
  103. package/temp/build/pi-desk/release/.next/static/css/6427d02b6edf406f.css +0 -1
  104. package/temp/build/pi-desk/release/.next/static/css/949d0d1a99d5de92.css +0 -1
  105. /package/temp/build/pi-desk/release/.next/static/{DqamF28k0ShQLbNVzXDIj → RLKcK-RQF5FvEeDmS9uih}/_buildManifest.js +0 -0
  106. /package/temp/build/pi-desk/release/.next/static/{DqamF28k0ShQLbNVzXDIj → RLKcK-RQF5FvEeDmS9uih}/_ssgManifest.js +0 -0
@@ -108,7 +108,6 @@ interface L2WorkSessionChatRecord {
108
108
  source: L2ChatSource
109
109
  state: L2ChatSourceState
110
110
  sourceEventVersion: number
111
- forceFullSync: boolean
112
111
  rawTemporaryMessages: Map<string, L4PiChatMessage>
113
112
  metadata: L2WorkSessionChatMetadata
114
113
  mainRunning: boolean
@@ -302,20 +301,6 @@ export class L2WorkSessionChatRuntime {
302
301
  return isCurrent() ? { cwd, isCurrent } : null
303
302
  }
304
303
 
305
- refreshPluginMessages(): void {
306
- for (const record of this.recordsByWorkId.values()) {
307
- if (record.state.runtime.presentationMode === 'basic') continue
308
- try {
309
- this.reprojectRecord(record, record.state.runtime.presentationMode)
310
- } catch (error) {
311
- console.error('[Pi Desk][WorkSessionChatRuntime] 插件消息刷新失败,保留已有聊天', {
312
- workId: record.source.workId,
313
- message: error instanceof Error ? error.message : String(error)
314
- })
315
- }
316
- }
317
- }
318
-
319
304
  setPresentation(source: L2ChatSource, mode: L2ChatMode): Promise<void> {
320
305
  const record = this.currentRecord(source)
321
306
  return this.enqueueCommand(record, async () => {
@@ -363,7 +348,6 @@ export class L2WorkSessionChatRuntime {
363
348
  temporaryMessages,
364
349
  runtime: { ...record.state.runtime, presentationMode: mode }
365
350
  }
366
- record.forceFullSync = true
367
351
  record.sourceEventVersion += 1
368
352
  this.publish({
369
353
  type: 'source_event',
@@ -380,16 +364,14 @@ export class L2WorkSessionChatRuntime {
380
364
 
381
365
  private async syncRecordRuntime(record: L2WorkSessionChatRecord): Promise<void> {
382
366
  const source = record.source
383
- const runtime = L2ChatRuntimeSchema.parse({
384
- ...(await getL4PiWorkSessionRuntime(source.sessionId).readChatRuntime()),
367
+ const runtime = await getL4PiWorkSessionRuntime(source.sessionId).readChatRuntime()
368
+ const nextRuntime = L2ChatRuntimeSchema.parse({
369
+ ...runtime,
385
370
  presentationMode: record.state.runtime.presentationMode
386
371
  })
387
372
  if (!this.isCurrentSource(source)) return
388
- if (!sameRuntime(record.state.runtime, runtime))
389
- this.applySourceEvent(source, { type: 'runtime_update', runtime })
390
- if (runtime.extensionMode === 'basic' && record.state.runtime.presentationMode !== 'basic') {
391
- this.reprojectRecord(record, 'basic')
392
- }
373
+ if (!sameRuntime(record.state.runtime, nextRuntime))
374
+ this.applySourceEvent(source, { type: 'runtime_update', runtime: nextRuntime })
393
375
  }
394
376
 
395
377
  createSync(sourceInput: L2ChatSource, cursor: L2ChatCursor): L2PreparedChatSourceSync {
@@ -413,24 +395,23 @@ export class L2WorkSessionChatRuntime {
413
395
  })
414
396
  }
415
397
 
416
- const event: L2ChatSessionSyncEvent =
417
- cursor !== null && cursorMatches && !record.forceFullSync
418
- ? {
419
- type: 'session_sync',
420
- mode: 'incremental',
421
- baseCursor: cursor,
422
- messages: messages.slice(cursor.index + 1).map(withoutL3ConversationMessageDetail),
423
- temporaryMessages,
424
- runtime
425
- }
426
- : {
427
- type: 'session_sync',
428
- mode: 'full',
429
- baseCursor: null,
430
- messages: messages.map(withoutL3ConversationMessageDetail),
431
- temporaryMessages,
432
- runtime
433
- }
398
+ const event: L2ChatSessionSyncEvent = cursorMatches
399
+ ? {
400
+ type: 'session_sync',
401
+ mode: 'incremental',
402
+ baseCursor: cursor,
403
+ messages: messages.slice(cursor.index + 1).map(withoutL3ConversationMessageDetail),
404
+ temporaryMessages,
405
+ runtime
406
+ }
407
+ : {
408
+ type: 'session_sync',
409
+ mode: 'full',
410
+ baseCursor: null,
411
+ messages: messages.map(withoutL3ConversationMessageDetail),
412
+ temporaryMessages,
413
+ runtime
414
+ }
434
415
 
435
416
  return { event, watermark: record.sourceEventVersion }
436
417
  }
@@ -548,12 +529,6 @@ export class L2WorkSessionChatRuntime {
548
529
  } finally {
549
530
  if (this.recordsByWorkId.get(source.workId) === record) {
550
531
  await this.syncRecordRuntime(record)
551
- if (
552
- record.state.runtime.extensionMode === 'basic' &&
553
- record.state.runtime.presentationMode !== 'basic'
554
- ) {
555
- this.reprojectRecord(record, 'basic')
556
- }
557
532
  }
558
533
  }
559
534
  })
@@ -685,7 +660,6 @@ export class L2WorkSessionChatRuntime {
685
660
  source,
686
661
  state,
687
662
  sourceEventVersion: 0,
688
- forceFullSync: isL4PiDeskSafeMode(),
689
663
  rawTemporaryMessages: new Map(),
690
664
  metadata: {
691
665
  sessionTitle: snapshot.sessionName ?? firstUserTitle,
@@ -714,6 +688,9 @@ export class L2WorkSessionChatRuntime {
714
688
  if (this.recordsByWorkId.get(record.source.workId) !== record) return
715
689
  const previousMetadata = record.metadata
716
690
  const previousStatus = this.statusForRecord(record)
691
+ const basic =
692
+ record.state.runtime.extensionMode === 'basic' ||
693
+ record.state.runtime.presentationMode === 'basic'
717
694
 
718
695
  switch (event.type) {
719
696
  case 'message_start':
@@ -726,7 +703,7 @@ export class L2WorkSessionChatRuntime {
726
703
  record.source,
727
704
  false,
728
705
  'temporary',
729
- record.state.runtime.presentationMode === 'basic'
706
+ basic
730
707
  )
731
708
  })
732
709
  return
@@ -744,7 +721,7 @@ export class L2WorkSessionChatRuntime {
744
721
  record.source,
745
722
  false,
746
723
  'update',
747
- record.state.runtime.presentationMode === 'basic'
724
+ basic
748
725
  )
749
726
  )
750
727
  if (update) this.applySourceEvent(record.source, update)
@@ -761,7 +738,7 @@ export class L2WorkSessionChatRuntime {
761
738
  record.source,
762
739
  false,
763
740
  'final',
764
- record.state.runtime.presentationMode === 'basic'
741
+ basic
765
742
  )
766
743
  const update = buildL3ConversationMessageUpdate(current, publicFinal)
767
744
  if (update) this.applySourceEvent(record.source, update)
@@ -801,12 +778,6 @@ export class L2WorkSessionChatRuntime {
801
778
  if (!sameRuntime(record.state.runtime, runtime)) {
802
779
  this.applySourceEvent(record.source, { type: 'runtime_update', runtime })
803
780
  }
804
- if (
805
- runtime.extensionMode === 'basic' &&
806
- record.state.runtime.presentationMode !== 'basic'
807
- ) {
808
- this.reprojectRecord(record, 'basic')
809
- }
810
781
  return
811
782
  }
812
783
  case 'agent_started':
@@ -861,7 +832,8 @@ export class L2WorkSessionChatRuntime {
861
832
  record.source,
862
833
  true,
863
834
  'final',
864
- record.state.runtime.presentationMode === 'basic'
835
+ record.state.runtime.extensionMode === 'basic' ||
836
+ record.state.runtime.presentationMode === 'basic'
865
837
  )
866
838
  record.state = { ...record.state, temporaryMessages }
867
839
  }
@@ -250,10 +250,6 @@ export class WorkSessionManage {
250
250
  })
251
251
  }
252
252
 
253
- refreshPluginMessages(): void {
254
- this.chat.refreshPluginMessages()
255
- }
256
-
257
253
  runPluginRestart<T>(operation: () => Promise<T>): Promise<T> {
258
254
  return this.runExclusive(async () => {
259
255
  await this.ensureHydrated()
@@ -0,0 +1,276 @@
1
+ import 'server-only'
2
+
3
+ import { execFile } from 'node:child_process'
4
+ import { readdir, realpath } from 'node:fs/promises'
5
+ import { basename, relative, resolve, sep } from 'node:path'
6
+ import { promisify } from 'node:util'
7
+ import { listL4WorkSessionGitRepositoryRoots } from '@server/l4_foundation/git/l4-work-session-git'
8
+ import { getL4FileSystemKey as fileSystemKey } from './l4-file-path'
9
+
10
+ const execFileAsync = promisify(execFile)
11
+ const SEARCH_CACHE_TTL_MS = 30_000
12
+ const SEARCH_CACHE_MAX_CWDS = 20
13
+ const SEARCH_INDEX_MAX_FILES = 50_000
14
+
15
+ export const IGNORED_NAMES = new Set([
16
+ '.git',
17
+ 'node_modules',
18
+ '.next',
19
+ 'dist',
20
+ 'build',
21
+ 'coverage',
22
+ '.turbo'
23
+ ])
24
+
25
+ interface SearchIndexCacheEntry {
26
+ files: string[]
27
+ truncated: boolean
28
+ expiresAt: number
29
+ }
30
+
31
+ const searchIndexCache = new Map<string, SearchIndexCacheEntry>()
32
+ const pendingSearchIndexes = new Map<string, Promise<SearchIndexCacheEntry>>()
33
+
34
+ function joinProtocolPath(parent: string, name: string): string {
35
+ return parent ? `${parent}/${name}` : name
36
+ }
37
+
38
+ export function isSupportedProtocolName(name: string): boolean {
39
+ return name.length > 0 && !name.includes('/') && !name.includes('\\') && !name.includes('\0')
40
+ }
41
+
42
+ function pathContainsIgnoredSegment(path: string): boolean {
43
+ return path.split('/').some((segment) => IGNORED_NAMES.has(segment))
44
+ }
45
+
46
+ export function invalidateSearchIndex(root: string): void {
47
+ const key = fileSystemKey(root)
48
+ searchIndexCache.delete(key)
49
+ pendingSearchIndexes.delete(key)
50
+ }
51
+
52
+ const GIT_SEARCH_OPTIONS = {
53
+ timeout: 10_000,
54
+ maxBuffer: 64 * 1024 * 1024,
55
+ env: { ...process.env, LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' }
56
+ }
57
+
58
+ async function listFilesWithContainingGit(
59
+ root: string
60
+ ): Promise<{ files: string[]; truncated: boolean } | null> {
61
+ try {
62
+ const { stdout: topLevelOutput } = await execFileAsync(
63
+ 'git',
64
+ ['-C', root, 'rev-parse', '--show-toplevel'],
65
+ GIT_SEARCH_OPTIONS
66
+ )
67
+ const topLevel = await realpath(topLevelOutput.trim())
68
+ const prefix = relative(topLevel, root).split(sep).join('/')
69
+ const { stdout } = await execFileAsync(
70
+ 'git',
71
+ [
72
+ '-C',
73
+ topLevel,
74
+ 'ls-files',
75
+ '--full-name',
76
+ '--cached',
77
+ '--others',
78
+ '--exclude-standard',
79
+ '-z',
80
+ '--',
81
+ prefix || '.'
82
+ ],
83
+ GIT_SEARCH_OPTIONS
84
+ )
85
+ const prefixWithSlash = prefix ? `${prefix}/` : ''
86
+ const all = stdout
87
+ .split('\0')
88
+ .flatMap((path) => {
89
+ if (!path) return []
90
+ const projectPath = prefixWithSlash
91
+ ? path.startsWith(prefixWithSlash)
92
+ ? path.slice(prefixWithSlash.length)
93
+ : ''
94
+ : path
95
+ return projectPath ? [projectPath] : []
96
+ })
97
+ .filter(
98
+ (path) =>
99
+ isSupportedProtocolName(basename(path)) &&
100
+ !path.includes('\\') &&
101
+ !pathContainsIgnoredSegment(path)
102
+ )
103
+ return {
104
+ files: all.slice(0, SEARCH_INDEX_MAX_FILES),
105
+ truncated: all.length > SEARCH_INDEX_MAX_FILES
106
+ }
107
+ } catch {
108
+ return null
109
+ }
110
+ }
111
+
112
+ function isPathInsideRepository(path: string, repositoryRoot: string): boolean {
113
+ return path === repositoryRoot || path.startsWith(`${repositoryRoot}/`)
114
+ }
115
+
116
+ async function listFilesInRepository(
117
+ projectRoot: string,
118
+ repositoryRoot: string,
119
+ nestedRepositoryRoots: readonly string[]
120
+ ): Promise<{ files: string[]; truncated: boolean } | null> {
121
+ const absoluteRoot = repositoryRoot
122
+ ? resolve(projectRoot, ...repositoryRoot.split('/'))
123
+ : projectRoot
124
+ try {
125
+ const { stdout } = await execFileAsync(
126
+ 'git',
127
+ [
128
+ '-C',
129
+ absoluteRoot,
130
+ 'ls-files',
131
+ '--full-name',
132
+ '--cached',
133
+ '--others',
134
+ '--exclude-standard',
135
+ '-z'
136
+ ],
137
+ GIT_SEARCH_OPTIONS
138
+ )
139
+ const files = stdout.split('\0').flatMap((path) => {
140
+ if (!path || path.includes('\\') || pathContainsIgnoredSegment(path)) return []
141
+ const projectPath = joinProtocolPath(repositoryRoot, path)
142
+ if (
143
+ nestedRepositoryRoots.some((nestedRoot) => isPathInsideRepository(projectPath, nestedRoot))
144
+ ) {
145
+ return []
146
+ }
147
+ return isSupportedProtocolName(basename(projectPath)) ? [projectPath] : []
148
+ })
149
+ return {
150
+ files: files.slice(0, SEARCH_INDEX_MAX_FILES),
151
+ truncated: files.length > SEARCH_INDEX_MAX_FILES
152
+ }
153
+ } catch {
154
+ return null
155
+ }
156
+ }
157
+
158
+ async function listFilesWithWalk(
159
+ root: string,
160
+ excludedRepositoryRoots: readonly string[] = []
161
+ ): Promise<{ files: string[]; truncated: boolean }> {
162
+ const files: string[] = []
163
+ const queue: Array<{ absolute: string; relative: string }> = [{ absolute: root, relative: '' }]
164
+ let queueIndex = 0
165
+
166
+ while (queueIndex < queue.length) {
167
+ const current = queue[queueIndex]!
168
+ queueIndex += 1
169
+ let dirents
170
+ try {
171
+ dirents = await readdir(current.absolute, { withFileTypes: true })
172
+ } catch {
173
+ continue
174
+ }
175
+
176
+ for (const entry of dirents) {
177
+ if (IGNORED_NAMES.has(entry.name) || !isSupportedProtocolName(entry.name)) continue
178
+ const childRelative = joinProtocolPath(current.relative, entry.name)
179
+ if (entry.isDirectory()) {
180
+ if (excludedRepositoryRoots.includes(childRelative)) continue
181
+ queue.push({ absolute: resolve(current.absolute, entry.name), relative: childRelative })
182
+ continue
183
+ }
184
+ if (!entry.isFile()) continue
185
+ files.push(childRelative)
186
+ if (files.length >= SEARCH_INDEX_MAX_FILES) return { files, truncated: true }
187
+ }
188
+ }
189
+
190
+ return { files, truncated: false }
191
+ }
192
+
193
+ async function listFilesWithRepositoryRoots(
194
+ projectRoot: string,
195
+ repositoryRoots: readonly string[]
196
+ ): Promise<{ files: string[]; truncated: boolean }> {
197
+ const files = new Set<string>()
198
+ let truncated = false
199
+ const hasProjectRootRepository = repositoryRoots.includes('')
200
+
201
+ if (!hasProjectRootRepository) {
202
+ const workspaceFiles = await listFilesWithWalk(projectRoot, repositoryRoots.filter(Boolean))
203
+ for (const path of workspaceFiles.files) files.add(path)
204
+ truncated ||= workspaceFiles.truncated
205
+ }
206
+
207
+ for (const repositoryRoot of repositoryRoots) {
208
+ const nestedRoots = repositoryRoots.filter(
209
+ (candidate) =>
210
+ candidate !== repositoryRoot &&
211
+ (repositoryRoot === '' || candidate.startsWith(`${repositoryRoot}/`))
212
+ )
213
+ let listing = await listFilesInRepository(projectRoot, repositoryRoot, nestedRoots)
214
+ if (!listing) {
215
+ const absoluteRoot = repositoryRoot
216
+ ? resolve(projectRoot, ...repositoryRoot.split('/'))
217
+ : projectRoot
218
+ const nestedRelativeRoots = nestedRoots.map((root) =>
219
+ repositoryRoot ? root.slice(repositoryRoot.length + 1) : root
220
+ )
221
+ const fallback = await listFilesWithWalk(absoluteRoot, nestedRelativeRoots)
222
+ listing = {
223
+ files: fallback.files.map((path) => joinProtocolPath(repositoryRoot, path)),
224
+ truncated: fallback.truncated
225
+ }
226
+ }
227
+ for (const path of listing.files) {
228
+ files.add(path)
229
+ if (files.size >= SEARCH_INDEX_MAX_FILES) {
230
+ return { files: [...files], truncated: true }
231
+ }
232
+ }
233
+ truncated ||= listing.truncated
234
+ }
235
+
236
+ return { files: [...files], truncated }
237
+ }
238
+
239
+ export async function getSearchIndex(root: string): Promise<SearchIndexCacheEntry> {
240
+ const key = fileSystemKey(root)
241
+ const now = Date.now()
242
+ const cached = searchIndexCache.get(key)
243
+ if (cached && cached.expiresAt > now) return cached
244
+
245
+ const active = pendingSearchIndexes.get(key)
246
+ if (active) return active
247
+
248
+ const load: Promise<SearchIndexCacheEntry> = Promise.resolve().then(async () => {
249
+ const repositoryRoots = await listL4WorkSessionGitRepositoryRoots(root)
250
+ const listing =
251
+ repositoryRoots.length > 0
252
+ ? await listFilesWithRepositoryRoots(root, repositoryRoots)
253
+ : ((await listFilesWithContainingGit(root)) ?? (await listFilesWithWalk(root)))
254
+ const next: SearchIndexCacheEntry = {
255
+ ...listing,
256
+ expiresAt: now + SEARCH_CACHE_TTL_MS
257
+ }
258
+ if (pendingSearchIndexes.get(key) === load) {
259
+ for (const [cacheKey, entry] of searchIndexCache) {
260
+ if (entry.expiresAt <= Date.now()) searchIndexCache.delete(cacheKey)
261
+ }
262
+ while (searchIndexCache.size >= SEARCH_CACHE_MAX_CWDS) {
263
+ const oldestKey = searchIndexCache.keys().next().value as string
264
+ searchIndexCache.delete(oldestKey)
265
+ }
266
+ searchIndexCache.set(key, next)
267
+ }
268
+ return next
269
+ })
270
+ pendingSearchIndexes.set(key, load)
271
+ try {
272
+ return await load
273
+ } finally {
274
+ if (pendingSearchIndexes.get(key) === load) pendingSearchIndexes.delete(key)
275
+ }
276
+ }