@jetcrab/pi-desk 1.1.5 → 1.1.8-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 (95) hide show
  1. package/README.md +4 -4
  2. package/README.zh-CN.md +4 -4
  3. package/docs/README.md +8 -0
  4. package/docs/doc_asset/client-overview.webp +0 -0
  5. package/docs/doc_asset/multi-window.webp +0 -0
  6. package/docs/{pi-desk/plugins.md → plugins.md} +108 -1
  7. package/package.json +2 -2
  8. package/src/client/l2_biz/project-preview/l2-project-preview-dialog.tsx +3 -0
  9. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +8 -0
  10. package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-viewport.ts +81 -7
  11. package/src/client/l2_biz/workbench/l2-workbench-chat-view.tsx +2 -1
  12. package/src/client/l3_modules/project-files/l3-project-document-git.ts +82 -0
  13. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +102 -5
  14. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +2 -6
  15. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +83 -1
  16. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +24 -2
  17. package/src/client/l3_modules/project-files/l3-project-git-model.ts +32 -0
  18. package/src/client/l4_foundation/ui/code/l4-code-diff.tsx +25 -5
  19. package/src/client/l4_foundation/ui/code/l4-code-line-changes.ts +49 -0
  20. package/src/client/l4_foundation/ui/code/l4-code-line-changes.worker.ts +10 -0
  21. package/src/client/l4_foundation/ui/code/l4-code-line-markers.module.css +60 -0
  22. package/src/client/l4_foundation/ui/code/l4-code-line-markers.ts +156 -0
  23. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +61 -5
  24. package/src/client/l4_foundation/ui/code/l4-monaco-lines-diff.d.ts +21 -0
  25. package/src/client/l4_foundation/ui/shadcn/hover-card.tsx +28 -2
  26. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +16 -0
  27. package/src/server/l4_foundation/pidesk/l4-pidesk-runtime.ts +1 -1
  28. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  29. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  30. package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
  31. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +5 -4
  32. package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
  33. package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
  34. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  35. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  36. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  37. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  38. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  39. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  40. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  41. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  42. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  43. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  44. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/temp/build/pi-desk/release/.next/server/app/page.js +6 -6
  46. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  47. package/temp/build/pi-desk/release/.next/server/chunks/20842.js +1 -0
  48. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +1 -1
  49. package/temp/build/pi-desk/release/.next/server/chunks/{9248.js → 354.js} +1 -1
  50. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +6 -6
  51. package/temp/build/pi-desk/release/.next/server/chunks/45671.js +1 -0
  52. package/temp/build/pi-desk/release/.next/server/chunks/47757.js +1 -1
  53. package/temp/build/pi-desk/release/.next/server/chunks/56284.js +62 -0
  54. package/temp/build/pi-desk/release/.next/server/chunks/73691.js +1 -0
  55. package/temp/build/pi-desk/release/.next/server/chunks/82499.js +2 -2
  56. package/temp/build/pi-desk/release/.next/server/chunks/94296.js +2 -2
  57. package/temp/build/pi-desk/release/.next/server/chunks/pi-desk-line-changes.js +1 -0
  58. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  59. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  60. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  61. package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
  62. package/temp/build/pi-desk/release/.next/server/webpack-runtime.js +1 -1
  63. package/temp/build/pi-desk/release/.next/static/chunks/28739.540a4f24a8eaa3fd.js +1 -0
  64. package/temp/build/pi-desk/release/.next/static/chunks/31087-333cae396a52f232.js +1 -0
  65. package/temp/build/pi-desk/release/.next/static/chunks/3814-35cfab0ce3aa7c41.js +1 -0
  66. package/temp/build/pi-desk/release/.next/static/chunks/38459.9a9b6f4f3ec3ff50.js +1 -0
  67. package/temp/build/pi-desk/release/.next/static/chunks/45992-a39a014497ec4dd1.js +13 -0
  68. package/temp/build/pi-desk/release/.next/static/chunks/53291.ab6ef49016107124.js +1 -0
  69. package/temp/build/pi-desk/release/.next/static/chunks/{54206.82a63ab971911eda.js → 54206.ffb8c581c6661ae2.js} +2 -2
  70. package/temp/build/pi-desk/release/.next/static/chunks/5598.f34dce2a9de0d0e3.js +1 -0
  71. package/temp/build/pi-desk/release/.next/static/chunks/70669.8b4231a8675499e0.js +1 -0
  72. package/temp/build/pi-desk/release/.next/static/chunks/95287.34b860abf26f64a5.js +1 -0
  73. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-586f6f88483051a7.js +3 -0
  74. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-c6c68b33156be10a.js → page-0f2cf68a4666420b.js} +4 -4
  75. package/temp/build/pi-desk/release/.next/static/chunks/pi-desk-line-changes.49f8d59c256621d6.js +1 -0
  76. package/temp/build/pi-desk/release/.next/static/chunks/webpack-26261285e181235a.js +1 -0
  77. package/temp/build/pi-desk/release/.next/static/css/{f6a76a493aae9ae3.css → adb1d4e48dd43c0a.css} +1 -1
  78. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  79. package/docs/pi-desk/README.md +0 -47
  80. package/temp/build/pi-desk/release/.next/server/chunks/6656.js +0 -1
  81. package/temp/build/pi-desk/release/.next/server/chunks/89236.js +0 -62
  82. package/temp/build/pi-desk/release/.next/static/chunks/19991.02fcb7c8ca3b1fe4.js +0 -1
  83. package/temp/build/pi-desk/release/.next/static/chunks/26285.54b8bcbf7a198c84.js +0 -1
  84. package/temp/build/pi-desk/release/.next/static/chunks/52354-6461358c522130be.js +0 -1
  85. package/temp/build/pi-desk/release/.next/static/chunks/74132-876f256c7a6101af.js +0 -13
  86. package/temp/build/pi-desk/release/.next/static/chunks/79615.7d68b269d93fcb93.js +0 -1
  87. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-4faf3a52d219296d.js +0 -3
  88. package/temp/build/pi-desk/release/.next/static/chunks/webpack-7bfbf627379fb4c1.js +0 -1
  89. /package/docs/{pi-desk/commands.md → commands.md} +0 -0
  90. /package/docs/{pi-desk/examples → examples}/local-application/browser/application.js +0 -0
  91. /package/docs/{pi-desk/examples → examples}/local-application/browser/entry.js +0 -0
  92. /package/docs/{pi-desk/examples → examples}/local-application/package.json +0 -0
  93. /package/docs/{pi-desk/examples → examples}/local-application/pi-desk.ts +0 -0
  94. /package/temp/build/pi-desk/release/.next/static/{1LdtGYUckBhYZy0R-6yG0 → -pemk30NXpiExEU5tjb1t}/_buildManifest.js +0 -0
  95. /package/temp/build/pi-desk/release/.next/static/{1LdtGYUckBhYZy0R-6yG0 → -pemk30NXpiExEU5tjb1t}/_ssgManifest.js +0 -0
