@jetcrab/pi-desk 1.1.18-dev → 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 (105) hide show
  1. package/docs/plugins.md +1 -1
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  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.tsx +10 -1
  10. package/src/client/l3_modules/project-files/l3-markdown-preview.tsx +10 -1
  11. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +2 -0
  12. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +171 -229
  13. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +133 -70
  14. package/src/client/l3_modules/project-files/l3-project-reader.ts +4 -3
  15. package/src/client/l3_modules/project-files/views/l3-project-search-controls.tsx +201 -0
  16. package/src/client/l3_modules/project-files/views/l3-project-search-results.tsx +333 -0
  17. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +47 -3
  18. package/src/client/l4_foundation/ui/markdown/l4-markdown-remark.ts +89 -0
  19. package/src/client/l4_foundation/ui/markdown/l4-markdown.module.css +58 -0
  20. package/src/client/l4_foundation/ui/markdown/l4-markdown.tsx +108 -0
  21. package/src/common/l2_biz/work-session/l2-work-session-file-contract.ts +4 -3
  22. package/src/common/l2_biz/workbench/l2-workbench-locale-messages.ts +6 -0
  23. package/src/common/l3_modules/project-files/l3-project-files-contract.ts +2 -3
  24. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +56 -10
  25. package/src/common/l4_foundation/file/l4-project-file-contract.ts +51 -1
  26. package/src/common/l4_foundation/locale/l4-host-error-messages.ts +11 -0
  27. package/src/common/l4_foundation/locale/l4-locale-messages.ts +16 -0
  28. package/src/instrumentation.ts +0 -2
  29. package/src/server/l1_entry/api/l1-project-read-routes.ts +6 -4
  30. package/src/server/l1_entry/api/l1-project-route.ts +9 -8
  31. package/src/server/l1_entry/api/l1-work-session-file-error.ts +23 -0
  32. package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +0 -3
  33. package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +30 -58
  34. package/src/server/l2_biz/work-session/l2-work-session-manage.ts +0 -4
  35. package/src/server/l4_foundation/file/l4-project-file-index.ts +276 -0
  36. package/src/server/l4_foundation/file/l4-project-search.ts +399 -0
  37. package/src/server/l4_foundation/file/l4-ripgrep-search.ts +192 -0
  38. package/src/server/l4_foundation/file/l4-work-session-project-file.ts +8 -332
  39. package/src/server/l4_foundation/pi/l4-pi-package-maintenance.ts +0 -6
  40. package/src/server/l4_foundation/pi/l4-pi-plugin-capability-probe.ts +1 -3
  41. package/src/server/l4_foundation/pi/l4-pi-plugin-probe.ts +1 -3
  42. package/src/server/l4_foundation/pi/l4-pi-runner-process.ts +2 -2
  43. package/src/server/l4_foundation/process/l4-process-tree.js +23 -4
  44. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  45. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  46. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  47. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  48. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -2
  49. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -2
  50. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  51. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  52. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  53. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  54. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  55. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  56. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  57. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  58. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/app/api/project-files/get/route.js +2 -1
  60. package/temp/build/pi-desk/release/.next/server/app/api/project-files/list/route.js +2 -1
  61. package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
  62. package/temp/build/pi-desk/release/.next/server/app/api/project-files/search/route.js +2 -1
  63. package/temp/build/pi-desk/release/.next/server/app/api/project-git/get/route.js +2 -1
  64. package/temp/build/pi-desk/release/.next/server/app/api/project-git-branches/list/route.js +2 -1
  65. package/temp/build/pi-desk/release/.next/server/app/api/project-git-changes/list/route.js +2 -1
  66. package/temp/build/pi-desk/release/.next/server/app/api/project-git-diff/get/route.js +2 -1
  67. package/temp/build/pi-desk/release/.next/server/app/api/project-git-heads/get/route.js +2 -1
  68. package/temp/build/pi-desk/release/.next/server/app/api/project-git-log/list/route.js +2 -1
  69. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  70. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  71. package/temp/build/pi-desk/release/.next/server/app/page.js +77 -77
  72. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/temp/build/pi-desk/release/.next/server/chunks/{33658.js → 10881.js} +8 -8
  74. package/temp/build/pi-desk/release/.next/server/chunks/23054.js +1 -1
  75. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +3 -3
  76. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +5 -5
  77. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +2 -4
  78. package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
  79. package/temp/build/pi-desk/release/.next/server/chunks/70780.js +1 -0
  80. package/temp/build/pi-desk/release/.next/server/chunks/74528.js +1 -1
  81. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +1 -1
  82. package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
  83. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  84. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  85. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  86. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  87. package/temp/build/pi-desk/release/.next/static/chunks/27611-0890482fe76bd9d6.js +238 -0
  88. package/temp/build/pi-desk/release/.next/static/chunks/{31087-ba426d09d6f68bfd.js → 31087-d8e5765d541d5c26.js} +1 -1
  89. package/temp/build/pi-desk/release/.next/static/chunks/{49516-cc4dcd84d64ab4fe.js → 51474-8dc714cb76b2e60a.js} +2 -2
  90. package/temp/build/pi-desk/release/.next/static/chunks/77592-5bef6acb8e7c4b96.js +13 -0
  91. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-a50c456648537afb.js +3 -0
  92. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-a984139e99586041.js → page-0789c43551b29dd2.js} +4 -4
  93. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-40a782fc8a3535b9.js → webpack-d7248206b7fc7ba8.js} +1 -1
  94. package/temp/build/pi-desk/release/.next/static/css/250a6b93cc2222d1.css +1 -0
  95. package/temp/build/pi-desk/release/.next/static/css/6dbae2922f88ca31.css +1 -0
  96. package/temp/build/pi-desk/release/.next/static/css/a4cf28fd76c70165.css +1 -0
  97. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  98. package/temp/build/pi-desk/release/.next/server/chunks/8584.js +0 -2
  99. package/temp/build/pi-desk/release/.next/static/chunks/39654-b5fedd762ea0312e.js +0 -238
  100. package/temp/build/pi-desk/release/.next/static/chunks/75130-d2ca74894aff8085.js +0 -13
  101. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-c0e1c5cef8620cf8.js +0 -3
  102. package/temp/build/pi-desk/release/.next/static/css/6427d02b6edf406f.css +0 -1
  103. package/temp/build/pi-desk/release/.next/static/css/79e06f05df9edff0.css +0 -1
  104. /package/temp/build/pi-desk/release/.next/static/{n2LrXQgq8UkbH_AzhR3fw → RLKcK-RQF5FvEeDmS9uih}/_buildManifest.js +0 -0
  105. /package/temp/build/pi-desk/release/.next/static/{n2LrXQgq8UkbH_AzhR3fw → RLKcK-RQF5FvEeDmS9uih}/_ssgManifest.js +0 -0
