@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
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { ChevronRightIcon, Columns2Icon, FolderIcon, FolderOpenIcon } from 'lucide-react'
4
- import type { KeyboardEvent } from 'react'
4
+ import { useRef, type KeyboardEvent } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
6
  import type { L4GitChanges } from '@common/l4_foundation/git/l4-git-history-contract'
7
7
  import type { L3ProjectRevealTargetType } from '@common/l3_modules/project-files/l3-project-files-contract'
@@ -28,6 +28,7 @@ interface Props {
28
28
  changes: L4GitChanges['items']
29
29
  selectedPath: string | null
30
30
  scrollTop: number
31
+ rowHeight: number
31
32
  onScroll: (value: number) => void
32
33
  onSelect: (path: string) => void
33
34
  onToggle: (path: string) => void
@@ -42,6 +43,7 @@ export function L2ProjectChangeTree({
42
43
  changes,
43
44
  selectedPath,
44
45
  scrollTop,
46
+ rowHeight,
45
47
  onScroll,
46
48
  onSelect,
47
49
  onToggle,
@@ -49,6 +51,12 @@ export function L2ProjectChangeTree({
49
51
  onReveal
50
52
  }: Props): React.JSX.Element {
51
53
  const { t } = useTranslation('projectHistory')
54
+ const pendingFocus = useRef<string | null>(null)
55
+ const focusRow = (element: HTMLElement | null, key: string): void => {
56
+ if (!element || pendingFocus.current !== key) return
57
+ pendingFocus.current = null
58
+ element.focus({ preventScroll: true })
59
+ }
52
60
  const changesByPath = new Map(changes.map((change) => [change.path, change]))
53
61
  const keyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
54
62
  if (event.defaultPrevented || !event.currentTarget.contains(event.target as Node)) return
@@ -84,14 +92,17 @@ export function L2ProjectChangeTree({
84
92
  const target = rows[next]
85
93
  if (!target) return
86
94
  const viewport = event.currentTarget
87
- if (next * 36 < viewport.scrollTop) viewport.scrollTop = next * 36
88
- else if ((next + 1) * 36 > viewport.scrollTop + viewport.clientHeight)
89
- viewport.scrollTop = (next + 1) * 36 - viewport.clientHeight
90
- requestAnimationFrame(() => {
91
- ;[...viewport.querySelectorAll<HTMLElement>('[data-tree-key]')]
92
- .find((item) => item.dataset.treeKey === target.key)
93
- ?.focus()
94
- })
95
+ if (next * rowHeight < viewport.scrollTop) viewport.scrollTop = next * rowHeight
96
+ else if ((next + 1) * rowHeight > viewport.scrollTop + viewport.clientHeight)
97
+ viewport.scrollTop = (next + 1) * rowHeight - viewport.clientHeight
98
+ pendingFocus.current = target.key
99
+ // 虚拟行可能尚未挂载,未命中时由该行的 ref 在提交后接续聚焦。
100
+ focusRow(
101
+ [...viewport.querySelectorAll<HTMLElement>('[data-tree-key]')].find(
102
+ (item) => item.dataset.treeKey === target.key
103
+ ) ?? null,
104
+ target.key
105
+ )
95
106
  }
96
107
 
97
108
  return (
@@ -100,6 +111,7 @@ export function L2ProjectChangeTree({
100
111
  label={t('changeFiles')}
101
112
  items={rows}
102
113
  scrollTop={scrollTop}
114
+ rowHeight={rowHeight}
103
115
  onScroll={onScroll}
104
116
  onKeyDown={keyDown}
105
117
  render={(row) => {
@@ -125,6 +137,7 @@ export function L2ProjectChangeTree({
125
137
  onReveal={onReveal}
126
138
  trigger={
127
139
  <div
140
+ ref={(element) => focusRow(element, row.key)}
128
141
  role="treeitem"
129
142
  tabIndex={0}
130
143
  aria-level={row.depth + 1}
@@ -132,10 +145,13 @@ export function L2ProjectChangeTree({
132
145
  aria-selected={row.type === 'file' && selectedPath === row.path}
133
146
  data-tree-key={row.key}
134
147
  className={cn(
135
- 'flex h-9 min-w-0 cursor-pointer items-center gap-2 pr-2 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
148
+ 'flex min-w-0 cursor-pointer items-center gap-1 pr-1 text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
136
149
  row.type === 'file' && selectedPath === row.path && 'bg-accent'
137
150
  )}
138
- style={{ paddingLeft: 8 + row.depth * 16 }}
151
+ style={{
152
+ height: rowHeight,
153
+ paddingLeft: 4 + row.depth * (rowHeight === 24 ? 12 : 24)
154
+ }}
139
155
  title={
140
156
  change?.oldPath && row.type === 'file'
141
157
  ? `${joinL3ProjectPath(repositoryRoot, change.oldPath)} → ${projectPath}`
@@ -190,6 +206,7 @@ export function L2ProjectChangeTree({
190
206
  <Button
191
207
  size="icon-sm"
192
208
  variant="ghost"
209
+ className={rowHeight === 24 ? 'size-6' : undefined}
193
210
  aria-label={t('viewDiffPath', { path: row.path })}
194
211
  onDoubleClick={(event) => event.stopPropagation()}
195
212
  onClick={(event) => {
@@ -1,5 +1,12 @@
1
1
  'use client'
2
- import { useLayoutEffect, useRef, useState, type ReactNode, type KeyboardEventHandler } from 'react'
2
+ import {
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ type KeyboardEventHandler
9
+ } from 'react'
3
10
 
4
11
  export function L2ProjectHistoryList<T>({
5
12
  items,
@@ -8,7 +15,10 @@ export function L2ProjectHistoryList<T>({
8
15
  onScroll,
9
16
  render,
10
17
  role = 'listbox',
11
- onKeyDown
18
+ onKeyDown,
19
+ rowHeight = 36,
20
+ onNearEnd,
21
+ footer
12
22
  }: {
13
23
  items: readonly T[]
14
24
  label: string
@@ -17,6 +27,9 @@ export function L2ProjectHistoryList<T>({
17
27
  render: (item: T, index: number) => ReactNode
18
28
  role?: 'listbox' | 'tree'
19
29
  onKeyDown?: KeyboardEventHandler<HTMLDivElement>
30
+ rowHeight?: number
31
+ onNearEnd?: () => void
32
+ footer?: ReactNode
20
33
  }): React.JSX.Element {
21
34
  const viewport = useRef<HTMLDivElement>(null)
22
35
  const [window, setWindow] = useState({ top: scrollTop, height: 300 })
@@ -33,10 +46,23 @@ export function L2ProjectHistoryList<T>({
33
46
  // eslint-disable-next-line react-hooks/exhaustive-deps
34
47
  }, [])
35
48
  useLayoutEffect(() => {
36
- if (viewport.current) viewport.current.scrollTop = scrollTop
37
- }, [items, scrollTop])
38
- const start = Math.max(0, Math.floor(window.top / 36) - 8)
39
- const end = Math.min(items.length, Math.ceil((window.top + window.height) / 36) + 8)
49
+ const element = viewport.current
50
+ if (!element) return
51
+ element.scrollTop = scrollTop
52
+ setWindow((previous) =>
53
+ previous.top === element.scrollTop ? previous : { ...previous, top: element.scrollTop }
54
+ )
55
+ }, [items, scrollTop, rowHeight])
56
+ useEffect(() => {
57
+ if (
58
+ items.length &&
59
+ window.height > 0 &&
60
+ items.length * rowHeight - window.top - window.height <= 200
61
+ )
62
+ onNearEnd?.()
63
+ }, [items.length, rowHeight, window.top, window.height, onNearEnd])
64
+ const start = Math.max(0, Math.floor(window.top / rowHeight) - 8)
65
+ const end = Math.min(items.length, Math.ceil((window.top + window.height) / rowHeight) + 8)
40
66
  return (
41
67
  <div
42
68
  ref={viewport}
@@ -51,11 +77,12 @@ export function L2ProjectHistoryList<T>({
51
77
  onScroll(top)
52
78
  }}
53
79
  >
54
- <div style={{ height: items.length * 36, position: 'relative' }}>
55
- <div style={{ position: 'absolute', top: start * 36, left: 0, right: 0 }}>
80
+ <div style={{ height: items.length * rowHeight, position: 'relative' }}>
81
+ <div style={{ position: 'absolute', top: start * rowHeight, left: 0, right: 0 }}>
56
82
  {items.slice(start, end).map((item, index) => render(item, start + index))}
57
83
  </div>
58
84
  </div>
85
+ {footer}
59
86
  </div>
60
87
  )
61
88
  }
@@ -18,6 +18,7 @@ import { cn } from '@client/l4_foundation/lib/l4-utils'
18
18
  import type { L2ProjectHistoryState } from '../runtime/l2-project-history-runtime'
19
19
  import { L2ProjectHistoryList } from './l2-project-history-list'
20
20
  import { L2ProjectChangeTree } from './l2-project-change-tree'
21
+ import { L2ProjectBranchSelect } from './l2-project-branch-select'
21
22
  import type { L3PathTreeRow } from '@client/l3_modules/project-files/l3-project-path-tree'
22
23
  import type { L3ProjectRevealTargetType } from '@common/l3_modules/project-files/l3-project-files-contract'
23
24
 
@@ -25,6 +26,7 @@ interface Props {
25
26
  state: L2ProjectHistoryState
26
27
  cwd: string
27
28
  changeRows: readonly L3PathTreeRow[]
29
+ changeRowHeight: number
28
30
  onSelectChange: (path: string) => void
29
31
  onToggleDirectory: (path: string) => void
30
32
  onReveal: (path: string, type: L3ProjectRevealTargetType) => Promise<void>
@@ -86,30 +88,17 @@ export function L2ProjectHistoryView(props: Props): React.JSX.Element {
86
88
  const data = state.changes.data
87
89
  const context = `${state.repositoryRoot}:${data?.comparison.original}:${data?.comparison.target}`
88
90
  const selectedPath = state.selectedChangePath
89
- const refs = [
90
- { value: 'HEAD', label: t('currentHead') },
91
- ...(state.branches.data?.local ?? []).map((name) => ({
92
- value: `refs/heads/${name}`,
93
- label: name,
94
- description: t('localBranch')
95
- })),
96
- ...(state.branches.data?.remote ?? []).map((name) => ({
97
- value: `refs/remotes/${name}`,
98
- label: name,
99
- description: t('remoteBranch')
100
- }))
101
- ]
102
91
  const upstream = state.branches.data?.upstream
103
92
  const commit = data?.commit
104
93
  const chooseCommit = (oid: string): void => {
105
94
  props.onCommit(oid)
106
95
  if (narrow) setMobilePane('files')
107
96
  }
108
- const feedback = (error: string | null): React.JSX.Element | null =>
97
+ const feedback = (error: string | null, retry = props.onRefresh): React.JSX.Element | null =>
109
98
  error ? (
110
99
  <div role="alert" className="flex flex-wrap items-center gap-2 p-3 text-sm text-destructive">
111
100
  <span className="min-w-0 flex-1 break-words">{error}</span>
112
- <Button size="sm" variant="outline" onClick={props.onRefresh}>
101
+ <Button size="sm" variant="outline" onClick={retry}>
113
102
  {t('retry')}
114
103
  </Button>
115
104
  {state.mode === 'compare' && state.strategy === 'merge-base' ? (
@@ -122,19 +111,47 @@ export function L2ProjectHistoryView(props: Props): React.JSX.Element {
122
111
 
123
112
  const log = (
124
113
  <div className="flex size-full min-h-0 min-w-0 flex-col">
125
- {feedback(state.log.error)}
126
- {state.log.status === 'loading' ? (
127
- <div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
128
- <LoaderCircleIcon className="size-4 animate-spin" />
129
- {t('loadingCommits')}
130
- </div>
131
- ) : null}
132
114
  <L2ProjectHistoryList
133
115
  key={`${state.repositoryRoot}:${state.mode}:${state.tip}:${state.base}:${state.target}:${state.query}:${state.author}`}
134
116
  items={state.log.data?.items ?? []}
135
117
  label={t('commitList')}
136
118
  scrollTop={scroll.log}
137
119
  onScroll={(value) => props.onScroll('log', value)}
120
+ onNearEnd={
121
+ state.log.status === 'ready' &&
122
+ state.log.data?.hasMore &&
123
+ state.log.data.items.length < 2000
124
+ ? props.onMoreLog
125
+ : undefined
126
+ }
127
+ footer={
128
+ <>
129
+ {feedback(state.log.error, state.log.data ? props.onMoreLog : props.onRefresh)}
130
+ {state.log.status === 'loading' ? (
131
+ <div
132
+ role="status"
133
+ className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
134
+ >
135
+ <LoaderCircleIcon className="size-4 animate-spin" />
136
+ {t('loadingCommits')}
137
+ </div>
138
+ ) : null}
139
+ {state.log.status === 'ready' && !state.log.data?.items.length ? (
140
+ <p className="px-3 py-4 text-sm text-muted-foreground">
141
+ {state.query || state.author ? t('noMatchingCommits') : t('noCommits')}
142
+ </p>
143
+ ) : null}
144
+ {state.log.data?.truncated ? (
145
+ <p className="px-3 py-2 text-xs text-muted-foreground">{t('scanLimit')}</p>
146
+ ) : (state.log.data?.items.length ?? 0) >= 2000 ? (
147
+ <p className="px-3 py-2 text-xs text-muted-foreground">{t('loadedLimit')}</p>
148
+ ) : state.log.status === 'ready' &&
149
+ state.log.data?.items.length &&
150
+ !state.log.data.hasMore ? (
151
+ <p className="px-3 py-2 text-xs text-muted-foreground">{t('noMoreCommits')}</p>
152
+ ) : null}
153
+ </>
154
+ }
138
155
  render={(item) => (
139
156
  <button
140
157
  key={item.oid}
@@ -168,26 +185,6 @@ export function L2ProjectHistoryView(props: Props): React.JSX.Element {
168
185
  </button>
169
186
  )}
170
187
  />
171
- {state.log.status === 'ready' && !state.log.data?.items.length ? (
172
- <p className="px-3 py-4 text-sm text-muted-foreground">
173
- {state.query || state.author ? t('noMatchingCommits') : t('noCommits')}
174
- </p>
175
- ) : null}
176
- {state.log.data?.truncated ? (
177
- <p className="px-3 py-2 text-xs text-muted-foreground">{t('scanLimit')}</p>
178
- ) : null}
179
- {(state.log.data?.items.length ?? 0) >= 2000 ? (
180
- <p className="px-3 py-2 text-xs text-muted-foreground">{t('loadedLimit')}</p>
181
- ) : state.log.data?.hasMore ? (
182
- <Button
183
- variant="ghost"
184
- size="sm"
185
- disabled={state.log.status === 'loading'}
186
- onClick={props.onMoreLog}
187
- >
188
- {t('loadMoreCommits')}
189
- </Button>
190
- ) : null}
191
188
  </div>
192
189
  )
193
190
 
@@ -247,6 +244,7 @@ export function L2ProjectHistoryView(props: Props): React.JSX.Element {
247
244
  cwd={props.cwd}
248
245
  repositoryRoot={state.repositoryRoot ?? ''}
249
246
  rows={props.changeRows}
247
+ rowHeight={props.changeRowHeight}
250
248
  changes={data?.items ?? []}
251
249
  selectedPath={selectedPath}
252
250
  scrollTop={scroll.changes}
@@ -387,29 +385,29 @@ export function L2ProjectHistoryView(props: Props): React.JSX.Element {
387
385
  onChange={props.onRepository}
388
386
  />
389
387
  {state.mode === 'history' ? (
390
- <L4SearchSelect
388
+ <L2ProjectBranchSelect
391
389
  className="w-48"
392
390
  ariaLabel={t('viewBranch')}
393
391
  value={state.tip}
394
- options={refs}
392
+ branches={state.branches.data}
395
393
  onChange={(value) => props.onRevision('tip', value)}
396
394
  />
397
395
  ) : (
398
396
  <>
399
- <L4SearchSelect
397
+ <L2ProjectBranchSelect
400
398
  className="w-44"
401
399
  ariaLabel={t('baseBranch')}
402
400
  placeholder={t('chooseBase')}
403
401
  value={state.base}
404
- options={refs}
402
+ branches={state.branches.data}
405
403
  onChange={(value) => props.onRevision('base', value)}
406
404
  />
407
405
  <span className="text-xs text-muted-foreground">→</span>
408
- <L4SearchSelect
406
+ <L2ProjectBranchSelect
409
407
  className="w-44"
410
408
  ariaLabel={t('targetBranch')}
411
409
  value={state.target}
412
- options={refs}
410
+ branches={state.branches.data}
413
411
  onChange={(value) => props.onRevision('target', value)}
414
412
  />
415
413
  <L4SearchSelect
@@ -9,6 +9,7 @@ import { readL3ImagePreviewMode } from '@client/l3_modules/preferences/l3-work-s
9
9
  import {
10
10
  L3ProjectFileRuntime,
11
11
  type L3ProjectFileState,
12
+ type L3FileTabTarget,
12
13
  type L3FileTabCloseScope
13
14
  } from '@client/l3_modules/project-files/l3-project-file-runtime'
14
15
  import type { L2WorkSessionGitBiz } from '../git-workspace/l2-work-session-git-biz'
@@ -222,7 +223,7 @@ export class L2FileWorkspaceRuntime {
222
223
  closeFile(id: string, path: string): void {
223
224
  this.closeTabs(id, path, 'current')
224
225
  }
225
- closeTabs(id: string, target: string | null, scope: L3FileTabCloseScope): void {
226
+ closeTabs(id: string, target: L3FileTabTarget | null, scope: L3FileTabCloseScope): void {
226
227
  this.workspaces.get(id)?.files.closeTabs(target, scope)
227
228
  }
228
229
  hideWindow(id: string): void {
@@ -214,9 +214,13 @@ export function L2FileWorkspaceView({
214
214
  gitRuntime={gitRuntime}
215
215
  paths={workspace.tabs}
216
216
  activePath={activePath}
217
- diffPath={diffPath}
217
+ diffs={workspace.diffs}
218
+ activeDiff={activeDiff}
218
219
  mobile={mobile}
219
- onActivate={(path) => runtime.activateFile(workId, path)}
220
+ onActivate={(target) => {
221
+ if (typeof target === 'string') runtime.activateFile(workId, target)
222
+ else runtime.openDiff(workId, target.repositoryRoot, target.path)
223
+ }}
220
224
  onClose={(path, scope) => runtime.closeTabs(workId, path, scope)}
221
225
  />
222
226
  <L3ProjectDocumentView
@@ -235,37 +235,47 @@ export function L3ConversationTailSpacerObserver({ active = true }: { active?: b
235
235
  scheduleRefresh()
236
236
  })
237
237
  let previousHeight: number | undefined
238
+ let previousWidth: number | undefined
238
239
  let viewportDifference = 0
239
240
  let resizeReleaseFrame: number | null = null
240
241
  let resizeReleaseTimer: number | null = null
241
242
  const viewportObserver = new ResizeObserver(([entry]) => {
242
- const height = entry?.contentRect.height
243
- if (height === undefined) return
243
+ if (!entry) return
244
+ const { height, width } = entry.contentRect
244
245
  const difference = height - (previousHeight ?? height)
246
+ const widthDifference = width - (previousWidth ?? width)
245
247
  previousHeight = height
246
- if (!difference) {
248
+ previousWidth = width
249
+ if (!difference && !widthDifference) {
247
250
  scheduleRefresh()
248
251
  return
249
252
  }
250
253
 
251
254
  logL3ConversationScroll('viewport:resize', scrollElement, {
252
255
  difference,
256
+ widthDifference,
253
257
  isAtBottom: state.isAtBottom,
254
258
  escapedFromLock: state.escapedFromLock
255
259
  })
256
- // 库只观察内容高度;视口缩放引起的滚动也不能被当成用户上翻。
257
- viewportDifference = difference
258
- state.resizeDifference = difference
260
+ // 库只观察内容高度;仅宽度变化时保留库标记的内容高度差。
261
+ if (difference) {
262
+ viewportDifference = difference
263
+ state.resizeDifference = difference
264
+ }
259
265
  if (refreshFrame !== null) {
260
266
  window.cancelAnimationFrame(refreshFrame)
261
267
  refreshFrame = null
262
268
  }
263
- // 视口变高时浏览器可能已截短 scrollTop;绘制前补齐留白并同步跟底,避免下一帧再弹回。
269
+ // 宽度重排和高度变化都在绘制前同步跟底;库的 instant 仍会等到下一帧。
264
270
  refreshL3ConversationTailSpacer(scrollElement)
265
271
  if (state.isAtBottom) {
266
272
  state.scrollTop = Math.max(0, state.calculatedTargetScrollTop)
267
273
  }
268
- logL3ConversationScroll('viewport:resize:positioned', scrollElement, { difference })
274
+ logL3ConversationScroll('viewport:resize:positioned', scrollElement, {
275
+ difference,
276
+ widthDifference
277
+ })
278
+ if (!difference) return
269
279
  if (resizeReleaseFrame !== null) window.cancelAnimationFrame(resizeReleaseFrame)
270
280
  if (resizeReleaseTimer !== null) window.clearTimeout(resizeReleaseTimer)
271
281
  resizeReleaseFrame = window.requestAnimationFrame(() => {
@@ -33,7 +33,7 @@ import { useL4CodePreferences } from '@client/l4_foundation/ui/code/l4-code-pref
33
33
  import { L4ImageViewer } from '@client/l4_foundation/ui/l4-image-viewer'
34
34
  import { useL4AppToast } from '@client/l4_foundation/ui/l4-app-toast'
35
35
  import { Button } from '@client/l4_foundation/ui/shadcn/button'
36
- import type { L3ProjectFileRuntime } from './l3-project-file-runtime'
36
+ import { getL3FileDiffKey, type L3ProjectFileRuntime } from './l3-project-file-runtime'
37
37
  import type { L3ProjectGitBrowser } from './l3-project-git-runtime'
38
38
  import { useL3ProjectDocumentGit } from './l3-project-document-git'
39
39
  import { L3CodeSurfaceToolbarButton } from './l3-code-surface-toolbar-button'
@@ -158,7 +158,10 @@ export function L3ProjectDocumentView({
158
158
  {activeDiff ? (
159
159
  <>
160
160
  <span className="mr-auto shrink-0 px-1 text-xs text-muted-foreground">
161
- {activeDiff.label ?? t('headToWorking')}
161
+ {activeDiff.label ??
162
+ (activeDiff.comparison
163
+ ? `${activeDiff.comparison.baseCommit?.slice(0, 7) ?? '∅'} → ${activeDiff.comparison.targetCommit.slice(0, 7)}`
164
+ : t('headToWorking'))}
162
165
  {activeDiff.status === 'text' &&
163
166
  activeDiff.original.content === activeDiff.modified.content
164
167
  ? t('noDiffChanges')
@@ -333,7 +336,7 @@ export function L3ProjectDocumentView({
333
336
  {activeDiff ? (
334
337
  activeDiff.status === 'text' ? (
335
338
  <L4CodeDiff
336
- key={`${activeDiff.repositoryRoot}:${activeDiff.path}`}
339
+ key={getL3FileDiffKey(activeDiff)}
337
340
  ref={diffRef}
338
341
  original={activeDiff.original}
339
342
  modified={activeDiff.modified}
@@ -352,7 +355,7 @@ export function L3ProjectDocumentView({
352
355
  {activeDiff.modifiedMetadata}
353
356
  </pre>
354
357
  </div>
355
- ) : activeDiff.status === 'loading' ? (
358
+ ) : activeDiff.status === 'loading' || activeDiff.status === 'unloaded' ? (
356
359
  <Loading />
357
360
  ) : (
358
361
  <div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center text-sm">