@@ -6,13 +6,21 @@ import {
6
6
  CopyIcon,
7
7
  FileCode2Icon,
8
8
  FoldVerticalIcon,
9
+ GitCompareArrowsIcon,
9
10
  ListOrderedIcon,
10
11
  LoaderCircleIcon,
11
12
  RefreshCwIcon,
12
13
  SearchIcon,
13
14
  WrapTextIcon
14
15
  } from 'lucide-react'
15
- import { useEffect, useImperativeHandle, useRef, useSyncExternalStore, type Ref } from 'react'
16
+ import {
17
+ useEffect,
18
+ useImperativeHandle,
19
+ useRef,
20
+ useState,
21
+ useSyncExternalStore,
22
+ type Ref
23
+ } from 'react'
16
24
  import { useTranslation } from 'react-i18next'
17
25
  import { copyL4BrowserText } from '@client/l4_foundation/lib/l4-browser-clipboard'
18
26
  import {
@@ -25,6 +33,8 @@ import { L4ImageViewer } from '@client/l4_foundation/ui/l4-image-viewer'
25
33
  import { useL4AppToast } from '@client/l4_foundation/ui/l4-app-toast'
26
34
  import { Button } from '@client/l4_foundation/ui/shadcn/button'
27
35
  import type { L3ProjectFileRuntime } from './l3-project-file-runtime'
36
+ import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
37
+ import { useL3ProjectDocumentGit } from './l3-project-document-git'
28
38
  import { L3CodeSurfaceToolbarButton } from './l3-code-surface-toolbar-button'
29
39
  import { buildL3FilePreviewHtml } from './l3-file-preview-html'
30
40
 
@@ -38,13 +48,17 @@ export interface L3ProjectDocumentHandle {
38
48
  export function L3ProjectDocumentView({
39
49
  ref: handleRef,
40
50
  runtime,
51
+ gitRuntime,
41
52
  compact,
53
+ visible = true,
42
54
  onEscape,
43
55
  onSwitchPane
44
56
  }: {
45
57
  ref?: Ref<L3ProjectDocumentHandle>
46
58
  runtime: L3ProjectFileRuntime
59
+ gitRuntime: L3ProjectGitBrowser
47
60
  compact: boolean
61
+ visible?: boolean
48
62
  onEscape: () => void
49
63
  onSwitchPane?: () => void
50
64
  }): React.JSX.Element {
@@ -55,6 +69,7 @@ export function L3ProjectDocumentView({
55
69
  const document = !activeDiff && activePath ? runtime.state.documents.get(activePath) : null
56
70
  const isHtml = Boolean(activePath && /\.html?$/i.test(activePath))
57
71
  const showSource = document?.status === 'text' && (!isHtml || document.viewMode === 'source')
72
+ const hostRef = useRef<HTMLDivElement>(null)
58
73
  const previewRef = useRef<L4CodePreviewHandle>(null)
59
74
  const diffRef = useRef<L4CodeDiffHandle>(null)
60
75
  const iframeRef = useRef<HTMLIFrameElement>(null)
@@ -83,6 +98,42 @@ export function L3ProjectDocumentView({
83
98
  if (activePath && previewRef.current)
84
99
  runtime.saveEditorState(activePath, previewRef.current.getViewState())
85
100
  }
101
+ const { refresh, unavailable } = useL3ProjectDocumentGit(
102
+ runtime,
103
+ gitRuntime,
104
+ hostRef,
105
+ savePosition,
106
+ visible
107
+ )
108
+ const comparison = document?.status === 'text' ? document.comparison : undefined
109
+ const [computed, setComputed] = useState<{
110
+ original: string
111
+ modified: string
112
+ count: number | null
113
+ } | null>(null)
114
+ const count =
115
+ comparison?.status === 'ready' &&
116
+ document?.status === 'text' &&
117
+ computed?.original === comparison.original.content &&
118
+ computed.modified === document.content
119
+ ? computed.count
120
+ : undefined
121
+ const openDiff = (line?: number): void => {
122
+ if (!activePath) return
123
+ savePosition()
124
+ runtime.openFileDiff(activePath, line)
125
+ }
126
+ const gitStatus =
127
+ unavailable || comparison?.status === 'error'
128
+ ? t('gitMarkersFailed')
129
+ : comparison?.status === 'unavailable' || (comparison?.status === 'ready' && count === null)
130
+ ? t('gitMarkersUnavailable')
131
+ : comparison?.status === 'loading' ||
132
+ (comparison?.status === 'ready' && count === undefined)
133
+ ? t('gitMarkersLoading')
134
+ : comparison?.status === 'ready'
135
+ ? t('gitChangesCount', { count })
136
+ : null
86
137
  const copyText = (): void => {
87
138
  if (document?.status !== 'text') return
88
139
  const selection = previewRef.current?.getSelectionText() ?? ''
@@ -93,6 +144,7 @@ export function L3ProjectDocumentView({
93
144
  }
94
145
  return (
95
146
  <div
147
+ ref={hostRef}
96
148
  className="flex size-full min-h-0 min-w-0 flex-col outline-none"
97
149
  tabIndex={-1}
98
150
  onFocus={(event) => {
@@ -181,6 +233,41 @@ export function L3ProjectDocumentView({
181
233
  ))}
182
234
  </div>
183
235
  ) : null}
236
+ {showSource && gitStatus ? (
237
+ <span
238
+ data-testid="file-git-status"
239
+ role="status"
240
+ className="mr-auto min-w-0 truncate text-xs text-muted-foreground"
241
+ title={comparison?.status === 'error' ? comparison.error : gitStatus}
242
+ >
243
+ {compact && comparison?.status === 'ready' && typeof count === 'number'
244
+ ? t('gitChangesCompact', { count })
245
+ : gitStatus}
246
+ </span>
247
+ ) : null}
248
+ {showSource && comparison?.status === 'ready' ? (
249
+ <>
250
+ {typeof count === 'number' && count > 0 ? (
251
+ <>
252
+ <L3CodeSurfaceToolbarButton
253
+ label={t('previousChange')}
254
+ onClick={() => previewRef.current?.previousChange()}
255
+ >
256
+ <ArrowUpIcon className="size-4" />
257
+ </L3CodeSurfaceToolbarButton>
258
+ <L3CodeSurfaceToolbarButton
259
+ label={t('nextChange')}
260
+ onClick={() => previewRef.current?.nextChange()}
261
+ >
262
+ <ArrowDownIcon className="size-4" />
263
+ </L3CodeSurfaceToolbarButton>
264
+ </>
265
+ ) : null}
266
+ <L3CodeSurfaceToolbarButton label={t('viewDiff')} onClick={() => openDiff()}>
267
+ <GitCompareArrowsIcon className="size-4" />
268
+ </L3CodeSurfaceToolbarButton>
269
+ </>
270
+ ) : null}
184
271
  {showSource && activePath ? (
185
272
  <>
186
273
  <L3CodeSurfaceToolbarButton
@@ -210,10 +297,7 @@ export function L3ProjectDocumentView({
210
297
  </>
211
298
  ) : null}
212
299
  {activePath ? (
213
- <L3CodeSurfaceToolbarButton
214
- label={t('reloadCurrentFile')}
215
- onClick={() => runtime.reloadFile(activePath)}
216
- >
300
+ <L3CodeSurfaceToolbarButton label={t('reloadCurrentFile')} onClick={refresh}>
217
301
  <RefreshCwIcon className="size-4" />
218
302
  </L3CodeSurfaceToolbarButton>
219
303
  ) : null}
@@ -231,6 +315,7 @@ export function L3ProjectDocumentView({
231
315
  modified={activeDiff.modified}
232
316
  viewMode={compact ? 'inline' : diffViewMode}
233
317
  savedState={activeDiff.editorState}
318
+ initialLine={activeDiff.initialLine}
234
319
  onSaveState={(position) => runtime.saveDiffState(activeDiff, position)}
235
320
  onEscape={escape}
236
321
  />
@@ -302,6 +387,18 @@ export function L3ProjectDocumentView({
302
387
  path={activePath}
303
388
  content={document.content}
304
389
  size={document.size}
390
+ originalContent={
391
+ comparison?.status === 'ready' ? comparison.original.content : undefined
392
+ }
393
+ onChanges={(count) => {
394
+ if (comparison?.status === 'ready')
395
+ setComputed({
396
+ original: comparison.original.content,
397
+ modified: document.content,
398
+ count
399
+ })
400
+ }}
401
+ onOpenDiff={openDiff}
305
402
  savedState={document.editorState}
306
403
  onSaveState={(path, state) => runtime.saveEditorState(path, state)}
307
404
  onEscape={escape}
@@ -39,6 +39,7 @@ import {
39
39
  import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
40
40
  import {
41
41
  getL3GitChangeBadge,
42
+ getL3GitChangeClass,
42
43
  getL3GitHeadLabel,
43
44
  getL3GitRepositoryName,
44
45
  joinL3GitProjectPath,
@@ -118,12 +119,7 @@ function fileIcon(path: string): React.JSX.Element {
118
119
 
119
120
  function changedFileNameClass(change: L3GitProjectChange | null): string | undefined {
120
121
  if (!change) return undefined
121
- const badge = getL3GitChangeBadge(change.change)
122
- if (badge === 'D') return 'text-muted-foreground'
123
- if (badge === '?') return 'text-red-600 dark:text-red-400'
124
- if (badge === 'A') return 'text-emerald-600 dark:text-emerald-400'
125
- if (badge === '!') return 'text-violet-600 dark:text-violet-400'
126
- return 'text-blue-600 dark:text-blue-400'
122
+ return getL3GitChangeClass(change.change)
127
123
  }
128
124
 
129
125
  function DirectoryFeedback({
@@ -22,6 +22,16 @@ export type L3DirectoryState =
22
22
  | { status: 'idle' | 'loading' }
23
23
  | { status: 'ready'; entries: L4ProjectFileEntry[]; truncated: boolean }
24
24
  | { status: 'error'; error: string }
25
+ export type L3FileComparison =
26
+ | { status: 'loading' }
27
+ | {
28
+ status: 'ready'
29
+ repositoryRoot: string
30
+ path: string
31
+ original: { path: string; content: string }
32
+ }
33
+ | { status: 'unavailable' }
34
+ | { status: 'error'; error: string }
25
35
  export type L3FileDocument =
26
36
  | { status: 'unloaded' }
27
37
  | { status: 'loading' }
@@ -31,6 +41,7 @@ export type L3FileDocument =
31
41
  size: number
32
42
  editorState: L4CodePreviewState | null
33
43
  viewMode: 'source' | 'preview'
44
+ comparison?: L3FileComparison
34
45
  }
35
46
  | { status: 'image'; mimeType: L4ProjectFileImageMimeType; size: number; objectUrl: string }
36
47
  | { status: 'error'; error: string }
@@ -47,6 +58,7 @@ interface DiffOptions {
47
58
  comparison?: L4GitHistoricalDiff
48
59
  editorState?: L4CodeDiffState | null
49
60
  label?: string
61
+ initialLine?: number
50
62
  }
51
63
  export type L3FileDiff = DiffOptions &
52
64
  (
@@ -492,6 +504,71 @@ export class L3ProjectFileRuntime {
492
504
  }
493
505
  this.publish()
494
506
  }
507
+ async compareFile(
508
+ path: string,
509
+ target: { repositoryRoot: string; path: string } | null,
510
+ signal: AbortSignal
511
+ ): Promise<void> {
512
+ const document = this.state.documents.get(path)
513
+ if (this.disposed || signal.aborted || document?.status !== 'text') return
514
+ if (!target) {
515
+ if (document.comparison) {
516
+ delete document.comparison
517
+ this.publish()
518
+ }
519
+ return
520
+ }
521
+ const loading: L3FileComparison = { status: 'loading' }
522
+ document.comparison = loading
523
+ this.publish()
524
+ const current = (): boolean =>
525
+ !this.disposed &&
526
+ !signal.aborted &&
527
+ this.state.documents.get(path) === document &&
528
+ document.comparison === loading
529
+ try {
530
+ const result = await this.reader.diff(target.repositoryRoot, target.path, signal)
531
+ if (!current()) return
532
+ if (result.kind === 'text') {
533
+ // 比较结果的工作区正文与基线一起替换,不能把新行号画在缓存正文上。
534
+ document.content = result.modified.content
535
+ document.size = new TextEncoder().encode(result.modified.content).length
536
+ document.comparison = {
537
+ status: 'ready',
538
+ repositoryRoot: target.repositoryRoot,
539
+ path: target.path,
540
+ original: result.original
541
+ }
542
+ } else {
543
+ document.comparison = { status: 'unavailable' }
544
+ }
545
+ } catch (cause) {
546
+ if (!current()) return
547
+ console.warn('[Pi Desk][ProjectFile] 读取行变更失败', {
548
+ cwd: this.cwd,
549
+ path,
550
+ error: message(cause)
551
+ })
552
+ document.comparison = { status: 'error', error: message(cause) }
553
+ }
554
+ this.publish()
555
+ }
556
+ openFileDiff(path: string, initialLine?: number): void {
557
+ const document = this.state.documents.get(path)
558
+ if (this.disposed || document?.status !== 'text' || document.comparison?.status !== 'ready')
559
+ return
560
+ const comparison = document.comparison
561
+ this.clearDiff()
562
+ this.state.activeDiff = {
563
+ status: 'text',
564
+ repositoryRoot: comparison.repositoryRoot,
565
+ path: comparison.path,
566
+ original: comparison.original,
567
+ modified: { path: comparison.path, content: document.content },
568
+ initialLine
569
+ }
570
+ this.publish()
571
+ }
495
572
  openDiff(
496
573
  repositoryRoot: string,
497
574
  path: string,
@@ -616,7 +693,12 @@ export class L3ProjectFileRuntime {
616
693
  private invalidateDocument(path: string): void {
617
694
  this.documentRequests.get(path)?.abort()
618
695
  this.documentRequests.delete(path)
619
- this.releaseDocument(this.state.documents.get(path))
696
+ const document = this.state.documents.get(path)
697
+ if (document?.status === 'text') {
698
+ if (document.editorState) this.restoredEditorStates.set(path, document.editorState)
699
+ this.restoredFileViewModes.set(path, document.viewMode)
700
+ }
701
+ this.releaseDocument(document)
620
702
  this.state.documents.set(path, { status: 'unloaded' })
621
703
  }
622
704
  private releaseDocument(document: L3FileDocument | undefined): void {
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { ChevronDownIcon, ChevronUpIcon, FileCode2Icon, XIcon } from 'lucide-react'
4
- import { useLayoutEffect, useRef, useState } from 'react'
4
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
6
  import { cn } from '@client/l4_foundation/lib/l4-utils'
7
7
  import { Button } from '@client/l4_foundation/ui/shadcn/button'
@@ -13,8 +13,12 @@ import {
13
13
  } from '@client/l4_foundation/ui/shadcn/context-menu'
14
14
  import type { L3FileTabCloseScope } from './l3-project-file-runtime'
15
15
  import { L3FileContextMenuContent } from './l3-file-context-menu-content'
16
+ import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
17
+ import { findL3GitFile, getL3GitChangeBadge, getL3GitChangeClass } from './l3-project-git-model'
16
18
 
17
19
  interface L3ProjectFileTabsProps {
20
+ cwd: string
21
+ gitRuntime: L3ProjectGitBrowser
18
22
  paths: readonly string[]
19
23
  activePath: string | null
20
24
  diffPath: string | null
@@ -39,6 +43,8 @@ function fileLabel(path: string, paths: readonly string[]): string {
39
43
  }
40
44
 
41
45
  export function L3ProjectFileTabs({
46
+ cwd,
47
+ gitRuntime,
42
48
  paths,
43
49
  activePath,
44
50
  diffPath,
@@ -47,6 +53,8 @@ export function L3ProjectFileTabs({
47
53
  onClose
48
54
  }: L3ProjectFileTabsProps): React.JSX.Element | null {
49
55
  const { t } = useTranslation('projectFiles')
56
+ useSyncExternalStore(gitRuntime.subscribe, gitRuntime.getRevision, gitRuntime.getRevision)
57
+ const snapshot = gitRuntime.getWorkspace(cwd)?.snapshot
50
58
  const rowsRef = useRef<HTMLDivElement>(null)
51
59
  const suppressTouchClickRef = useRef(false)
52
60
  const [expanded, setExpanded] = useState(false)
@@ -98,6 +106,10 @@ export function L3ProjectFileTabs({
98
106
  {tabs.map((tab, index) => {
99
107
  const clipped = !expanded && clippedIndexes.includes(index)
100
108
  const selected = tab.path === active
109
+ const change =
110
+ tab.path && snapshot?.status === 'ready'
111
+ ? findL3GitFile(cwd, tab.path, snapshot.repositories)?.change
112
+ : undefined
101
113
  const closeLabel =
102
114
  tab.path === null
103
115
  ? t('closeDiff', { name: tab.title.split('/').at(-1) })
@@ -186,7 +198,17 @@ export function L3ProjectFileTabs({
186
198
  }}
187
199
  >
188
200
  <FileCode2Icon className="size-3.5 shrink-0" />
189
- <span className="truncate">{tab.label}</span>
201
+ <span className={cn('truncate', change && getL3GitChangeClass(change))}>
202
+ {tab.label}
203
+ </span>
204
+ {change ? (
205
+ <span
206
+ className={cn('shrink-0 text-xs', getL3GitChangeClass(change))}
207
+ aria-label={t('gitStatus', { status: getL3GitChangeBadge(change) })}
208
+ >
209
+ {getL3GitChangeBadge(change)}
210
+ </span>
211
+ ) : null}
190
212
  </button>
191
213
  <Button
192
214
  variant="ghost"
@@ -131,6 +131,38 @@ export function getL3GitChangeBadge(change: L4GitFileChange): string {
131
131
  return '?'
132
132
  }
133
133
 
134
+ export function getL3GitChangeClass(change: L4GitFileChange): string {
135
+ const badge = getL3GitChangeBadge(change)
136
+ if (badge === 'D') return 'text-muted-foreground'
137
+ if (badge === '?') return 'text-red-600 dark:text-red-400'
138
+ if (badge === 'A') return 'text-emerald-600 dark:text-emerald-400'
139
+ if (badge === '!') return 'text-violet-600 dark:text-violet-400'
140
+ return 'text-blue-600 dark:text-blue-400'
141
+ }
142
+
143
+ export function findL3GitFile(
144
+ cwd: string,
145
+ path: string,
146
+ repositories: readonly L4GitRepository[]
147
+ ): { repository: L4GitRepository; path: string; change: L4GitFileChange | undefined } | null {
148
+ if (/^(?:[a-z]:|\/|\\\\)/i.test(path)) return null
149
+ const windows = /^[a-z]:[\\/]/i.test(cwd) || cwd.startsWith('\\\\')
150
+ const key = (value: string): string => (windows ? value.toLowerCase() : value)
151
+ const repository = [...repositories]
152
+ .sort((left, right) => right.root.length - left.root.length)
153
+ .find((item) => !item.root || key(path).startsWith(`${key(item.root)}/`))
154
+ if (!repository) return null
155
+ const relative = repository.root ? path.slice(repository.root.length + 1) : path
156
+ return {
157
+ repository,
158
+ path: relative,
159
+ change:
160
+ repository.state === 'ready'
161
+ ? repository.changes.find((item) => key(item.path) === key(relative))
162
+ : undefined
163
+ }
164
+ }
165
+
134
166
  export function listL3GitProjectChanges(
135
167
  repositories: readonly L4GitRepository[]
136
168
  ): L3GitProjectChange[] {
@@ -44,10 +44,11 @@ interface L4CodeDiffProps {
44
44
  onEscape?: () => void
45
45
  savedState?: L4CodeDiffState | null
46
46
  onSaveState?: (state: L4CodeDiffState | null) => void
47
+ initialLine?: number
47
48
  }
48
49
 
49
50
  function L4CodeDiffComponent(
50
- { original, modified, viewMode, onEscape, savedState, onSaveState }: L4CodeDiffProps,
51
+ { original, modified, viewMode, onEscape, savedState, onSaveState, initialLine }: L4CodeDiffProps,
51
52
  ref: ForwardedRef<L4CodeDiffHandle>
52
53
  ): React.JSX.Element {
53
54
  const { t } = useTranslation('common')
@@ -58,12 +59,14 @@ function L4CodeDiffComponent(
58
59
  const escapeRef = useRef(onEscape)
59
60
  const savedStateRef = useRef(savedState)
60
61
  const saveStateRef = useRef(onSaveState)
62
+ const initialLineRef = useRef(initialLine)
61
63
 
62
64
  useEffect(() => {
63
65
  escapeRef.current = onEscape
64
66
  savedStateRef.current = savedState
65
67
  saveStateRef.current = onSaveState
66
- }, [onEscape, onSaveState, savedState])
68
+ initialLineRef.current = initialLine
69
+ }, [initialLine, onEscape, onSaveState, savedState])
67
70
  const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
68
71
  const [changeCount, setChangeCount] = useState<number | null>(null)
69
72
 
@@ -130,6 +133,7 @@ function L4CodeDiffComponent(
130
133
  useInlineViewWhenSpaceIsLimited: false,
131
134
  enableSplitViewResizing: true,
132
135
  diffAlgorithm: 'advanced',
136
+ ignoreTrimWhitespace: false,
133
137
  maxComputationTime: 5_000,
134
138
  hideUnchangedRegions: initialSettings.collapseUnchanged
135
139
  ? {
@@ -156,13 +160,29 @@ function L4CodeDiffComponent(
156
160
  contextmenu: true,
157
161
  ariaLabel: t('gitDiffReadOnly')
158
162
  })
159
- diffEditor.setModel({ original: originalModel, modified: modifiedModel })
163
+ const requestedLine = initialLineRef.current
164
+ let initialPositionApplied = false
160
165
  scrollListeners.push(
161
166
  diffEditor.onDidUpdateDiff(() => {
162
- if (active) setChangeCount(diffEditor?.getLineChanges()?.length ?? null)
167
+ if (!active || !diffEditor) return
168
+ const changes = diffEditor.getLineChanges()
169
+ setChangeCount(changes?.length ?? null)
170
+ if (!initialPositionApplied && changes !== null && requestedLine !== undefined) {
171
+ initialPositionApplied = true
172
+ const lineNumber = Math.max(
173
+ 1,
174
+ Math.min(requestedLine, modifiedModel?.getLineCount() ?? 1)
175
+ )
176
+ const modifiedEditor = diffEditor.getModifiedEditor()
177
+ modifiedEditor.setPosition({ lineNumber, column: 1 })
178
+ modifiedEditor.revealLineInCenter(lineNumber)
179
+ }
163
180
  })
164
181
  )
165
- if (savedStateRef.current) diffEditor.restoreViewState(savedStateRef.current)
182
+ diffEditor.setModel({ original: originalModel, modified: modifiedModel })
183
+ if (requestedLine === undefined && savedStateRef.current) {
184
+ diffEditor.restoreViewState(savedStateRef.current)
185
+ }
166
186
  const save = (): void => saveStateRef.current?.(diffEditor?.saveViewState() ?? null)
167
187
  scrollListeners.push(
168
188
  diffEditor.getOriginalEditor().onDidScrollChange(save),
@@ -0,0 +1,49 @@
1
+ import { DefaultLinesDiffComputer } from 'monaco-editor/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer'
2
+
3
+ export interface L4LineChange {
4
+ kind: 'added' | 'modified' | 'deleted'
5
+ originalStartLineNumber: number
6
+ originalEndLineNumberExclusive: number
7
+ modifiedStartLineNumber: number
8
+ modifiedEndLineNumberExclusive: number
9
+ }
10
+
11
+ /** 行号从 1 开始,结束行不包含在范围内;纯删除的 modified 范围为空。 */
12
+ export function computeL4LineChanges(original: string, modified: string): L4LineChange[] | null {
13
+ if (original.length + modified.length > 2 * 1024 * 1024) return null
14
+ const originalLines = original.split(/\r\n|\r|\n/)
15
+ const modifiedLines = modified.split(/\r\n|\r|\n/)
16
+ if (originalLines.length + modifiedLines.length > 50_000) return null
17
+
18
+ try {
19
+ const startedAt = Date.now()
20
+ const result = new DefaultLinesDiffComputer().computeDiff(originalLines, modifiedLines, {
21
+ ignoreTrimWhitespace: false,
22
+ maxComputationTimeMs: 500,
23
+ computeMoves: false
24
+ })
25
+ if (result.hitTimeout || Date.now() - startedAt > 500) return null
26
+ return result.changes.map(({ original: before, modified: after }) => {
27
+ // Monaco 的空模型占一行,但空文件不应被归类为替换了这一行。
28
+ const originalEnd = original === '' ? 1 : before.endLineNumberExclusive
29
+ const modifiedEnd = modified === '' ? 1 : after.endLineNumberExclusive
30
+ return {
31
+ kind:
32
+ before.startLineNumber === originalEnd
33
+ ? 'added'
34
+ : after.startLineNumber === modifiedEnd
35
+ ? 'deleted'
36
+ : 'modified',
37
+ originalStartLineNumber: before.startLineNumber,
38
+ originalEndLineNumberExclusive: originalEnd,
39
+ modifiedStartLineNumber: after.startLineNumber,
40
+ modifiedEndLineNumberExclusive: modifiedEnd
41
+ }
42
+ })
43
+ } catch (error: unknown) {
44
+ console.warn('[Pi Desk][CodePreview] 行差异计算失败', {
45
+ errorName: error instanceof Error ? error.name : 'UnknownError'
46
+ })
47
+ return null
48
+ }
49
+ }
@@ -0,0 +1,10 @@
1
+ import { computeL4LineChanges, type L4LineChange } from './l4-code-line-changes'
2
+
3
+ const scope = self as unknown as {
4
+ onmessage: ((event: MessageEvent<{ original: string; modified: string }>) => void) | null
5
+ postMessage: (changes: L4LineChange[] | null) => void
6
+ }
7
+
8
+ scope.onmessage = (event): void => {
9
+ scope.postMessage(computeL4LineChanges(event.data.original, event.data.modified))
10
+ }
@@ -0,0 +1,60 @@
1
+ .host {
2
+ --git-added: #16803c;
3
+ --git-modified: #216bcc;
4
+ --git-deleted: #d12c38;
5
+ }
6
+
7
+ :global(.dark) .host {
8
+ --git-added: #59c77b;
9
+ --git-modified: #6aabff;
10
+ --git-deleted: #ff757f;
11
+ }
12
+
13
+ .added,
14
+ .modified,
15
+ .deletedBefore,
16
+ .deletedAfter {
17
+ /* 实际移动命中区,保持点击相对元素左缘小于 4px,避免触发 Monaco 折叠。 */
18
+ margin-left: 8px;
19
+ width: 4px !important;
20
+ z-index: 1;
21
+ cursor: pointer;
22
+ }
23
+
24
+ .added::before,
25
+ .modified::before {
26
+ position: absolute;
27
+ top: 0;
28
+ bottom: 0;
29
+ left: 0;
30
+ width: 3px;
31
+ content: '';
32
+ }
33
+
34
+ .added::before {
35
+ background: var(--git-added);
36
+ }
37
+
38
+ .modified::before {
39
+ background: var(--git-modified);
40
+ }
41
+
42
+ .deletedBefore::before,
43
+ .deletedAfter::before {
44
+ position: absolute;
45
+ left: 0;
46
+ width: 0;
47
+ height: 0;
48
+ border-top: 4px solid transparent;
49
+ border-bottom: 4px solid transparent;
50
+ border-left: 4px solid var(--git-deleted);
51
+ content: '';
52
+ }
53
+
54
+ .deletedBefore::before {
55
+ top: -3px;
56
+ }
57
+
58
+ .deletedAfter::before {
59
+ bottom: -3px;
60
+ }