@@ -1,13 +1,18 @@
1
1
  import type {
2
2
  L4ProjectFileEntry,
3
3
  L4ProjectFileImageMimeType,
4
- L4ProjectFileImagePreviewMode
4
+ L4ProjectFileImagePreviewMode,
5
+ L4ProjectFileSearch,
6
+ L4ProjectSearchInput
5
7
  } from '@common/l4_foundation/file/l4-project-file-contract'
6
8
  import type { L4GitHistoricalDiff } from '@common/l4_foundation/git/l4-git-history-contract'
7
9
  import { L3_PROJECT_FILES_LOCALE_MESSAGES } from '@common/l3_modules/project-files/l3-project-files-locale-messages'
8
10
  import { l4LocalizedErrorMessage } from '@client/l4_foundation/locale/l4-localized-error'
9
11
  import { createL4Base64ImageBlob } from '@client/l4_foundation/media/l4-base64-image'
10
- import type { L4CodePreviewState } from '@client/l4_foundation/ui/code/l4-code-preview'
12
+ import type {
13
+ L4CodePreviewState,
14
+ L4CodeLineNavigation
15
+ } from '@client/l4_foundation/ui/code/l4-code-preview'
11
16
  import type { L4CodeDiffState } from '@client/l4_foundation/ui/code/l4-code-diff'
12
17
  import { saveL4CodePreferences } from '@client/l4_foundation/ui/code/l4-code-preferences'
