@jetcrab/pi-desk 1.1.6-dev → 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 (90) 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/l3_modules/project-files/l3-project-document-git.ts +82 -0
  11. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +102 -5
  12. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +2 -6
  13. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +83 -1
  14. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +24 -2
  15. package/src/client/l3_modules/project-files/l3-project-git-model.ts +32 -0
  16. package/src/client/l4_foundation/ui/code/l4-code-diff.tsx +25 -5
  17. package/src/client/l4_foundation/ui/code/l4-code-line-changes.ts +49 -0
  18. package/src/client/l4_foundation/ui/code/l4-code-line-changes.worker.ts +10 -0
  19. package/src/client/l4_foundation/ui/code/l4-code-line-markers.module.css +60 -0
  20. package/src/client/l4_foundation/ui/code/l4-code-line-markers.ts +156 -0
  21. package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +61 -5
  22. package/src/client/l4_foundation/ui/code/l4-monaco-lines-diff.d.ts +21 -0
  23. package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +16 -0
  24. package/src/server/l4_foundation/pidesk/l4-pidesk-runtime.ts +1 -1
  25. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  26. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  27. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +5 -4
  28. package/temp/build/pi-desk/release/.next/required-server-files.js +2 -2
  29. package/temp/build/pi-desk/release/.next/required-server-files.json +2 -2
  30. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  31. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  32. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  33. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  34. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  35. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  36. package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
  37. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  38. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  39. package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
  40. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  41. package/temp/build/pi-desk/release/.next/server/app/page.js +3 -3
  42. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/temp/build/pi-desk/release/.next/server/chunks/20842.js +1 -0
  44. package/temp/build/pi-desk/release/.next/server/chunks/25406.js +1 -1
  45. package/temp/build/pi-desk/release/.next/server/chunks/{9248.js → 354.js} +1 -1
  46. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +6 -6
  47. package/temp/build/pi-desk/release/.next/server/chunks/45671.js +1 -0
  48. package/temp/build/pi-desk/release/.next/server/chunks/47757.js +1 -1
  49. package/temp/build/pi-desk/release/.next/server/chunks/56284.js +62 -0
  50. package/temp/build/pi-desk/release/.next/server/chunks/73691.js +1 -0
  51. package/temp/build/pi-desk/release/.next/server/chunks/82499.js +2 -2
  52. package/temp/build/pi-desk/release/.next/server/chunks/94296.js +2 -2
  53. package/temp/build/pi-desk/release/.next/server/chunks/pi-desk-line-changes.js +1 -0
  54. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  55. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  56. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  57. package/temp/build/pi-desk/release/.next/server/webpack-runtime.js +1 -1
  58. package/temp/build/pi-desk/release/.next/static/chunks/28739.540a4f24a8eaa3fd.js +1 -0
  59. package/temp/build/pi-desk/release/.next/static/chunks/31087-333cae396a52f232.js +1 -0
  60. package/temp/build/pi-desk/release/.next/static/chunks/3814-35cfab0ce3aa7c41.js +1 -0
  61. package/temp/build/pi-desk/release/.next/static/chunks/38459.9a9b6f4f3ec3ff50.js +1 -0
  62. package/temp/build/pi-desk/release/.next/static/chunks/45992-a39a014497ec4dd1.js +13 -0
  63. package/temp/build/pi-desk/release/.next/static/chunks/53291.ab6ef49016107124.js +1 -0
  64. package/temp/build/pi-desk/release/.next/static/chunks/{54206.82a63ab971911eda.js → 54206.ffb8c581c6661ae2.js} +2 -2
  65. package/temp/build/pi-desk/release/.next/static/chunks/5598.f34dce2a9de0d0e3.js +1 -0
  66. package/temp/build/pi-desk/release/.next/static/chunks/70669.8b4231a8675499e0.js +1 -0
  67. package/temp/build/pi-desk/release/.next/static/chunks/95287.34b860abf26f64a5.js +1 -0
  68. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-586f6f88483051a7.js +3 -0
  69. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-83ed165c9a3835c1.js → page-0f2cf68a4666420b.js} +3 -3
  70. package/temp/build/pi-desk/release/.next/static/chunks/pi-desk-line-changes.49f8d59c256621d6.js +1 -0
  71. package/temp/build/pi-desk/release/.next/static/chunks/webpack-26261285e181235a.js +1 -0
  72. package/temp/build/pi-desk/release/.next/static/css/{f6a76a493aae9ae3.css → adb1d4e48dd43c0a.css} +1 -1
  73. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  74. package/docs/pi-desk/README.md +0 -47
  75. package/temp/build/pi-desk/release/.next/server/chunks/6656.js +0 -1
  76. package/temp/build/pi-desk/release/.next/server/chunks/89236.js +0 -62
  77. package/temp/build/pi-desk/release/.next/static/chunks/19991.02fcb7c8ca3b1fe4.js +0 -1
  78. package/temp/build/pi-desk/release/.next/static/chunks/26285.54b8bcbf7a198c84.js +0 -1
  79. package/temp/build/pi-desk/release/.next/static/chunks/52354-6461358c522130be.js +0 -1
  80. package/temp/build/pi-desk/release/.next/static/chunks/74132-876f256c7a6101af.js +0 -13
  81. package/temp/build/pi-desk/release/.next/static/chunks/79615.7d68b269d93fcb93.js +0 -1
  82. package/temp/build/pi-desk/release/.next/static/chunks/app/layout-4faf3a52d219296d.js +0 -3
  83. package/temp/build/pi-desk/release/.next/static/chunks/webpack-7bfbf627379fb4c1.js +0 -1
  84. /package/docs/{pi-desk/commands.md → commands.md} +0 -0
  85. /package/docs/{pi-desk/examples → examples}/local-application/browser/application.js +0 -0
  86. /package/docs/{pi-desk/examples → examples}/local-application/browser/entry.js +0 -0
  87. /package/docs/{pi-desk/examples → examples}/local-application/package.json +0 -0
  88. /package/docs/{pi-desk/examples → examples}/local-application/pi-desk.ts +0 -0
  89. /package/temp/build/pi-desk/release/.next/static/{NwX_o5zDfv-NABRLmLIjY → -pemk30NXpiExEU5tjb1t}/_buildManifest.js +0 -0
  90. /package/temp/build/pi-desk/release/.next/static/{NwX_o5zDfv-NABRLmLIjY → -pemk30NXpiExEU5tjb1t}/_ssgManifest.js +0 -0
