@jetcrab/pi-desk 1.1.23-dev → 1.1.25

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 (95) hide show
  1. package/docs/commands.md +4 -2
  2. package/docs/plugins.md +2 -0
  3. package/package.json +2 -2
  4. package/src/app/project-preview/page.tsx +13 -0
  5. package/src/client/l1_entry/pages/l1-project-preview-page.tsx +1 -5
  6. package/src/client/l2_biz/project-preview/hooks/l2-use-project-preview.ts +25 -0
  7. package/src/client/l2_biz/project-preview/l2-project-preview-storage.ts +7 -7
  8. package/src/client/l2_biz/project-preview/l2-project-preview.tsx +8 -5
  9. package/src/client/l2_biz/project-preview/runtime/l2-project-history-runtime.ts +14 -11
  10. package/src/client/l2_biz/project-preview/views/l2-project-branch-select.tsx +347 -0
  11. package/src/client/l2_biz/project-preview/views/l2-project-change-tree.tsx +28 -11
  12. package/src/client/l2_biz/project-preview/views/l2-project-history-list.tsx +35 -8
  13. package/src/client/l2_biz/project-preview/views/l2-project-history-view.tsx +46 -48
  14. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +2 -1
  15. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +6 -2
  16. package/src/client/l3_modules/conversation/l3-conversation-viewport.tsx +18 -8
  17. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +7 -4
  18. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +181 -89
  19. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +80 -28
  20. package/src/common/l2_biz/project-preview/l2-project-history-messages.ts +4 -2
  21. package/src/common/l2_biz/project-preview/l2-project-preview-navigation.ts +14 -0
  22. package/src/server/l1_entry/api/l1-plugin-management-route.ts +0 -4
  23. package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +4 -37
  24. package/src/server/l2_biz/plugin-management/l2-plugin-updates.ts +1 -4
  25. package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +4 -2
  26. package/src/server/l4_foundation/pi/l4-pi-chat-worker.ts +16 -1
  27. package/src/server/l4_foundation/pi/l4-pi-package-root-consistency.ts +3 -6
  28. package/src/server/l4_foundation/pi/l4-pi-package-root-gate.ts +1 -1
  29. package/src/server/l4_foundation/pi/l4-pi-plugin-owner-runtime.ts +4 -0
  30. package/src/server/l4_foundation/pi/l4-pi-plugin-sources.ts +5 -1
  31. package/src/server/l4_foundation/pi/l4-pi-session-initialization.ts +30 -34
  32. package/src/server/l4_foundation/pi/l4-pi-session-resources.ts +15 -14
  33. package/src/server/l4_foundation/pi/l4-pi-work-session-runtime.ts +1 -1
  34. package/src/server/l4_foundation/skills/runtime/l4-skill-catalog.ts +159 -166
  35. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  36. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  37. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  38. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  39. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
  40. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
  41. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  42. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  43. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  44. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  45. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  46. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  47. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  48. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  49. package/temp/build/pi-desk/release/.next/server/app/api/plugins/add/route.js +1 -1
  50. package/temp/build/pi-desk/release/.next/server/app/api/plugins/apply/route.js +1 -1
  51. package/temp/build/pi-desk/release/.next/server/app/api/plugins/batch/route.js +1 -1
  52. package/temp/build/pi-desk/release/.next/server/app/api/plugins/del/route.js +1 -1
  53. package/temp/build/pi-desk/release/.next/server/app/api/plugins/enabled-set/route.js +1 -1
  54. package/temp/build/pi-desk/release/.next/server/app/api/plugins/get/route.js +1 -1
  55. package/temp/build/pi-desk/release/.next/server/app/api/plugins/list/route.js +1 -1
  56. package/temp/build/pi-desk/release/.next/server/app/api/plugins/reload/route.js +1 -1
  57. package/temp/build/pi-desk/release/.next/server/app/api/plugins/update/route.js +1 -1
  58. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/app/page.js +2 -2
  60. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  61. package/temp/build/pi-desk/release/.next/server/app/project-preview/page.js +1 -1
  62. package/temp/build/pi-desk/release/.next/server/app/project-preview/page_client-reference-manifest.js +1 -1
  63. package/temp/build/pi-desk/release/.next/server/chunks/16438.js +1 -1
  64. package/temp/build/pi-desk/release/.next/server/chunks/18748.js +1 -0
  65. package/temp/build/pi-desk/release/.next/server/chunks/24557.js +1 -1
  66. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +13 -14
  67. package/temp/build/pi-desk/release/.next/server/chunks/27344.js +15 -0
  68. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +1 -1
  69. package/temp/build/pi-desk/release/.next/server/chunks/36361.js +2 -2
  70. package/temp/build/pi-desk/release/.next/server/chunks/50520.js +2 -2
  71. package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
  72. package/temp/build/pi-desk/release/.next/server/chunks/80290.js +1 -1
  73. package/temp/build/pi-desk/release/.next/server/chunks/92386.js +15 -16
  74. package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
  75. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  76. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  77. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  78. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  79. package/temp/build/pi-desk/release/.next/static/chunks/15302-6e070e13fa65c863.js +1 -0
  80. package/temp/build/pi-desk/release/.next/static/chunks/19991.bcca96d396b83030.js +1 -0
  81. package/temp/build/pi-desk/release/.next/static/chunks/65232-ef4e5a4174f62faf.js +15 -0
  82. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-54040b9fd54ee8b3.js → page-18d63fd3920dfae0.js} +3 -3
  83. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-038b6c14e03ed878.js +1 -0
  84. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-48eecbb66781c006.js → webpack-c0b805df46fcf8b3.js} +1 -1
  85. package/temp/build/pi-desk/release/.next/static/css/c43b04c244a64fb4.css +1 -0
  86. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  87. package/temp/build/pi-desk/release/.next/server/chunks/20460.js +0 -15
  88. package/temp/build/pi-desk/release/.next/server/chunks/93049.js +0 -1
  89. package/temp/build/pi-desk/release/.next/static/chunks/15302-9c807afc6c60de0c.js +0 -1
  90. package/temp/build/pi-desk/release/.next/static/chunks/19991.8aa2e4b6df15fad6.js +0 -1
  91. package/temp/build/pi-desk/release/.next/static/chunks/9038-05fd2688407b39c1.js +0 -15
  92. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-bc18765121950e65.js +0 -1
  93. package/temp/build/pi-desk/release/.next/static/css/10e063cfb3cda7bd.css +0 -1
  94. /package/temp/build/pi-desk/release/.next/static/{gAYTx4KJJR3vTtGYexj4J → 6a4eIEjmpW5l8Qlgtzzzg}/_buildManifest.js +0 -0
  95. /package/temp/build/pi-desk/release/.next/static/{gAYTx4KJJR3vTtGYexj4J → 6a4eIEjmpW5l8Qlgtzzzg}/_ssgManifest.js +0 -0