13
18
  import { L3ProjectRequestError, type L3ProjectFileReader } from './l3-project-reader'
@@ -47,13 +52,26 @@ export type L3FileDocument =
47
52
  }
48
53
  | { status: 'image'; mimeType: L4ProjectFileImageMimeType; size: number; objectUrl: string }
49
54
  | { status: 'error'; error: string }
50
- export interface L3FileSearch {
51
- query: string
55
+ export interface L3FileSearch extends L4ProjectSearchInput, L4ProjectFileSearch {
52
56
  status: 'idle' | 'loading' | 'ready' | 'error'
53
- matches: string[]
54
- truncated: boolean
55
57
  error: string | null
56
58
  }
59
+ const DEFAULT_SEARCH_OPTIONS = {
60
+ scope: 'all',
61
+ caseSensitive: false,
62
+ wholeWord: false,
63
+ regex: false
64
+ } as const
65
+ function emptySearch(input: L4ProjectSearchInput): L3FileSearch {
66
+ return {
67
+ ...input,
68
+ status: input.query.trim() ? 'loading' : 'idle',
69
+ fileMatches: [],
70
+ contentMatches: [],
71
+ truncated: false,
72
+ error: null
73
+ }
74
+ }
57
75
  interface DiffOptions {
58
76
  repositoryRoot: string
59
77
  path: string
@@ -132,7 +150,7 @@ export class L3ProjectFileRuntime {
132
150
  directories: new Map(),
133
151
  expandedPaths: new Set(),
134
152
  treeScrollTop: 0,
135
- search: { query: '', status: 'idle', matches: [], truncated: false, error: null },
153
+ search: emptySearch({ query: '', ...DEFAULT_SEARCH_OPTIONS }),
136
154
  searchOpen: false,
137
155
  documents: new Map(),
138
156
  activeDiff: null
@@ -150,6 +168,7 @@ export class L3ProjectFileRuntime {
150
168
  private readonly restoredFileViewModes = new Map<string, 'source' | 'preview'>()
151
169
  private readonly restoredPreviewScrollTops = new Map<string, number>()
152
170
  private searchRequest: AbortController | null = null
171
+ private navigation: L4CodeLineNavigation | null = null
153
172
  private diffRequest: AbortController | null = null
154
173
  private revision = 0
155
174
  private disposed = false
@@ -165,8 +184,23 @@ export class L3ProjectFileRuntime {
165
184
  }
166
185
  getRevision = (): number => this.revision
167
186
  setSearchOpen(open: boolean): void {
168
- if (this.state.searchOpen === open) return
187
+ if (this.disposed || this.state.searchOpen === open) return
188
+ this.cancelSearch()
169
189
  this.state.searchOpen = open
190
+ this.state.search = emptySearch({
191
+ ...this.searchInput(),
192
+ scope: open ? 'all' : this.state.search.scope
193
+ })
194
+ this.publish()
195
+ }
196
+ get pendingNavigation(): L4CodeLineNavigation | null {
197
+ const path = this.state.activePath
198
+ return path && !this.documentRequests.has(path) ? this.navigation : null
199
+ }
200
+ consumeNavigation(target: L4CodeLineNavigation, exists: boolean): void {
201
+ if (this.navigation !== target) return
202
+ this.navigation = null
203
+ if (!exists) this.options.onNotice?.(projectText('searchLineUnavailable'))
170
204
  this.publish()
171
205
  }
172
206
  get canReveal(): boolean {
@@ -203,13 +237,7 @@ export class L3ProjectFileRuntime {
203
237
  input.activePath && input.tabs.includes(input.activePath) ? input.activePath : null
204
238
  this.state.expandedPaths = new Set(input.expandedPaths)
205
239
  this.state.treeScrollTop = input.treeScrollTop
206
- this.state.search = {
207
- query: input.searchQuery,
208
- status: input.searchQuery ? 'loading' : 'idle',
209
- matches: [],
210
- truncated: false,
211
- error: null
212
- }
240
+ this.state.search = emptySearch({ query: input.searchQuery, ...DEFAULT_SEARCH_OPTIONS })
213
241
  for (const path of input.tabs) this.state.documents.set(path, { status: 'unloaded' })
214
242
  for (const path of input.expandedPaths) void this.ensureDirectory(path)
215
243
  if (input.activeDiff) {
@@ -320,18 +348,12 @@ export class L3ProjectFileRuntime {
320
348
  this.directoryRequests.clear()
321
349
  this.state.directories.clear()
322
350
  this.cancelSearch()
323
- this.state.search = {
324
- query: this.state.search.query,
325
- status: this.state.search.query ? 'loading' : 'idle',
326
- matches: [],
327
- truncated: false,
328
- error: null
329
- }
351
+ this.state.search = emptySearch(this.searchInput())
330
352
  this.publish()
331
353
  void this.ensureRoot().then(() => {
332
354
  if (this.disposed) return
333
355
  for (const path of this.state.expandedPaths) void this.ensureDirectory(path)
334
- if (this.state.search.query) {
356
+ if (this.state.searchOpen && this.state.search.query.trim()) {
335
357
  this.cancelSearch()
336
358
  this.state.search = { ...this.state.search, status: 'loading' }
337
359
  void this.search(this.state.search.query)
@@ -339,17 +361,31 @@ export class L3ProjectFileRuntime {
339
361
  })
340
362
  }
341
363
  setSearchQuery(query: string): void {
342
- if (this.state.search.query === query) return
364
+ if (this.disposed || this.state.search.query === query) return
343
365
  this.cancelSearch()
344
- this.state.search = {
345
- query,
346
- status: query.trim() ? 'loading' : 'idle',
347
- matches: [],
348
- truncated: false,
349
- error: null
350
- }
366
+ this.state.search = emptySearch({ ...this.searchInput(), query })
351
367
  this.publish()
352
368
  }
369
+ setSearchOptions(options: Partial<Omit<L4ProjectSearchInput, 'query'>>): void {
370
+ if (this.disposed) return
371
+ const previous = this.searchInput()
372
+ const next = { ...previous, ...options }
373
+ if (
374
+ next.scope === previous.scope &&
375
+ next.caseSensitive === previous.caseSensitive &&
376
+ next.wholeWord === previous.wholeWord &&
377
+ next.regex === previous.regex
378
+ )
379
+ return
380
+ this.cancelSearch()
381
+ this.state.search = emptySearch(next)
382
+ this.publish()
383
+ if (this.state.searchOpen) void this.search(next.query)
384
+ }
385
+ private searchInput(): L4ProjectSearchInput {
386
+ const { query, scope, caseSensitive, wholeWord, regex } = this.state.search
387
+ return { query, scope, caseSensitive, wholeWord, regex }
388
+ }
353
389
  async search(query: string): Promise<void> {
354
390
  if (
355
391
  this.disposed ||
@@ -361,27 +397,22 @@ export class L3ProjectFileRuntime {
361
397
  return
362
398
  const request = new AbortController()
363
399
  this.searchRequest = request
364
- this.state.search = { query, status: 'loading', matches: [], truncated: false, error: null }
400
+ const input = this.searchInput()
401
+ this.state.search = emptySearch(input)
365
402
  this.publish()
366
403
  try {
367
- const result = await this.reader.search(query.trim(), request.signal)
404
+ const result = await this.reader.search(input, request.signal)
368
405
  if (this.searchRequest !== request) return
369
- this.state.search = { query, status: 'ready', ...result, error: null }
406
+ this.state.search = { ...input, status: 'ready', ...result, error: null }
370
407
  } catch (cause) {
371
408
  if (this.searchRequest !== request) return
372
- this.state.search = {
373
- query,
374
- status: 'error',
375
- matches: [],
376
- truncated: false,
377
- error: message(cause)
378
- }
409
+ this.state.search = { ...emptySearch(input), status: 'error', error: message(cause) }
379
410
  }
380
411
  if (this.searchRequest !== request) return
381
412
  this.searchRequest = null
382
413
  this.publish()
383
414
  }
384
- openFile(input: string): void {
415
+ openFile(input: string, line?: number): void {
385
416
  if (this.disposed) return
386
417
  const normalized = normalizePath(this.cwd, input)
387
418
  const windows = /^[a-z]:[\\/]/i.test(this.cwd) || this.cwd.startsWith('\\\\')
@@ -399,8 +430,11 @@ export class L3ProjectFileRuntime {
399
430
  this.clearDiff()
400
431
  if (!this.state.documents.has(path)) this.state.documents.set(path, { status: 'unloaded' })
401
432
  this.state.activePath = path
433
+ this.navigation = line === undefined ? null : { line }
434
+ const document = this.state.documents.get(path)
435
+ if (line !== undefined && document?.status === 'text') document.viewMode = 'source'
436
+ void this.ensureDocument(path, line !== undefined)
402
437
  this.publish()
403
- void this.ensureDocument(path)
404
438
  }
405
439
  closeTabs(target: string | null, scope: L3FileTabCloseScope): void {
406
440
  const state = this.state
@@ -439,6 +473,7 @@ export class L3ProjectFileRuntime {
439
473
  }
440
474
  state.tabs = state.tabs.filter((path) => !removed.has(path))
441
475
  if (state.activePath && removed.has(state.activePath)) {
476
+ this.navigation = null
442
477
  const activeIndex = tabs.indexOf(state.activePath)
443
478
  state.activePath =
444
479
  tabs.slice(activeIndex + 1).find((path) => path !== null && !removed.has(path)) ??
@@ -454,17 +489,23 @@ export class L3ProjectFileRuntime {
454
489
  }
455
490
  reloadFile(path: string): void {
456
491
  if (!this.state.tabs.includes(path)) return
492
+ if (this.state.activePath === path && this.navigation) {
493
+ // 面板显示时的自动重读与搜索定位共用刷新,不能清除待消费的目标行。
494
+ void this.ensureDocument(path, true)
495
+ return
496
+ }
457
497
  this.invalidateDocument(path)
458
498
  this.publish()
459
499
  void this.ensureDocument(path)
460
500
  }
461
- async ensureDocument(path: string): Promise<void> {
462
- if (this.disposed || !this.state.tabs.includes(path)) return
501
+ async ensureDocument(path: string, refresh = false): Promise<void> {
502
+ if (this.disposed || !this.state.tabs.includes(path) || this.documentRequests.has(path)) return
463
503
  const current = this.state.documents.get(path)
464
- if (current && ['loading', 'text', 'image'].includes(current.status)) return
504
+ if (!refresh && current && ['loading', 'text', 'image'].includes(current.status)) return
465
505
  const request = new AbortController()
466
506
  this.documentRequests.set(path, request)
467
- this.state.documents.set(path, { status: 'loading' })
507
+ // 搜索定位刷新正文时保留已挂载的代码视图,内容未变时不重建编辑器。
508
+ if (current?.status !== 'text') this.state.documents.set(path, { status: 'loading' })
468
509
  this.publish()
469
510
  try {
470
511
  const result = await this.reader.get(path, this.options.imagePreviewMode(), request.signal)
@@ -472,29 +513,47 @@ export class L3ProjectFileRuntime {
472
513
  this.documentRequests.delete(path)
473
514
  this.closeMissing.delete(path)
474
515
  this.restoredTabs.delete(path)
475
- this.state.documents.set(
476
- path,
477
- result.kind === 'text'
478
- ? {
479
- status: 'text',
480
- content: result.content,
481
- size: result.size,
482
- editorState: this.restoredEditorStates.get(path) ?? null,
483
- viewMode:
484
- this.restoredFileViewModes.get(path) ??
485
- (getL3ProjectTextPreviewKind(path) === 'markdown' ||
486
- (this.options.htmlPreview && getL3ProjectTextPreviewKind(path) === 'html')
487
- ? 'preview'
488
- : 'source'),
489
- previewScrollTop: this.restoredPreviewScrollTops.get(path) ?? 0
490
- }
491
- : {
492
- status: 'image',
493
- mimeType: result.mimeType,
494
- size: result.size,
495
- objectUrl: URL.createObjectURL(createL4Base64ImageBlob(result))
496
- }
497
- )
516
+ const unchanged =
517
+ current?.status === 'text' &&
518
+ result.kind === 'text' &&
519
+ current.content === result.content &&
520
+ current.size === result.size
521
+ if (!unchanged)
522
+ this.state.documents.set(
523
+ path,
524
+ result.kind === 'text'
525
+ ? {
526
+ status: 'text',
527
+ content: result.content,
528
+ size: result.size,
529
+ editorState:
530
+ current?.status === 'text'
531
+ ? current.editorState
532
+ : (this.restoredEditorStates.get(path) ?? null),
533
+ viewMode:
534
+ this.state.activePath === path && this.navigation
535
+ ? 'source'
536
+ : current?.status === 'text'
537
+ ? current.viewMode
538
+ : (this.restoredFileViewModes.get(path) ??
539
+ (getL3ProjectTextPreviewKind(path) === 'markdown' ||
540
+ (this.options.htmlPreview && getL3ProjectTextPreviewKind(path) === 'html')
541
+ ? 'preview'
542
+ : 'source')),
543
+ previewScrollTop:
544
+ current?.status === 'text'
545
+ ? current.previewScrollTop
546
+ : (this.restoredPreviewScrollTops.get(path) ?? 0)
547
+ }
548
+ : {
549
+ status: 'image',
550
+ mimeType: result.mimeType,
551
+ size: result.size,
552
+ objectUrl: URL.createObjectURL(createL4Base64ImageBlob(result))
553
+ }
554
+ )
555
+ if (this.state.activePath === path && result.kind !== 'text') this.navigation = null
556
+ this.releaseDocument(current)
498
557
  } catch (cause) {
499
558
  if (this.documentRequests.get(path) !== request) return
500
559
  this.documentRequests.delete(path)
@@ -516,6 +575,8 @@ export class L3ProjectFileRuntime {
516
575
  error: message(cause)
517
576
  })
518
577
  this.state.documents.set(path, { status: 'error', error: message(cause) })
578
+ this.releaseDocument(current)
579
+ if (this.state.activePath === path) this.navigation = null
519
580
  }
520
581
  this.publish()
521
582
  }
@@ -703,6 +764,7 @@ export class L3ProjectFileRuntime {
703
764
  this.publish()
704
765
  }
705
766
  private clearDiff(): void {
767
+ this.navigation = null
706
768
  this.diffRequest?.abort()
707
769
  this.diffRequest = null
708
770
  this.state.activeDiff = null
@@ -712,6 +774,7 @@ export class L3ProjectFileRuntime {
712
774
  this.searchRequest = null
713
775
  }
714
776
  private invalidateDocument(path: string): void {
777
+ if (this.state.activePath === path) this.navigation = null
715
778
  this.documentRequests.get(path)?.abort()
716
779
  this.documentRequests.delete(path)
717
780
  const document = this.state.documents.get(path)
@@ -5,6 +5,7 @@ import * as contract from '@common/l3_modules/project-files/l3-project-files-con
5
5
  import type {
6
6
  L4ProjectFileList,
7
7
  L4ProjectFileSearch,
8
+ L4ProjectSearchInput,
8
9
  L4ProjectFileContent,
9
10
  L4ProjectFileImagePreviewMode
10
11
  } from '@common/l4_foundation/file/l4-project-file-contract'
@@ -62,7 +63,7 @@ export async function requestL3Project<I, O>(
62
63
 
63
64
  export interface L3ProjectFileReader {
64
65
  list(path: string, signal?: AbortSignal): Promise<L4ProjectFileList>
65
- search(query: string, signal?: AbortSignal): Promise<L4ProjectFileSearch>
66
+ search(input: L4ProjectSearchInput, signal?: AbortSignal): Promise<L4ProjectFileSearch>
66
67
  get(
67
68
  path: string,
68
69
  imagePreviewMode: L4ProjectFileImagePreviewMode,
@@ -89,8 +90,8 @@ export function createL3ProjectReader(
89
90
  return {
90
91
  list: (path, signal) =>
91
92
  requestL3Project(clientId, contract.L3ProjectFileList, { ...context, path }, signal),
92
- search: (query, signal) =>
93
- requestL3Project(clientId, contract.L3ProjectFileSearch, { ...context, query }, signal),
93
+ search: (input, signal) =>
94
+ requestL3Project(clientId, contract.L3ProjectFileSearch, { ...context, ...input }, signal),
94
95
  get: (path, imagePreviewMode, signal) =>
95
96
  requestL3Project(
96
97
  clientId,
@@ -0,0 +1,201 @@
1
+ 'use client'
2
+
3
+ import { SearchIcon, XIcon } from 'lucide-react'
4
+ import { useEffect, useRef, useState, type Ref } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import type { L4ProjectSearchInput } from '@common/l4_foundation/file/l4-project-file-contract'
7
+ import { cn } from '@client/l4_foundation/lib/l4-utils'
8
+ import { Button } from '@client/l4_foundation/ui/shadcn/button'
9
+ import {
10
+ InputGroup,
11
+ InputGroupAddon,
12
+ InputGroupButton,
13
+ InputGroupInput
14
+ } from '@client/l4_foundation/ui/shadcn/input-group'
15
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@client/l4_foundation/ui/shadcn/tooltip'
16
+
17
+ type SearchOptions = Partial<Omit<L4ProjectSearchInput, 'query'>>
18
+
19
+ interface L3ProjectSearchControlsProps {
20
+ input: L4ProjectSearchInput
21
+ inputRef: Ref<HTMLInputElement>
22
+ invalid: boolean
23
+ onQueryChange: (query: string) => void
24
+ onOptionsChange: (options: SearchOptions) => void
25
+ onComposingChange: (composing: boolean) => void
26
+ onClose: () => void
27
+ onOpenFirst: () => void
28
+ onFocusResults: () => void
29
+ }
30
+
31
+ const SCOPES = ['all', 'files', 'content'] as const
32
+ const OPTIONS = [
33
+ { key: 'caseSensitive', label: 'searchCaseSensitive', text: 'Aa' },
34
+ { key: 'wholeWord', label: 'searchWholeWord', text: 'Ab' },
35
+ { key: 'regex', label: 'searchRegex', text: '.*' }
36
+ ] as const
37
+
38
+ export function L3ProjectSearchControls({
39
+ input,
40
+ inputRef,
41
+ invalid,
42
+ onQueryChange,
43
+ onOptionsChange,
44
+ onComposingChange,
45
+ onClose,
46
+ onOpenFirst,
47
+ onFocusResults
48
+ }: L3ProjectSearchControlsProps): React.JSX.Element {
49
+ const { t } = useTranslation('projectFiles')
50
+ const [composition, setComposition] = useState<string | null>(null)
51
+ const composing = useRef(false)
52
+ useEffect(() => () => onComposingChange(false), [onComposingChange])
53
+ const placeholder = t(
54
+ input.scope === 'files'
55
+ ? 'searchFiles'
56
+ : input.scope === 'content'
57
+ ? 'searchContent'
58
+ : 'searchAll'
59
+ )
60
+
61
+ return (
62
+ <div className="shrink-0 border-b px-2.5 py-2">
63
+ <div className="rounded-lg border border-input bg-card p-1 focus-within:border-ring focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-ring">
64
+ <div className="flex min-h-7 items-center justify-between gap-1">
65
+ <div
66
+ className="flex min-w-0 items-center gap-1"
67
+ role="radiogroup"
68
+ aria-label={t('searchScope')}
69
+ >
70
+ {SCOPES.map((scope, index) => (
71
+ <Button
72
+ key={scope}
73
+ type="button"
74
+ role="radio"
75
+ aria-checked={input.scope === scope}
76
+ tabIndex={input.scope === scope ? 0 : -1}
77
+ disabled={composition !== null}
78
+ variant="ghost"
79
+ size="sm"
80
+ data-testid={`project-search-scope-${scope}`}
81
+ className={cn(
82
+ 'min-h-7 px-2 text-xs',
83
+ input.scope === scope && 'bg-accent text-accent-foreground'
84
+ )}
85
+ onClick={() => onOptionsChange({ scope })}
86
+ onKeyDown={(event) => {
87
+ const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
88
+ const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'
89
+ if (!forward && !backward && event.key !== 'Home' && event.key !== 'End') return
90
+ event.preventDefault()
91
+ const next =
92
+ event.key === 'Home'
93
+ ? 0
94
+ : event.key === 'End'
95
+ ? 2
96
+ : (index + (forward ? 1 : 2)) % 3
97
+ onOptionsChange({ scope: SCOPES[next] })
98
+ event.currentTarget.parentElement
99
+ ?.querySelectorAll<HTMLButtonElement>('[role="radio"]')
100
+ [next]?.focus()
101
+ }}
102
+ >
103
+ {t(
104
+ scope === 'all'
105
+ ? 'searchScopeAll'
106
+ : scope === 'files'
107
+ ? 'searchScopeFiles'
108
+ : 'searchScopeContent'
109
+ )}
110
+ </Button>
111
+ ))}
112
+ </div>
113
+ <Tooltip>
114
+ <TooltipTrigger
115
+ render={
116
+ <Button
117
+ type="button"
118
+ variant="ghost"
119
+ size="icon-sm"
120
+ className="size-7 shrink-0"
121
+ aria-label={t('closeSearch')}
122
+ onClick={onClose}
123
+ />
124
+ }
125
+ >
126
+ <XIcon className="size-4" />
127
+ </TooltipTrigger>
128
+ <TooltipContent>{t('closeSearch')}</TooltipContent>
129
+ </Tooltip>
130
+ </div>
131
+ <InputGroup className="min-h-8 rounded-none border-0 bg-transparent has-[[data-slot=input-group-control]:focus-visible]:outline-none">
132
+ <InputGroupAddon className="pl-1.5 py-0">
133
+ <SearchIcon className="size-3.5" />
134
+ </InputGroupAddon>
135
+ <InputGroupInput
136
+ ref={inputRef}
137
+ data-testid="project-file-search"
138
+ className="min-w-0 px-1.5 text-sm placeholder:text-xs"
139
+ value={composition ?? input.query}
140
+ placeholder={placeholder}
141
+ aria-label={placeholder}
142
+ aria-invalid={invalid || undefined}
143
+ autoComplete="off"
144
+ spellCheck={false}
145
+ onChange={(event) => {
146
+ if (composing.current) setComposition(event.target.value)
147
+ else onQueryChange(event.target.value)
148
+ }}
149
+ onCompositionStart={(event) => {
150
+ composing.current = true
151
+ setComposition(event.currentTarget.value)
152
+ onComposingChange(true)
153
+ }}
154
+ onCompositionEnd={(event) => {
155
+ composing.current = false
156
+ setComposition(null)
157
+ onQueryChange(event.currentTarget.value)
158
+ onComposingChange(false)
159
+ }}
160
+ onKeyDown={(event) => {
161
+ if (event.nativeEvent.isComposing || composing.current) return
162
+ if (event.key === 'Enter') {
163
+ event.preventDefault()
164
+ onOpenFirst()
165
+ } else if (event.key === 'ArrowDown') {
166
+ event.preventDefault()
167
+ onFocusResults()
168
+ }
169
+ }}
170
+ />
171
+ <InputGroupAddon align="inline-end" className="gap-0 pr-1 py-0">
172
+ {OPTIONS.map(({ key, label, text }) => (
173
+ <Tooltip key={key}>
174
+ <TooltipTrigger
175
+ render={
176
+ <InputGroupButton
177
+ size="icon-xs"
178
+ disabled={composition !== null}
179
+ aria-label={t(label)}
180
+ aria-pressed={input[key]}
181
+ data-testid={`project-search-option-${key}`}
182
+ className={cn(
183
+ 'border border-transparent font-mono text-xs text-muted-foreground',
184
+ key === 'wholeWord' && 'underline underline-offset-4',
185
+ input[key] && 'border-input bg-accent text-accent-foreground'
186
+ )}
187
+ onClick={() => onOptionsChange({ [key]: !input[key] })}
188
+ />
189
+ }
190
+ >
191
+ {text}
192
+ </TooltipTrigger>
193
+ <TooltipContent>{t(label)}</TooltipContent>
194
+ </Tooltip>
195
+ ))}
196
+ </InputGroupAddon>
197
+ </InputGroup>
198
+ </div>
199
+ </div>
200
+ )
201
+ }