@@ -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
+ }
@@ -0,0 +1,156 @@
1
+ import type { editor } from 'monaco-editor'
2
+ import type { L4Monaco } from './l4-monaco-runtime'
3
+ import type { L4LineChange } from './l4-code-line-changes'
4
+ import styles from './l4-code-line-markers.module.css'
5
+
6
+ export interface L4CodeLineMarkers {
7
+ previousChange: () => void
8
+ nextChange: () => void
9
+ dispose: () => void
10
+ }
11
+
12
+ export function bindL4CodeLineMarkers(
13
+ codeEditor: editor.IStandaloneCodeEditor,
14
+ monaco: L4Monaco,
15
+ input: {
16
+ path: string
17
+ original: string
18
+ modified: string
19
+ tooltip: (kind: L4LineChange['kind'], count: number) => string
20
+ onChanges: (count: number | null) => void
21
+ onOpenDiff: (lineNumber: number) => void
22
+ }
23
+ ): L4CodeLineMarkers {
24
+ const decorations = codeEditor.createDecorationsCollection()
25
+ const lineCount = codeEditor.getModel()?.getLineCount() ?? 1
26
+ let changes: L4LineChange[] = []
27
+ let active = true
28
+ let worker: Worker | null = null
29
+ let timeout: ReturnType<typeof setTimeout> | null = null
30
+ const markerSelector = [styles.added, styles.modified, styles.deletedBefore, styles.deletedAfter]
31
+ .map((className) => `.${className}`)
32
+ .join(',')
33
+
34
+ const markerLine = (change: L4LineChange): number =>
35
+ Math.min(
36
+ lineCount,
37
+ change.kind === 'deleted'
38
+ ? Math.max(1, change.modifiedStartLineNumber - 1)
39
+ : change.modifiedStartLineNumber
40
+ )
41
+
42
+ const stopWorker = (): void => {
43
+ if (timeout !== null) clearTimeout(timeout)
44
+ timeout = null
45
+ worker?.terminate()
46
+ worker = null
47
+ }
48
+
49
+ const finish = (result: L4LineChange[] | null): void => {
50
+ if (!active) return
51
+ stopWorker()
52
+ changes = result ?? []
53
+ decorations.set(
54
+ changes.map((change): editor.IModelDeltaDecoration => {
55
+ const deleted = change.kind === 'deleted'
56
+ const startLine = markerLine(change)
57
+ const endLine = deleted ? startLine : change.modifiedEndLineNumberExclusive - 1
58
+ const count = deleted
59
+ ? change.originalEndLineNumberExclusive - change.originalStartLineNumber
60
+ : change.modifiedEndLineNumberExclusive - change.modifiedStartLineNumber
61
+ const colors = {
62
+ added: ['#16803c', '#59c77b'],
63
+ modified: ['#216bcc', '#6aabff'],
64
+ deleted: ['#d12c38', '#ff757f']
65
+ }[change.kind]
66
+ const markerClass = deleted
67
+ ? change.modifiedStartLineNumber === 1
68
+ ? styles.deletedBefore
69
+ : styles.deletedAfter
70
+ : styles[change.kind]
71
+ return {
72
+ range: new monaco.Range(startLine, 1, endLine, 1),
73
+ options: {
74
+ isWholeLine: !deleted,
75
+ showIfCollapsed: true,
76
+ linesDecorationsClassName: deleted ? undefined : markerClass,
77
+ firstLineDecorationClassName: deleted ? markerClass : undefined,
78
+ linesDecorationsTooltip: input.tooltip(change.kind, count),
79
+ overviewRuler: {
80
+ color: colors[0],
81
+ darkColor: colors[1],
82
+ position: monaco.editor.OverviewRulerLane.Left
83
+ }
84
+ }
85
+ }
86
+ })
87
+ )
88
+ if (result === null) {
89
+ console.warn('[Pi Desk][CodePreview] 行标记不可用,计算失败或超过限制', {
90
+ path: input.path
91
+ })
92
+ }
93
+ input.onChanges(result?.length ?? null)
94
+ }
95
+
96
+ const mouseListener = codeEditor.onMouseDown((event) => {
97
+ if (
98
+ event.target.type !== monaco.editor.MouseTargetType.GUTTER_LINE_DECORATIONS ||
99
+ !event.target.element?.closest(markerSelector)
100
+ ) {
101
+ return
102
+ }
103
+ const line = event.target.position?.lineNumber
104
+ if (line === undefined) return
105
+ const change = changes.find((item) =>
106
+ item.kind === 'deleted'
107
+ ? markerLine(item) === line
108
+ : item.modifiedStartLineNumber <= line && line < item.modifiedEndLineNumberExclusive
109
+ )
110
+ if (change) input.onOpenDiff(line)
111
+ })
112
+
113
+ try {
114
+ worker = new Worker(new URL('./l4-code-line-changes.worker.ts', import.meta.url), {
115
+ type: 'module',
116
+ name: 'pi-desk-line-changes'
117
+ })
118
+ worker.onmessage = (event: MessageEvent<L4LineChange[] | null>): void => finish(event.data)
119
+ worker.onerror = (): void => finish(null)
120
+ worker.onmessageerror = (): void => finish(null)
121
+ // 含 Worker 启动预算;取消时终止 Worker,旧文件结果没有回流通道。
122
+ timeout = setTimeout(() => finish(null), 3_000)
123
+ worker.postMessage({ original: input.original, modified: input.modified })
124
+ } catch {
125
+ finish(null)
126
+ }
127
+
128
+ const navigate = (direction: 'previous' | 'next'): void => {
129
+ if (changes.length === 0) return
130
+ const currentLine = codeEditor.getPosition()?.lineNumber ?? 1
131
+ const lines = changes.map(markerLine)
132
+ const line =
133
+ direction === 'next'
134
+ ? (lines.find((item) => item > currentLine) ?? lines[0])
135
+ : (lines.findLast((item) => item < currentLine) ?? lines[lines.length - 1])
136
+ codeEditor.setPosition({ lineNumber: line, column: 1 })
137
+ codeEditor.revealLineInCenter(line)
138
+ codeEditor.focus()
139
+ }
140
+
141
+ return {
142
+ previousChange(): void {
143
+ navigate('previous')
144
+ },
145
+ nextChange(): void {
146
+ navigate('next')
147
+ },
148
+ dispose(): void {
149
+ active = false
150
+ stopWorker()
151
+ mouseListener.dispose()
152
+ decorations.clear()
153
+ changes = []
154
+ }
155
+ }
156
+ }
@@ -23,6 +23,8 @@ import {
23
23
 
24
24
  import { bindL4MonacoPreviewEscape } from './l4-monaco-preview-escape'
25
25
  import { useL4CodePreferences } from './l4-code-preferences'
26
+ import { bindL4CodeLineMarkers, type L4CodeLineMarkers } from './l4-code-line-markers'
27
+ import markerStyles from './l4-code-line-markers.module.css'
26
28
 
27
29
  export type L4CodePreviewState = editor.ICodeEditorViewState
28
30
 
@@ -32,6 +34,8 @@ export interface L4CodePreviewHandle {
32
34
  getSelectionText: () => string
33
35
  getViewState: () => L4CodePreviewState | null
34
36
  focus: () => void
37
+ previousChange: () => void
38
+ nextChange: () => void
35
39
  }
36
40
 
37
41
  interface L4CodePreviewProps {
@@ -41,13 +45,27 @@ interface L4CodePreviewProps {
41
45
  savedState: L4CodePreviewState | null
42
46
  onSaveState: (path: string, state: L4CodePreviewState | null) => void
43
47
  onEscape?: () => void
48
+ originalContent?: string
49
+ onChanges?: (count: number | null) => void
50
+ onOpenDiff?: (lineNumber: number) => void
44
51
  }
45
52
 
46
53
  function L4CodePreviewComponent(
47
- { path, content, size, savedState, onSaveState, onEscape }: L4CodePreviewProps,
54
+ {
55
+ path,
56
+ content,
57
+ size,
58
+ savedState,
59
+ onSaveState,
60
+ onEscape,
61
+ originalContent,
62
+ onChanges,
63
+ onOpenDiff
64
+ }: L4CodePreviewProps,
48
65
  ref: ForwardedRef<L4CodePreviewHandle>
49
66
  ): React.JSX.Element {
50
67
  const { t } = useTranslation('common')
68
+ const { t: projectFilesT } = useTranslation('projectFiles')
51
69
  const { wrapLines } = useL4CodePreferences()
52
70
  const hostRef = useRef<HTMLDivElement>(null)
53
71
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
@@ -55,11 +73,16 @@ function L4CodePreviewComponent(
55
73
  const wrapLinesRef = useRef(wrapLines)
56
74
  const savedStateRef = useRef(savedState)
57
75
  const escapeRef = useRef(onEscape)
76
+ const markersRef = useRef<L4CodeLineMarkers | null>(null)
77
+ const changesRef = useRef(onChanges)
78
+ const openDiffRef = useRef(onOpenDiff)
58
79
 
59
80
  useEffect(() => {
60
81
  savedStateRef.current = savedState
61
82
  escapeRef.current = onEscape
62
- }, [onEscape, savedState])
83
+ changesRef.current = onChanges
84
+ openDiffRef.current = onOpenDiff
85
+ }, [onChanges, onEscape, onOpenDiff, savedState])
63
86
  const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
64
87
 
65
88
  useEffect(() => {
@@ -90,6 +113,12 @@ function L4CodePreviewComponent(
90
113
  },
91
114
  focus(): void {
92
115
  editorRef.current?.focus()
116
+ },
117
+ previousChange(): void {
118
+ markersRef.current?.previousChange()
119
+ },
120
+ nextChange(): void {
121
+ markersRef.current?.nextChange()
93
122
  }
94
123
  }),
95
124
  []
@@ -101,6 +130,7 @@ function L4CodePreviewComponent(
101
130
  let active = true
102
131
  let codeEditor: editor.IStandaloneCodeEditor | null = null
103
132
  let model: editor.ITextModel | null = null
133
+ let markers: L4CodeLineMarkers | null = null
104
134
  let stopThemeObserver = (): void => undefined
105
135
  let stopTypographyObserver = (): void => undefined
106
136
 
@@ -131,6 +161,7 @@ function L4CodePreviewComponent(
131
161
  wrappingIndent: 'same',
132
162
  ...getL4MonacoTypography(),
133
163
  lineNumbersMinChars: 3,
164
+ lineDecorationsWidth: 26,
134
165
  folding: size <= 1024 * 1024,
135
166
  renderValidationDecorations: 'off',
136
167
  renderWhitespace: 'selection',
@@ -145,6 +176,25 @@ function L4CodePreviewComponent(
145
176
  if (escapeRef.current)
146
177
  bindL4MonacoPreviewEscape(codeEditor, monaco.KeyCode.Escape, () => escapeRef.current?.())
147
178
  editorRef.current = codeEditor
179
+ if (originalContent !== undefined) {
180
+ markers = bindL4CodeLineMarkers(codeEditor, monaco, {
181
+ path,
182
+ original: originalContent,
183
+ modified: content,
184
+ tooltip: (kind, count) =>
185
+ projectFilesT(
186
+ {
187
+ added: 'gitAddedLines',
188
+ modified: 'gitModifiedLines',
189
+ deleted: 'gitDeletedLines'
190
+ }[kind],
191
+ { count }
192
+ ),
193
+ onChanges: (count) => changesRef.current?.(count),
194
+ onOpenDiff: (lineNumber) => openDiffRef.current?.(lineNumber)
195
+ })
196
+ markersRef.current = markers
197
+ }
148
198
  stopThemeObserver = observeL4MonacoTheme((theme) => monaco.editor.setTheme(theme))
149
199
  stopTypographyObserver = observeL4MonacoTypography((typography) =>
150
200
  codeEditor?.updateOptions(typography)
@@ -158,18 +208,24 @@ function L4CodePreviewComponent(
158
208
  errorName: error instanceof Error ? error.name : 'UnknownError'
159
209
  })
160
210
  setLoadState('error')
211
+ if (originalContent !== undefined) changesRef.current?.(null)
161
212
  })
162
213
 
163
214
  return () => {
164
215
  active = false
165
216
  stopThemeObserver()
166
217
  stopTypographyObserver()
167
- if (codeEditor) onSaveStateRef.current(path, codeEditor.saveViewState())
218
+ markers?.dispose()
219
+ markersRef.current = null
220
+ if (codeEditor) {
221
+ savedStateRef.current = codeEditor.saveViewState()
222
+ onSaveStateRef.current(path, savedStateRef.current)
223
+ }
168
224
  editorRef.current = null
169
225
  codeEditor?.dispose()
170
226
  model?.dispose()
171
227
  }
172
- }, [content, path, size, t])
228
+ }, [content, originalContent, path, projectFilesT, size, t])
173
229
 