@@ -72,16 +72,29 @@ function emptySearch(input: L4ProjectSearchInput): L3FileSearch {
72
72
  error: null
73
73
  }
74
74
  }
75
- interface DiffOptions {
75
+ export interface L3FileDiffTarget {
76
76
  repositoryRoot: string
77
77
  path: string
78
78
  comparison?: L4GitHistoricalDiff
79
+ }
80
+ export type L3FileTabTarget = string | L3FileDiffTarget
81
+ export function getL3FileDiffKey(target: L3FileDiffTarget): string {
82
+ return JSON.stringify([
83
+ target.repositoryRoot,
84
+ target.path,
85
+ target.comparison?.baseCommit ?? null,
86
+ target.comparison?.targetCommit ?? null,
87
+ target.comparison?.oldPath ?? null
88
+ ])
89
+ }
90
+ interface DiffOptions extends L3FileDiffTarget {
79
91
  editorState?: L4CodeDiffState | null
80
92
  label?: string
81
93
  initialLine?: number
82
94
  }
83
95
  export type L3FileDiff = DiffOptions &
84
96
  (
97
+ | { status: 'unloaded' }
85
98
  | { status: 'loading' }
86
99
  | {
87
100
  status: 'text'
@@ -101,6 +114,7 @@ export interface L3ProjectFileState {
101
114
  search: L3FileSearch
102
115
  searchOpen: boolean
103
116
  documents: Map<string, L3FileDocument>
117
+ diffs: L3FileDiff[]
104
118
  activeDiff: L3FileDiff | null
105
119
  }
106
120
  interface RuntimeOptions {
@@ -118,7 +132,8 @@ export interface L3ProjectFileBrowsingState {
118
132
  expandedPaths: string[]
119
133
  treeScrollTop: number
120
134
  searchQuery: string
121
- activeDiff: { repositoryRoot: string; path: string; comparison?: L4GitHistoricalDiff } | null
135
+ diffs?: L3FileDiffTarget[]
136
+ activeDiff: L3FileDiffTarget | null
122
137
  }
123
138
  function projectText(
124
139
  key: keyof (typeof L3_PROJECT_FILES_LOCALE_MESSAGES)['zh-CN'],
@@ -153,6 +168,7 @@ export class L3ProjectFileRuntime {
153
168
  search: emptySearch({ query: '', ...DEFAULT_SEARCH_OPTIONS }),
154
169
  searchOpen: false,
155
170
  documents: new Map(),
171
+ diffs: [],
156
172
  activeDiff: null
157
173
  }
158
174
  private readonly listeners = new Set<() => void>()
@@ -169,7 +185,7 @@ export class L3ProjectFileRuntime {
169
185
  private readonly restoredPreviewScrollTops = new Map<string, number>()
170
186
  private searchRequest: AbortController | null = null
171
187
  private navigation: L4CodeLineNavigation | null = null
172
- private diffRequest: AbortController | null = null
188
+ private readonly diffRequests = new Map<string, AbortController>()
173
189
  private revision = 0
174
190
  private disposed = false
175
191
 
@@ -225,8 +241,9 @@ export class L3ProjectFileRuntime {
225
241
  return { editorStates, fileViewModes }
226
242
  }
227
243
  restoreBrowsing(input: L3ProjectFileBrowsingState): void {
228
- this.state.tabs = [...input.tabs]
229
- for (const path of input.tabs) {
244
+ if (this.disposed) return
245
+ this.state.tabs = input.tabs.slice(0, this.options.maxTabs)
246
+ for (const path of this.state.tabs) {
230
247
  this.restoredTabs.add(path)
231
248
  const editorState = input.editorStates[path]
232
249
  if (editorState) this.restoredEditorStates.set(path, editorState)
@@ -234,18 +251,30 @@ export class L3ProjectFileRuntime {
234
251
  if (viewMode) this.restoredFileViewModes.set(path, viewMode)
235
252
  }
236
253
  this.state.activePath =
237
- input.activePath && input.tabs.includes(input.activePath) ? input.activePath : null
254
+ input.activePath && this.state.tabs.includes(input.activePath) ? input.activePath : null
238
255
  this.state.expandedPaths = new Set(input.expandedPaths)
239
256
  this.state.treeScrollTop = input.treeScrollTop
240
257
  this.state.search = emptySearch({ query: input.searchQuery, ...DEFAULT_SEARCH_OPTIONS })
241
- for (const path of input.tabs) this.state.documents.set(path, { status: 'unloaded' })
242
- for (const path of input.expandedPaths) void this.ensureDirectory(path)
243
- if (input.activeDiff) {
244
- this.openDiff(
245
- input.activeDiff.repositoryRoot,
246
- input.activeDiff.path,
247
- input.activeDiff.comparison
258
+ for (const path of this.state.tabs) this.state.documents.set(path, { status: 'unloaded' })
259
+ const targets = [...(input.diffs ?? []), ...(input.activeDiff ? [input.activeDiff] : [])]
260
+ for (const target of targets) {
261
+ if (this.state.diffs.some((diff) => getL3FileDiffKey(diff) === getL3FileDiffKey(target)))
262
+ continue
263
+ if (
264
+ this.options.maxTabs &&
265
+ this.state.tabs.length + this.state.diffs.length >= this.options.maxTabs
248
266
  )
267
+ break
268
+ this.state.diffs.push({ ...target, status: 'unloaded' })
269
+ }
270
+ for (const path of input.expandedPaths) void this.ensureDirectory(path)
271
+ this.publish()
272
+ const activeTarget = input.activeDiff
273
+ const activeDiff = activeTarget
274
+ ? this.state.diffs.find((diff) => getL3FileDiffKey(diff) === getL3FileDiffKey(activeTarget))
275
+ : null
276
+ if (activeDiff) {
277
+ this.openDiff(activeDiff.repositoryRoot, activeDiff.path, activeDiff.comparison)
249
278
  } else if (this.state.activePath) {
250
279
  void this.ensureDocument(this.state.activePath)
251
280
  }
@@ -421,13 +450,12 @@ export class L3ProjectFileRuntime {
421
450
  normalized)
422
451
  : normalized
423
452
  if (!this.state.tabs.includes(path)) {
424
- if (this.options.maxTabs && this.state.tabs.length >= this.options.maxTabs) {
425
- this.options.onNotice?.(projectText('maxOpenFiles', { count: this.options.maxTabs }))
453
+ if (!this.canOpenTab()) {
426
454
  return
427
455
  }
428
456
  this.state.tabs.push(path)
429
457
  }
430
- this.clearDiff()
458
+ this.state.activeDiff = null
431
459
  if (!this.state.documents.has(path)) this.state.documents.set(path, { status: 'unloaded' })
432
460
  this.state.activePath = path
433
461
  this.navigation = line === undefined ? null : { line }
@@ -436,55 +464,76 @@ export class L3ProjectFileRuntime {
436
464
  void this.ensureDocument(path, line !== undefined)
437
465
  this.publish()
438
466
  }
439
- closeTabs(target: string | null, scope: L3FileTabCloseScope): void {
467
+ closeTabs(target: L3FileTabTarget | null, scope: L3FileTabCloseScope): void {
468
+ if (this.disposed) return
440
469
  const state = this.state
441
- const tabs: (string | null)[] = [...state.tabs, ...(state.activeDiff ? [null] : [])]
442
- const index = tabs.indexOf(target)
470
+ const tabs: (string | L3FileDiff)[] = [...state.tabs, ...state.diffs]
471
+ const resolved =
472
+ target === null
473
+ ? state.activeDiff
474
+ : typeof target === 'string'
475
+ ? target
476
+ : state.diffs.find((diff) => getL3FileDiffKey(diff) === getL3FileDiffKey(target))
477
+ const index = resolved === null || resolved === undefined ? -1 : tabs.indexOf(resolved)
443
478
  if (index < 0 && scope !== 'all') return
444
479
  const removed = new Set(
445
480
  tabs.filter(
446
481
  (tab, position) =>
447
482
  scope === 'all' ||
448
483
  (scope === 'current'
449
- ? tab === target
484
+ ? tab === resolved
450
485
  : scope === 'others'
451
- ? tab !== target
486
+ ? tab !== resolved
452
487
  : scope === 'left'
453
488
  ? position < index
454
489
  : position > index)
455
490
  )
456
491
  )
457
492
  if (!removed.size) return
458
- const previousActive = state.activeDiff ? null : state.activePath
459
- for (const path of removed) {
460
- if (path === null) {
461
- this.clearDiff()
493
+ const previousActive = state.activeDiff ?? state.activePath
494
+ for (const tab of removed) {
495
+ if (typeof tab !== 'string') {
496
+ this.invalidateDiff(tab)
462
497
  continue
463
498
  }
464
- this.documentRequests.get(path)?.abort()
465
- this.documentRequests.delete(path)
466
- this.releaseDocument(state.documents.get(path))
467
- state.documents.delete(path)
468
- this.restoredEditorStates.delete(path)
469
- this.restoredFileViewModes.delete(path)
470
- this.restoredPreviewScrollTops.delete(path)
471
- this.closeMissing.delete(path)
472
- this.restoredTabs.delete(path)
499
+ this.documentRequests.get(tab)?.abort()
500
+ this.documentRequests.delete(tab)
501
+ this.releaseDocument(state.documents.get(tab))
502
+ state.documents.delete(tab)
503
+ this.restoredEditorStates.delete(tab)
504
+ this.restoredFileViewModes.delete(tab)
505
+ this.restoredPreviewScrollTops.delete(tab)
506
+ this.closeMissing.delete(tab)
507
+ this.restoredTabs.delete(tab)
473
508
  }
474
509
  state.tabs = state.tabs.filter((path) => !removed.has(path))
510
+ state.diffs = state.diffs.filter((diff) => !removed.has(diff))
475
511
  if (state.activePath && removed.has(state.activePath)) {
476
- this.navigation = null
477
- const activeIndex = tabs.indexOf(state.activePath)
512
+ const fileTabs = tabs.filter((tab): tab is string => typeof tab === 'string')
513
+ const activeIndex = fileTabs.indexOf(state.activePath)
478
514
  state.activePath =
479
- tabs.slice(activeIndex + 1).find((path) => path !== null && !removed.has(path)) ??
480
- tabs
515
+ fileTabs.slice(activeIndex + 1).find((path) => !removed.has(path)) ??
516
+ fileTabs
481
517
  .slice(0, activeIndex)
482
518
  .reverse()
483
- .find((path) => path !== null && !removed.has(path)) ??
519
+ .find((path) => !removed.has(path)) ??
484
520
  null
485
521
  }
522
+ if (previousActive !== null && removed.has(previousActive)) {
523
+ this.navigation = null
524
+ const activeIndex = tabs.indexOf(previousActive)
525
+ const next =
526
+ tabs.slice(activeIndex + 1).find((tab) => !removed.has(tab)) ??
527
+ tabs
528
+ .slice(0, activeIndex)
529
+ .reverse()
530
+ .find((tab) => !removed.has(tab))
531
+ state.activeDiff = typeof next === 'object' ? next : null
532
+ if (typeof next === 'string') state.activePath = next
533
+ }
486
534
  this.publish()
487
- if (!state.activeDiff && state.activePath && previousActive !== state.activePath)
535
+ if (state.activeDiff) void this.ensureDiff(state.activeDiff)
536
+ else if (state.activePath && previousActive !== state.activePath)
488
537
  void this.ensureDocument(state.activePath)
489
538
  }
490
539
  reloadFile(path: string): void {
@@ -634,16 +683,32 @@ export class L3ProjectFileRuntime {
634
683
  if (this.disposed || document?.status !== 'text' || document.comparison?.status !== 'ready')
635
684
  return
636
685
  const comparison = document.comparison
637
- this.clearDiff()
638
- this.state.activeDiff = {
639
- status: 'text',
640
- repositoryRoot: comparison.repositoryRoot,
641
- path: comparison.path,
642
- original: comparison.original,
643
- modified: { path: comparison.path, content: document.content },
644
- initialLine
686
+ let diff = this.state.diffs.find(
687
+ (item) =>
688
+ !item.comparison &&
689
+ item.repositoryRoot === comparison.repositoryRoot &&
690
+ item.path === comparison.path
691
+ )
692
+ if (!diff) {
693
+ if (!this.canOpenTab()) return
694
+ diff = {
695
+ status: 'text',
696
+ repositoryRoot: comparison.repositoryRoot,
697
+ path: comparison.path,
698
+ original: comparison.original,
699
+ modified: { path: comparison.path, content: document.content }
700
+ }
701
+ this.state.diffs.push(diff)
702
+ } else {
703
+ this.invalidateDiff(diff)
704
+ Object.assign(diff, {
705
+ status: 'text',
706
+ original: comparison.original,
707
+ modified: { path: comparison.path, content: document.content }
708
+ })
645
709
  }
646
- this.publish()
710
+ if (initialLine !== undefined) diff.initialLine = initialLine
711
+ this.openDiff(diff.repositoryRoot, diff.path)
647
712
  }
648
713
  openDiff(
649
714
  repositoryRoot: string,
@@ -652,30 +717,35 @@ export class L3ProjectFileRuntime {
652
717
  label?: string
653
718
  ): void {
654
719
  if (this.disposed) return
655
- this.clearDiff()
656
- this.state.activeDiff = {
657
- status: 'loading',
658
- repositoryRoot,
659
- path,
660
- comparison,
661
- label
720
+ const target = { repositoryRoot, path, comparison }
721
+ let diff = this.state.diffs.find((item) => getL3FileDiffKey(item) === getL3FileDiffKey(target))
722
+ if (!diff) {
723
+ if (!this.canOpenTab()) return
724
+ diff = { ...target, status: 'unloaded', label }
725
+ this.state.diffs.push(diff)
726
+ } else if (label !== undefined) {
727
+ diff.label = label
662
728
  }
729
+ this.navigation = null
730
+ this.state.activeDiff = diff
663
731
  this.publish()
664
- void this.ensureDiff()
732
+ void this.ensureDiff(diff)
665
733
  }
666
734
  closeDiff(): void {
667
735
  this.closeTabs(null, 'current')
668
736
  }
669
737
  reloadDiff(): void {
670
- const previous = this.state.activeDiff
671
- if (!previous) return
672
- this.clearDiff()
673
- this.state.activeDiff = { ...previous, status: 'loading' }
738
+ const diff = this.state.activeDiff
739
+ if (!diff || this.disposed) return
740
+ this.invalidateDiff(diff)
674
741
  this.publish()
675
- void this.ensureDiff()
742
+ void this.ensureDiff(diff)
676
743
  }
677
744
  saveDiffState(expected: L3FileDiff, state: L4CodeDiffState | null): void {
678
- if (this.state.activeDiff === expected) expected.editorState = state
745
+ // 卸载回调仍属于原标签;尚未创建编辑器时的空状态不能抹掉已有位置。
746
+ if (this.disposed || !this.state.diffs.includes(expected) || !state) return
747
+ expected.editorState = state
748
+ delete expected.initialLine
679
749
  }
680
750
  setDiffViewMode(diffViewMode: 'side-by-side' | 'inline'): void {
681
751
  if (this.state.activeDiff) saveL4CodePreferences({ diffViewMode })
@@ -694,10 +764,12 @@ export class L3ProjectFileRuntime {
694
764
  this.invalidateDocument(path)
695
765
  this.closeMissing.add(path)
696
766
  }
697
- if (this.state.activeDiff?.repositoryRoot === repositoryRoot) this.reloadDiff()
698
- else this.publish()
699
- if (!this.state.activeDiff && this.state.activePath)
700
- void this.ensureDocument(this.state.activePath)
767
+ for (const diff of this.state.diffs) {
768
+ if (diff.repositoryRoot === repositoryRoot && !diff.comparison) this.invalidateDiff(diff)
769
+ }
770
+ this.publish()
771
+ if (this.state.activeDiff) void this.ensureDiff(this.state.activeDiff)
772
+ else if (this.state.activePath) void this.ensureDocument(this.state.activePath)
701
773
  }
702
774
  saveEditorState(path: string, state: L4CodePreviewState | null): void {
703
775
  const document = this.state.documents.get(path)
@@ -725,49 +797,69 @@ export class L3ProjectFileRuntime {
725
797
  for (const request of this.documentRequests.values()) request.abort()
726
798
  this.documentRequests.clear()
727
799
  this.cancelSearch()
728
- this.clearDiff()
800
+ for (const diff of this.state.diffs) this.invalidateDiff(diff)
801
+ this.state.diffs = []
802
+ this.state.activeDiff = null
729
803
  for (const document of this.state.documents.values()) this.releaseDocument(document)
730
804
  this.state.documents.clear()
731
805
  this.restoredPreviewScrollTops.clear()
732
806
  this.listeners.clear()
733
807
  }
734
- private async ensureDiff(): Promise<void> {
735
- const current = this.state.activeDiff
736
- if (this.disposed || current?.status !== 'loading' || this.diffRequest) return
808
+ private async ensureDiff(diff: L3FileDiff): Promise<void> {
809
+ const key = getL3FileDiffKey(diff)
810
+ if (this.disposed || diff.status !== 'unloaded' || this.diffRequests.has(key)) return
737
811
  const request = new AbortController()
738
- this.diffRequest = request
812
+ this.diffRequests.set(key, request)
813
+ Object.assign(diff, { status: 'loading' })
814
+ this.publish()
739
815
  try {
740
816
  const result = await this.reader.diff(
741
- current.repositoryRoot,
742
- current.path,
817
+ diff.repositoryRoot,
818
+ diff.path,
743
819
  request.signal,
744
- current.comparison
820
+ diff.comparison
745
821
  )
746
- if (this.diffRequest !== request) return
747
- this.state.activeDiff =
822
+ if (this.diffRequests.get(key) !== request) return
823
+ // 标签对象贯穿整个生命周期,异步完成不能覆盖卸载回调保存的编辑位置。
824
+ Object.assign(
825
+ diff,
748
826
  result.kind === 'text'
749
- ? { ...current, status: 'text', original: result.original, modified: result.modified }
827
+ ? { status: 'text', original: result.original, modified: result.modified }
750
828
  : result.kind === 'metadata'
751
829
  ? {
752
- ...current,
753
830
  status: 'metadata',
754
831
  originalMetadata: result.original,
755
832
  modifiedMetadata: result.modified
756
833
  }
757
- : { ...current, status: result.kind, error: null }
834
+ : { status: result.kind, error: null }
835
+ )
758
836
  } catch (cause) {
759
- if (this.diffRequest !== request) return
760
- this.state.activeDiff = { ...current, status: 'error', error: message(cause) }
837
+ if (this.diffRequests.get(key) !== request) return
838
+ console.warn('[Pi Desk][ProjectFile] 读取 Diff 失败', {
839
+ cwd: this.cwd,
840
+ repositoryRoot: diff.repositoryRoot,
841
+ path: diff.path,
842
+ error: message(cause)
843
+ })
844
+ Object.assign(diff, { status: 'error', error: message(cause) })
761
845
  }
762
- if (this.diffRequest !== request) return
763
- this.diffRequest = null
846
+ if (this.diffRequests.get(key) !== request) return
847
+ this.diffRequests.delete(key)
764
848
  this.publish()
765
849
  }
766
- private clearDiff(): void {
767
- this.navigation = null
768
- this.diffRequest?.abort()
769
- this.diffRequest = null
770
- this.state.activeDiff = null
850
+ private invalidateDiff(diff: L3FileDiff): void {
851
+ const key = getL3FileDiffKey(diff)
852
+ this.diffRequests.get(key)?.abort()
853
+ this.diffRequests.delete(key)
854
+ Object.assign(diff, { status: 'unloaded' })
855
+ for (const field of ['original', 'modified', 'originalMetadata', 'modifiedMetadata', 'error'])
856
+ Reflect.deleteProperty(diff, field)
857
+ }
858
+ private canOpenTab(): boolean {
859
+ const maxTabs = this.options.maxTabs
860
+ if (!maxTabs || this.state.tabs.length + this.state.diffs.length < maxTabs) return true
861
+ this.options.onNotice?.(projectText('maxOpenFiles', { count: maxTabs }))
862
+ return false
771
863
  }
772
864
  private cancelSearch(): void {
773
865
  this.searchRequest?.abort()
@@ -11,7 +11,12 @@ import {
11
11
  ContextMenuSeparator,
12
12
  ContextMenuTrigger
13
13
  } from '@client/l4_foundation/ui/shadcn/context-menu'
14
- import type { L3FileTabCloseScope } from './l3-project-file-runtime'
14
+ import {
15
+ getL3FileDiffKey,
16
+ type L3FileDiffTarget,
17
+ type L3FileTabTarget,
18
+ type L3FileTabCloseScope
19
+ } from './l3-project-file-runtime'
15
20
  import { L3FileContextMenuContent } from './l3-file-context-menu-content'
16
21
  import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
17
22
  import { findL3GitFile, getL3GitChangeBadge, getL3GitChangeClass } from './l3-project-git-model'
@@ -21,11 +26,12 @@ interface L3ProjectFileTabsProps {
21
26
  gitRuntime: L3ProjectGitBrowser
22
27
  paths: readonly string[]
23
28
  activePath: string | null
24
- diffPath: string | null
29
+ diffs: readonly L3FileDiffTarget[]
30
+ activeDiff: L3FileDiffTarget | null
25
31
  mobile: boolean
26
32
  actions?: ReactNode
27
- onActivate: (path: string) => void
28
- onClose: (path: string | null, scope: L3FileTabCloseScope) => void
33
+ onActivate: (target: L3FileTabTarget) => void
34
+ onClose: (target: L3FileTabTarget | null, scope: L3FileTabCloseScope) => void
29
35
  }
30
36
 
31
37
  const CLOSE_ACTIONS: readonly { scope: L3FileTabCloseScope; label: string }[] = [
@@ -48,7 +54,8 @@ export function L3ProjectFileTabs({
48
54
  gitRuntime,
49
55
  paths,
50
56
  activePath,
51
- diffPath,
57
+ diffs,
58
+ activeDiff,
52
59
  mobile,
53
60
  actions,
54
61
  onActivate,
@@ -65,14 +72,50 @@ export function L3ProjectFileTabs({
65
72
  const merged = actions !== undefined
66
73
  const rowHeight = merged && !mobile ? 32 : 36
67
74
  const maxHeight = (mobile ? 2 : 3) * rowHeight
68
- const tabs: { path: string | null; label: string; title: string }[] = paths.map((path) => ({
75
+ const allPaths = [
76
+ ...paths,
77
+ ...diffs.map((diff) => [diff.repositoryRoot, diff.path].filter(Boolean).join('/'))
78
+ ]
79
+ const tabs: {
80
+ target: L3FileTabTarget
81
+ key: string
82
+ path: string
83
+ label: string
84
+ revisions?: string
85
+ title: string
86
+ testId: string
87
+ }[] = paths.map((path) => ({
88
+ target: path,
89
+ key: `file:${path}`,
69
90
  path,
70
- label: fileLabel(path, paths),
71
- title: path
91
+ label: fileLabel(path, allPaths),
92
+ title: path,
93
+ testId: `file-tab-${path}`
72
94
  }))
73
- if (diffPath)
74
- tabs.push({ path: null, label: `${fileLabel(diffPath, paths)} · Diff`, title: diffPath })
75
- const active = diffPath ? null : activePath
95
+ for (const diff of diffs) {
96
+ const path = [diff.repositoryRoot, diff.path].filter(Boolean).join('/')
97
+ const comparison = diff.comparison
98
+ const revisions = comparison
99
+ ? `${comparison.baseCommit?.slice(0, 7) ?? '∅'} → ${comparison.targetCommit.slice(0, 7)}`
100
+ : ''
101
+ const key = getL3FileDiffKey(diff)
102
+ tabs.push({
103
+ target: diff,
104
+ key: `diff:${key}`,
105
+ path,
106
+ label: `${fileLabel(path, allPaths)} · Diff`,
107
+ revisions,
108
+ title: [
109
+ path,
110
+ comparison?.oldPath,
111
+ comparison ? `${comparison.baseCommit ?? '∅'} → ${comparison.targetCommit}` : null
112
+ ]
113
+ .filter(Boolean)
114
+ .join('\n'),
115
+ testId: `file-diff-tab-${path}${comparison ? `-${key}` : ''}`
116
+ })
117
+ }
118
+ const active = activeDiff ? `diff:${getL3FileDiffKey(activeDiff)}` : `file:${activePath}`
76
119
 
77
120
  useLayoutEffect(() => {
78
121
  const rows = rowsRef.current
@@ -95,7 +138,7 @@ export function L3ProjectFileTabs({
95
138
  const observer = new ResizeObserver(inspect)
96
139
  observer.observe(rows)
97
140
  return () => observer.disconnect()
98
- }, [active, diffPath, maxHeight, paths])
141
+ }, [active, diffs, maxHeight, paths])
99
142
 
100
143
  if (tabs.length === 0) return null
101
144
  return (
@@ -115,18 +158,20 @@ export function L3ProjectFileTabs({
115
158
  >
116
159
  {tabs.map((tab, index) => {
117
160
  const clipped = !expanded && clippedIndexes.includes(index)
118
- const selected = tab.path === active
161
+ const selected = tab.key === active
162
+ const isDiff = typeof tab.target !== 'string'
119
163
  const change =
120
- tab.path && snapshot?.status === 'ready'
164
+ !isDiff && snapshot?.status === 'ready'
121
165
  ? findL3GitFile(cwd, tab.path, snapshot.repositories)?.change
122
166
  : undefined
123
- const closeLabel =
124
- tab.path === null
125
- ? t('closeDiff', { name: tab.title.split('/').at(-1) })
126
- : t('closeFile', { name: tab.title.split('/').at(-1) })
167
+ const closeLabel = isDiff
168
+ ? t('closeDiff', {
169
+ name: `${tab.path.split('/').at(-1)}${tab.revisions ? ` · ${tab.revisions}` : ''}`
170
+ })
171
+ : t('closeFile', { name: tab.path.split('/').at(-1) })
127
172
  return (
128
173
  <ContextMenu
129
- key={tab.path ?? 'diff'}
174
+ key={tab.key}
130
175
  onOpenChange={(open, details) => {
131
176
  if (
132
177
  open &&
@@ -140,16 +185,20 @@ export function L3ProjectFileTabs({
140
185
  render={
141
186
  <div
142
187
  data-file-tab
143
- data-testid={
144
- tab.path === null ? `file-diff-tab-${tab.title}` : `file-tab-${tab.path}`
145
- }
188
+ data-testid={tab.testId}
146
189
  aria-hidden={clipped || undefined}
147
190
  inert={clipped || undefined}
148
191
  title={tab.title}
149
192
  className={cn(
150
193
  'flex max-w-full items-center text-sm',
151
194
  merged ? (mobile ? 'h-9' : 'h-8') : 'h-9 border-r border-b',
152
- mobile ? 'w-[min(50%,12rem)]' : 'max-w-60',
195
+ mobile
196
+ ? tab.revisions
197
+ ? 'w-full'
198
+ : 'w-[min(50%,12rem)]'
199
+ : tab.revisions
200
+ ? 'max-w-80'
201
+ : 'max-w-60',
153
202
  clipped && 'invisible',
154
203
  selected
155
204
  ? 'bg-background text-foreground shadow-[inset_0_-2px_0_var(--primary)]'
@@ -171,7 +220,7 @@ export function L3ProjectFileTabs({
171
220
  suppressTouchClickRef.current = false
172
221
  return
173
222
  }
174
- if (tab.path !== null) onActivate(tab.path)
223
+ onActivate(tab.target)
175
224
  }}
176
225
  onKeyDown={(event) => {
177
226
  if (event.key === 'F10' && event.shiftKey) {
@@ -212,6 +261,9 @@ export function L3ProjectFileTabs({
212
261
  <span className={cn('truncate', change && getL3GitChangeClass(change))}>
213
262
  {tab.label}
214
263
  </span>
264
+ {tab.revisions ? (
265
+ <span className="shrink-0 font-mono text-xs">{tab.revisions}</span>
266
+ ) : null}
215
267
  {change ? (
216
268
  <span
217
269
  className={cn('shrink-0 text-xs', getL3GitChangeClass(change))}
@@ -225,8 +277,8 @@ export function L3ProjectFileTabs({
225
277
  variant="ghost"
226
278
  size="icon"
227
279
  data-testid={
228
- tab.path === null
229
- ? `file-diff-tab-close-${tab.title}`
280
+ isDiff
281
+ ? tab.testId.replace('file-diff-tab-', 'file-diff-tab-close-')
230
282
  : `file-tab-close-${tab.path}`
231
283
  }
232
284
  aria-label={closeLabel}
@@ -236,7 +288,7 @@ export function L3ProjectFileTabs({
236
288
  suppressTouchClickRef.current = false
237
289
  return
238
290
  }
239
- onClose(tab.path, 'current')
291
+ onClose(tab.target, 'current')
240
292
  }}
241
293
  >
242
294
  <XIcon className="size-3.5" />
@@ -258,7 +310,7 @@ export function L3ProjectFileTabs({
258
310
  }
259
311
  onClick={() => {
260
312
  suppressTouchClickRef.current = false
261
- onClose(tab.path, scope)
313
+ onClose(tab.target, scope)
262
314
  }}
263
315
  >
264
316
  {t(label)}
@@ -1,5 +1,6 @@
1
1
  export const L2_PROJECT_HISTORY_MESSAGES = {
2
2
  en: {
3
+ previewTitle: 'Project preview',
3
4
  chooseProject: 'Open a project',
4
5
  switchProject: 'Switch project',
5
6
  directoryPath: 'Directory path',
@@ -26,7 +27,7 @@ export const L2_PROJECT_HISTORY_MESSAGES = {
26
27
  noCommits: 'No commits',
27
28
  scanLimit: 'History scan limit reached. Choose a more specific branch or commit.',
28
29
  loadedLimit: '2,000 commits loaded. Narrow your search.',
29
- loadMoreCommits: 'Load more commits',
30
+ noMoreCommits: 'All commits loaded',
30
31
  backCommits: 'Back to commits',
31
32
  commitChanges: 'Commit changes',
32
33
  cumulativeChanges: 'Cumulative changes',
@@ -90,6 +91,7 @@ export const L2_PROJECT_HISTORY_MESSAGES = {
90
91
  historyReadFailed: 'Could not read Git history'
91
92
  },
92
93
  'zh-CN': {
94
+ previewTitle: '项目预览',
93
95
  chooseProject: '打开项目',
94
96
  switchProject: '切换项目',
95
97
  directoryPath: '目录路径',
@@ -116,7 +118,7 @@ export const L2_PROJECT_HISTORY_MESSAGES = {
116
118
  noCommits: '没有提交记录',
117
119
  scanLimit: '历史扫描达到上限,请选择更具体的分支或提交。',
118
120
  loadedLimit: '已加载 2000 条,请缩小查询范围。',
119
- loadMoreCommits: '加载更多提交',
121
+ noMoreCommits: '已加载全部提交',
120
122
  backCommits: '返回提交列表',
121
123
  commitChanges: '提交变更',
122
124
  cumulativeChanges: '累计变更',