174
230
  useEffect(() => {
175
231
  editorRef.current?.updateOptions({ wordWrap: wrapLines ? 'on' : 'off' })
@@ -180,7 +236,7 @@ function L4CodePreviewComponent(
180
236
  data-testid="code-preview"
181
237
  className="relative h-full min-h-0 w-full overflow-hidden bg-background"
182
238
  >
183
- <div ref={hostRef} className="size-full" />
239
+ <div ref={hostRef} className={`size-full ${markerStyles.host}`} />
184
240
  {loadState === 'loading' ? (
185
241
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center gap-2 bg-background text-sm text-muted-foreground">
186
242
  <LoaderCircleIcon className="size-4 animate-spin" /> {t('codeLoading')}
@@ -0,0 +1,21 @@
1
+ declare module 'monaco-editor/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' {
2
+ interface LineRange {
3
+ startLineNumber: number
4
+ endLineNumberExclusive: number
5
+ }
6
+
7
+ export class DefaultLinesDiffComputer {
8
+ computeDiff(
9
+ originalLines: string[],
10
+ modifiedLines: string[],
11
+ options: {
12
+ ignoreTrimWhitespace: boolean
13
+ maxComputationTimeMs: number
14
+ computeMoves: boolean
15
+ }
16
+ ): {
17
+ changes: { original: LineRange; modified: LineRange }[]
18
+ hitTimeout: boolean
19
+ }
20
+ }
21
+ }
@@ -45,6 +45,14 @@ export const L3_PROJECT_FILES_LOCALE_MESSAGES = {
45
45
  copyContentFailed: 'Could not copy the file. Please copy it manually.',
46
46
  fileActions: 'File actions',
47
47
  headToWorking: 'HEAD ↔ working tree',
48
+ gitChangesCount: 'Against HEAD · {{count}} changes',
49
+ gitChangesCompact: '{{count}} changes',
50
+ gitMarkersLoading: 'Comparing with HEAD…',
51
+ gitMarkersFailed: 'Could not read changes · Reload to retry',
52
+ gitMarkersUnavailable: 'Line change markers unavailable',
53
+ gitAddedLines: '{{count}} lines added',
54
+ gitModifiedLines: '{{count}} lines modified',
55
+ gitDeletedLines: '{{count}} lines deleted',
48
56
  noDiffChanges: ' · No content differences',
49
57
  previousChange: 'Previous change',
50
58
  nextChange: 'Next change',
@@ -131,6 +139,14 @@ export const L3_PROJECT_FILES_LOCALE_MESSAGES = {
131
139
  copyContentFailed: '复制文件内容失败,请手动复制。',
132
140
  fileActions: '文件操作',
133
141
  headToWorking: 'HEAD ↔ 工作区',
142
+ gitChangesCount: '相对 HEAD · {{count}} 处变更',
143
+ gitChangesCompact: '{{count}} 处变更',
144
+ gitMarkersLoading: '正在与 HEAD 比较…',
145
+ gitMarkersFailed: '变更读取失败 · 刷新重试',
146
+ gitMarkersUnavailable: '此文件无法显示行变更',
147
+ gitAddedLines: '新增 {{count}} 行',
148
+ gitModifiedLines: '修改 {{count}} 行',
149
+ gitDeletedLines: '删除 {{count}} 行',
134
150
  noDiffChanges: ' · 当前比较无内容差异',
135
151
  previousChange: '上一个变更',
136
152
  nextChange: '下一个变更',
@@ -60,7 +60,7 @@ export async function initializeL4PiDeskCommands(
60
60
  const sdkRoot = dirname(
61
61
  dirname(createRequire(join(hostRoot, 'package.json')).resolve('@jetcrab/pi-desk-sdk'))
62
62
  )
63
- const docsDirectory = join(hostRoot, 'docs', 'pi-desk')
63
+ const docsDirectory = join(hostRoot, 'docs')
64
64
  await Promise.all(
65
65
  ['README.md', 'commands.md', 'plugins.md'].map((file) => access(join(docsDirectory, file)))
66
66
  )
@@ -1 +1 @@
1
- NwX_o5zDfv-NABRLmLIjY
1
+ -pemk30NXpiExEU5tjb1t