@jetcrab/pi-desk 1.1.24-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.
- package/package.json +2 -2
- package/src/app/project-preview/page.tsx +13 -0
- package/src/client/l1_entry/pages/l1-project-preview-page.tsx +1 -5
- package/src/client/l2_biz/project-preview/hooks/l2-use-project-preview.ts +25 -0
- package/src/client/l2_biz/project-preview/l2-project-preview-storage.ts +7 -7
- package/src/client/l2_biz/project-preview/l2-project-preview.tsx +8 -5
- package/src/client/l2_biz/project-preview/runtime/l2-project-history-runtime.ts +14 -11
- package/src/client/l2_biz/project-preview/views/l2-project-branch-select.tsx +347 -0
- package/src/client/l2_biz/project-preview/views/l2-project-change-tree.tsx +28 -11
- package/src/client/l2_biz/project-preview/views/l2-project-history-list.tsx +35 -8
- package/src/client/l2_biz/project-preview/views/l2-project-history-view.tsx +46 -48
- package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +2 -1
- package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +6 -2
- package/src/client/l3_modules/conversation/l3-conversation-viewport.tsx +18 -8
- package/src/client/l3_modules/project-files/l3-project-document-view.tsx +7 -4
- package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +181 -89
- package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +80 -28
- package/src/common/l2_biz/project-preview/l2-project-history-messages.ts +4 -2
- package/src/common/l2_biz/project-preview/l2-project-preview-navigation.ts +14 -0
- package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
- package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
- package/temp/build/pi-desk/release/.next/required-server-files.js +3 -3
- package/temp/build/pi-desk/release/.next/required-server-files.json +3 -3
- package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/page.js +2 -2
- package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/project-preview/page.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/project-preview/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/27344.js +15 -0
- package/temp/build/pi-desk/release/.next/server/chunks/35985.js +1 -1
- package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
- package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
- package/temp/build/pi-desk/release/.next/static/chunks/15302-6e070e13fa65c863.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/19991.bcca96d396b83030.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/65232-ef4e5a4174f62faf.js +15 -0
- package/temp/build/pi-desk/release/.next/static/chunks/app/{page-ff44dcf27c2ba1c2.js → page-18d63fd3920dfae0.js} +3 -3
- package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-038b6c14e03ed878.js +1 -0
- package/temp/build/pi-desk/release/.next/static/chunks/{webpack-48eecbb66781c006.js → webpack-c0b805df46fcf8b3.js} +1 -1
- package/temp/build/pi-desk/release/.next/static/css/c43b04c244a64fb4.css +1 -0
- package/temp/build/pi-desk/release/.next/trace-build +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/20460.js +0 -15
- package/temp/build/pi-desk/release/.next/static/chunks/15302-9c807afc6c60de0c.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/19991.8aa2e4b6df15fad6.js +0 -1
- package/temp/build/pi-desk/release/.next/static/chunks/9038-05fd2688407b39c1.js +0 -15
- package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-bc18765121950e65.js +0 -1
- package/temp/build/pi-desk/release/.next/static/css/10e063cfb3cda7bd.css +0 -1
- /package/temp/build/pi-desk/release/.next/static/{w3CeIboVupqJdq890TlcG → 6a4eIEjmpW5l8Qlgtzzzg}/_buildManifest.js +0 -0
- /package/temp/build/pi-desk/release/.next/static/{w3CeIboVupqJdq890TlcG → 6a4eIEjmpW5l8Qlgtzzzg}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jetcrab/pi-desk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.25",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"viewerjs": "^1.11.8",
|
|
71
71
|
"ws": "^8.21.3",
|
|
72
72
|
"zod": "^4.4.3",
|
|
73
|
-
"@jetcrab/pi-desk-sdk": "^1.1.
|
|
73
|
+
"@jetcrab/pi-desk-sdk": "^1.1.10"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@earendil-works/pi-agent-core": "1.2.0",
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { Suspense } from 'react'
|
|
2
|
+
import type { Metadata } from 'next'
|
|
2
3
|
import { L1ProjectPreviewPage } from '@client/l1_entry/pages/l1-project-preview-page'
|
|
4
|
+
import { buildL2ProjectPreviewTitle } from '@common/l2_biz/project-preview/l2-project-preview-navigation'
|
|
5
|
+
import { getL4HostSettingsStore } from '@server/l4_foundation/l4-host-settings-store'
|
|
6
|
+
|
|
7
|
+
export async function generateMetadata({
|
|
8
|
+
searchParams
|
|
9
|
+
}: {
|
|
10
|
+
searchParams: Promise<{ cwd?: string | string[] }>
|
|
11
|
+
}): Promise<Metadata> {
|
|
12
|
+
const { cwd } = await searchParams
|
|
13
|
+
const { region } = getL4HostSettingsStore().getSnapshot()
|
|
14
|
+
return { title: buildL2ProjectPreviewTitle(Array.isArray(cwd) ? cwd[0] : cwd, region.locale) }
|
|
15
|
+
}
|
|
3
16
|
|
|
4
17
|
export default function ProjectPreview(): React.JSX.Element {
|
|
5
18
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import { useCallback, useRef } from 'react'
|
|
4
4
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
5
5
|
import { L2BuildLoginHref } from '@common/l2_biz/auth/l2-auth-return-to'
|
|
6
6
|
import { buildL2ProjectPreviewHref } from '@common/l2_biz/project-preview/l2-project-preview-navigation'
|
|
@@ -18,10 +18,6 @@ export function L1ProjectPreviewPage(): React.JSX.Element {
|
|
|
18
18
|
redirecting.current = true
|
|
19
19
|
window.location.assign(L2BuildLoginHref(window.location.pathname + window.location.search))
|
|
20
20
|
}, [])
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const name = cwd?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1)
|
|
23
|
-
document.title = name ? `${name} · Pi Desk` : 'Pi Desk'
|
|
24
|
-
}, [cwd])
|
|
25
21
|
return (
|
|
26
22
|
<L2ProjectPreviewPage
|
|
27
23
|
clientId={clientId}
|
|
@@ -12,6 +12,18 @@ import {
|
|
|
12
12
|
type L2ProjectPreviewProject
|
|
13
13
|
} from '../l2-project-preview-storage'
|
|
14
14
|
|
|
15
|
+
function subscribePointer(listener: () => void): () => void {
|
|
16
|
+
const media = window.matchMedia('(pointer: fine)')
|
|
17
|
+
media.addEventListener('change', listener)
|
|
18
|
+
return () => media.removeEventListener('change', listener)
|
|
19
|
+
}
|
|
20
|
+
function readFinePointer(): boolean {
|
|
21
|
+
return window.matchMedia('(pointer: fine)').matches
|
|
22
|
+
}
|
|
23
|
+
function readServerPointer(): boolean {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
type Page = 'files' | 'code' | 'git'
|
|
16
28
|
type HistoryPane = 'log' | 'files'
|
|
17
29
|
interface PreviewState extends Pick<L2ProjectPreviewOwner, 'files' | 'git' | 'history'> {
|
|
@@ -25,6 +37,7 @@ interface PreviewState extends Pick<L2ProjectPreviewOwner, 'files' | 'git' | 'hi
|
|
|
25
37
|
narrow: boolean
|
|
26
38
|
stacked: boolean
|
|
27
39
|
width: number
|
|
40
|
+
changeRowHeight: number
|
|
28
41
|
openGit: (root?: string) => void
|
|
29
42
|
refresh: () => void
|
|
30
43
|
}
|
|
@@ -67,6 +80,12 @@ export function useL2ProjectPreview(
|
|
|
67
80
|
const stacked = narrow || size.height < 680
|
|
68
81
|
const repositories = git.getWorkspace(cwd)!.snapshot.repositories
|
|
69
82
|
const state = files.state
|
|
83
|
+
const finePointer = useSyncExternalStore(subscribePointer, readFinePointer, readServerPointer)
|
|
84
|
+
const changeRowHeight = finePointer ? 24 : 36
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
history.setChangeRowHeight(changeRowHeight)
|
|
88
|
+
}, [history, changeRowHeight])
|
|
70
89
|
|
|
71
90
|
useEffect(() => {
|
|
72
91
|
saveCurrent.current = (): void => {
|
|
@@ -97,6 +116,11 @@ export function useL2ProjectPreview(
|
|
|
97
116
|
logScroll: history.scroll.log,
|
|
98
117
|
changesScroll: history.scroll.changes
|
|
99
118
|
},
|
|
119
|
+
diffs: files.state.diffs.map(({ repositoryRoot, path, comparison }) => ({
|
|
120
|
+
repositoryRoot,
|
|
121
|
+
path,
|
|
122
|
+
...(comparison ? { comparison } : {})
|
|
123
|
+
})),
|
|
100
124
|
activeDiff: files.state.activeDiff
|
|
101
125
|
? {
|
|
102
126
|
repositoryRoot: files.state.activeDiff.repositoryRoot,
|
|
@@ -198,6 +222,7 @@ export function useL2ProjectPreview(
|
|
|
198
222
|
narrow,
|
|
199
223
|
stacked,
|
|
200
224
|
width: size.width,
|
|
225
|
+
changeRowHeight,
|
|
201
226
|
openGit(root): void {
|
|
202
227
|
updateGlobal({ gitOpen: true })
|
|
203
228
|
setPage('git')
|
|
@@ -57,6 +57,11 @@ const editorStateSchema = z.custom<L4CodePreviewState>(
|
|
|
57
57
|
Array.isArray((value as { cursorState?: unknown }).cursorState) &&
|
|
58
58
|
typeof (value as { viewState?: unknown }).viewState === 'object'
|
|
59
59
|
)
|
|
60
|
+
const diffSchema = z.object({
|
|
61
|
+
repositoryRoot: z.string(),
|
|
62
|
+
path: L4ProjectFilePathSchema,
|
|
63
|
+
comparison: L4GitHistoricalDiffSchema.optional()
|
|
64
|
+
})
|
|
60
65
|
const projectSchema = z.object({
|
|
61
66
|
page: z.enum(['files', 'code', 'git']),
|
|
62
67
|
historyPane: z.enum(['log', 'files']),
|
|
@@ -70,13 +75,8 @@ const projectSchema = z.object({
|
|
|
70
75
|
changesOnly: z.boolean(),
|
|
71
76
|
expandedRepositories: z.array(z.string()).max(100),
|
|
72
77
|
history: historySchema,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
repositoryRoot: z.string(),
|
|
76
|
-
path: L4ProjectFilePathSchema,
|
|
77
|
-
comparison: L4GitHistoricalDiffSchema.optional()
|
|
78
|
-
})
|
|
79
|
-
.nullable()
|
|
78
|
+
diffs: z.array(diffSchema).max(20).optional(),
|
|
79
|
+
activeDiff: diffSchema.nullable()
|
|
80
80
|
})
|
|
81
81
|
export type L2ProjectPreviewProject = z.infer<typeof projectSchema>
|
|
82
82
|
const entrySchema = z.object({ cwd: z.string(), lastOpenedAt: z.number(), state: projectSchema })
|
|
@@ -63,6 +63,7 @@ export function L2ProjectPreview({
|
|
|
63
63
|
narrow,
|
|
64
64
|
stacked,
|
|
65
65
|
width,
|
|
66
|
+
changeRowHeight,
|
|
66
67
|
openGit,
|
|
67
68
|
refresh
|
|
68
69
|
} = useL2ProjectPreview(clientId, cwd, refreshOnFocus, onUnauthorized)
|
|
@@ -71,9 +72,6 @@ export function L2ProjectPreview({
|
|
|
71
72
|
const gitState = git.getWorkspace(cwd)!
|
|
72
73
|
const repositories = gitState.snapshot.repositories
|
|
73
74
|
const state = files.state
|
|
74
|
-
const diffPath = state.activeDiff
|
|
75
|
-
? [state.activeDiff.repositoryRoot, state.activeDiff.path].filter(Boolean).join('/')
|
|
76
|
-
: null
|
|
77
75
|
const fileTree = (
|
|
78
76
|
<L3ProjectFileBrowser
|
|
79
77
|
cwd={cwd}
|
|
@@ -98,10 +96,14 @@ export function L2ProjectPreview({
|
|
|
98
96
|
gitRuntime={git}
|
|
99
97
|
paths={state.tabs}
|
|
100
98
|
activePath={state.activePath}
|
|
101
|
-
|
|
99
|
+
diffs={state.diffs}
|
|
100
|
+
activeDiff={state.activeDiff}
|
|
102
101
|
mobile={narrow}
|
|
103
102
|
actions={actions}
|
|
104
|
-
onActivate={(
|
|
103
|
+
onActivate={(target) => {
|
|
104
|
+
if (typeof target === 'string') files.openFile(target)
|
|
105
|
+
else files.openDiff(target.repositoryRoot, target.path, target.comparison)
|
|
106
|
+
}}
|
|
105
107
|
onClose={(path, scope) => files.closeTabs(path, scope)}
|
|
106
108
|
/>
|
|
107
109
|
)}
|
|
@@ -143,6 +145,7 @@ export function L2ProjectPreview({
|
|
|
143
145
|
state={history.state}
|
|
144
146
|
cwd={cwd}
|
|
145
147
|
changeRows={history.getChangeRows()}
|
|
148
|
+
changeRowHeight={changeRowHeight}
|
|
146
149
|
onSelectChange={(path) => history.selectChange(path)}
|
|
147
150
|
onToggleDirectory={(path) => history.toggleChangeDirectory(path)}
|
|
148
151
|
onReveal={(path, type) => files.revealFile(path, type)}
|
|
@@ -86,6 +86,7 @@ export class L2ProjectHistoryRuntime {
|
|
|
86
86
|
private readonly collapsedDirectories = new Set<string>()
|
|
87
87
|
private changeTree = buildL3ProjectPathTree([])
|
|
88
88
|
private changeRows: L3PathTreeRow[] = []
|
|
89
|
+
private changeRowHeight = 36
|
|
89
90
|
constructor(
|
|
90
91
|
private readonly reader: L3ProjectReader,
|
|
91
92
|
private readonly files: L3ProjectFileRuntime
|
|
@@ -137,6 +138,13 @@ export class L2ProjectHistoryRuntime {
|
|
|
137
138
|
this.scroll[pane] = value
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
setChangeRowHeight(height: number): void {
|
|
142
|
+
if (this.changeRowHeight === height) return
|
|
143
|
+
this.scroll.changes = (this.scroll.changes / this.changeRowHeight) * height
|
|
144
|
+
this.changeRowHeight = height
|
|
145
|
+
this.publish()
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
setRepository(root: string): void {
|
|
141
149
|
if (root === this.state.repositoryRoot) {
|
|
142
150
|
if (this.state.branches.status === 'idle') {
|
|
@@ -213,18 +221,16 @@ export class L2ProjectHistoryRuntime {
|
|
|
213
221
|
const oid = typeof commit === 'string' ? commit : commit.oid
|
|
214
222
|
this.state.selectedCommit = oid
|
|
215
223
|
this.scroll.changes = 0
|
|
216
|
-
this.clearHistoricalDiff()
|
|
217
224
|
void this.loadChanges({ commit: oid, ...(parent ? { parent } : {}) }, 1)
|
|
218
225
|
}
|
|
219
226
|
showAggregate(): void {
|
|
220
227
|
if (!this.range) return
|
|
221
228
|
this.state.selectedCommit = null
|
|
222
|
-
this.clearHistoricalDiff()
|
|
223
229
|
void this.loadChanges(this.range, 1)
|
|
224
230
|
}
|
|
225
231
|
loadMoreLog(): void {
|
|
226
232
|
if (
|
|
227
|
-
this.state.log.status !== 'ready' ||
|
|
233
|
+
(this.state.log.status !== 'ready' && this.state.log.status !== 'error') ||
|
|
228
234
|
!this.state.log.data?.hasMore ||
|
|
229
235
|
this.state.log.data.items.length >= 2000
|
|
230
236
|
)
|
|
@@ -416,7 +422,6 @@ export class L2ProjectHistoryRuntime {
|
|
|
416
422
|
this.state.log = empty()
|
|
417
423
|
this.state.changes = empty()
|
|
418
424
|
this.resetChangeTree()
|
|
419
|
-
this.clearHistoricalDiff()
|
|
420
425
|
this.publish()
|
|
421
426
|
}
|
|
422
427
|
private resetChangeTree(): void {
|
|
@@ -427,18 +432,16 @@ export class L2ProjectHistoryRuntime {
|
|
|
427
432
|
this.scroll.changes = 0
|
|
428
433
|
}
|
|
429
434
|
private rebuildChangeRows(): void {
|
|
430
|
-
const
|
|
435
|
+
const height = this.changeRowHeight
|
|
436
|
+
const index = Math.floor(this.scroll.changes / height)
|
|
431
437
|
const anchor = this.changeRows[index]
|
|
432
|
-
const offset = this.scroll.changes %
|
|
438
|
+
const offset = this.scroll.changes % height
|
|
433
439
|
this.changeRows = flattenL3ProjectPathTree(this.changeTree, this.collapsedDirectories)
|
|
434
440
|
const next = anchor ? this.changeRows.findIndex((row) => row.key === anchor.key) : -1
|
|
435
441
|
this.scroll.changes =
|
|
436
442
|
next >= 0
|
|
437
|
-
? next *
|
|
438
|
-
: Math.min(this.scroll.changes, Math.max(0, (this.changeRows.length - 1) *
|
|
439
|
-
}
|
|
440
|
-
private clearHistoricalDiff(): void {
|
|
441
|
-
if (this.files.state.activeDiff?.comparison) this.files.closeDiff()
|
|
443
|
+
? next * height + offset
|
|
444
|
+
: Math.min(this.scroll.changes, Math.max(0, (this.changeRows.length - 1) * height))
|
|
442
445
|
}
|
|
443
446
|
private begin(key: 'branches' | 'log' | 'changes'): AbortController {
|
|
444
447
|
this.cancel(key)
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import { CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronsUpDownIcon } from 'lucide-react'
|
|
6
|
+
import type { L4GitReadBranches } from '@common/l4_foundation/git/l4-git-history-contract'
|
|
7
|
+
import {
|
|
8
|
+
buildL3GitBranchTree,
|
|
9
|
+
type L3GitBranchTreeNode
|
|
10
|
+
} from '@client/l3_modules/project-files/l3-project-git-model'
|
|
11
|
+
import { cn } from '@client/l4_foundation/lib/l4-utils'
|
|
12
|
+
import { Button } from '@client/l4_foundation/ui/shadcn/button'
|
|
13
|
+
import { Input } from '@client/l4_foundation/ui/shadcn/input'
|
|
14
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@client/l4_foundation/ui/shadcn/popover'
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
branches: L4GitReadBranches | null
|
|
18
|
+
value: string
|
|
19
|
+
onChange: (value: string) => void
|
|
20
|
+
ariaLabel: string
|
|
21
|
+
placeholder?: string
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface BranchRow {
|
|
26
|
+
key: string
|
|
27
|
+
label: string
|
|
28
|
+
fullName: string
|
|
29
|
+
parentKey: string | null
|
|
30
|
+
level: number
|
|
31
|
+
position: number
|
|
32
|
+
siblingCount: number
|
|
33
|
+
expandable: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function branchAncestors(value: string): string[] {
|
|
37
|
+
const result: string[] = []
|
|
38
|
+
let parent = value
|
|
39
|
+
while (parent.includes('/')) {
|
|
40
|
+
parent = parent.slice(0, parent.lastIndexOf('/'))
|
|
41
|
+
if (parent === 'refs') break
|
|
42
|
+
result.push(parent)
|
|
43
|
+
}
|
|
44
|
+
return result
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function L2ProjectBranchSelect({
|
|
48
|
+
branches,
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
ariaLabel,
|
|
52
|
+
placeholder,
|
|
53
|
+
className
|
|
54
|
+
}: Props): React.JSX.Element {
|
|
55
|
+
const { t } = useTranslation('projectHistory')
|
|
56
|
+
const { t: common } = useTranslation('common')
|
|
57
|
+
const treeId = useId()
|
|
58
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
59
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
60
|
+
const rowRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
61
|
+
const [open, setOpen] = useState(false)
|
|
62
|
+
const [query, setQuery] = useState('')
|
|
63
|
+
const [activeKey, setActiveKey] = useState(value)
|
|
64
|
+
const [expanded, setExpanded] = useState(
|
|
65
|
+
() => new Set(['refs/heads', 'refs/remotes', ...branchAncestors(value)])
|
|
66
|
+
)
|
|
67
|
+
const search = query.trim().toLocaleLowerCase()
|
|
68
|
+
const selectedLabel =
|
|
69
|
+
value === 'HEAD' ? t('currentHead') : value.replace(/^refs\/(heads|remotes)\//, '')
|
|
70
|
+
|
|
71
|
+
const rows = useMemo((): BranchRow[] => {
|
|
72
|
+
const result: BranchRow[] = []
|
|
73
|
+
const groups = [
|
|
74
|
+
{ key: 'refs/heads', label: t('localBranch'), names: branches?.local ?? [] },
|
|
75
|
+
{ key: 'refs/remotes', label: t('remoteBranch'), names: branches?.remote ?? [] }
|
|
76
|
+
]
|
|
77
|
+
.map((group) => ({
|
|
78
|
+
...group,
|
|
79
|
+
nodes: buildL3GitBranchTree(
|
|
80
|
+
group.names.filter((name) => name.toLocaleLowerCase().includes(search))
|
|
81
|
+
)
|
|
82
|
+
}))
|
|
83
|
+
.filter((group) => group.nodes.length > 0)
|
|
84
|
+
const showHead = `HEAD ${t('currentHead')}`.toLocaleLowerCase().includes(search)
|
|
85
|
+
const rootCount = groups.length + Number(showHead)
|
|
86
|
+
if (showHead) {
|
|
87
|
+
result.push({
|
|
88
|
+
key: 'HEAD',
|
|
89
|
+
label: t('currentHead'),
|
|
90
|
+
fullName: t('currentHead'),
|
|
91
|
+
parentKey: null,
|
|
92
|
+
level: 1,
|
|
93
|
+
position: 1,
|
|
94
|
+
siblingCount: rootCount,
|
|
95
|
+
expandable: false
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
const append = (
|
|
99
|
+
nodes: readonly L3GitBranchTreeNode[],
|
|
100
|
+
prefix: string,
|
|
101
|
+
parentKey: string,
|
|
102
|
+
level: number
|
|
103
|
+
): void => {
|
|
104
|
+
nodes.forEach((node, index) => {
|
|
105
|
+
const key = `${prefix}/${node.path}`
|
|
106
|
+
result.push({
|
|
107
|
+
key,
|
|
108
|
+
label: node.name,
|
|
109
|
+
fullName: node.path,
|
|
110
|
+
parentKey,
|
|
111
|
+
level,
|
|
112
|
+
position: index + 1,
|
|
113
|
+
siblingCount: nodes.length,
|
|
114
|
+
expandable: node.children.length > 0
|
|
115
|
+
})
|
|
116
|
+
if (expanded.has(key)) append(node.children, prefix, key, level + 1)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
groups.forEach((group, index) => {
|
|
120
|
+
result.push({
|
|
121
|
+
key: group.key,
|
|
122
|
+
label: group.label,
|
|
123
|
+
fullName: group.label,
|
|
124
|
+
parentKey: null,
|
|
125
|
+
level: 1,
|
|
126
|
+
position: index + Number(showHead) + 1,
|
|
127
|
+
siblingCount: rootCount,
|
|
128
|
+
expandable: true
|
|
129
|
+
})
|
|
130
|
+
if (expanded.has(group.key)) append(group.nodes, group.key, group.key, 2)
|
|
131
|
+
})
|
|
132
|
+
return result
|
|
133
|
+
}, [branches, expanded, search, t])
|
|
134
|
+
|
|
135
|
+
const activeRow =
|
|
136
|
+
rows.find((row) => row.key === activeKey) ??
|
|
137
|
+
rows.find((row) => row.key === value) ??
|
|
138
|
+
rows.find((row) => !row.expandable) ??
|
|
139
|
+
rows[0]
|
|
140
|
+
|
|
141
|
+
const changeOpen = (next: boolean): void => {
|
|
142
|
+
if (next) {
|
|
143
|
+
setQuery('')
|
|
144
|
+
setActiveKey(value)
|
|
145
|
+
setExpanded((previous) => new Set([...previous, ...branchAncestors(value)]))
|
|
146
|
+
}
|
|
147
|
+
setOpen(next)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const changeSearch = (text: string): void => {
|
|
151
|
+
setQuery(text)
|
|
152
|
+
setActiveKey('')
|
|
153
|
+
const filter = text.trim().toLocaleLowerCase()
|
|
154
|
+
if (!filter) return
|
|
155
|
+
const keys = [
|
|
156
|
+
...(branches?.local ?? [])
|
|
157
|
+
.filter((name) => name.toLocaleLowerCase().includes(filter))
|
|
158
|
+
.map((name) => `refs/heads/${name}`),
|
|
159
|
+
...(branches?.remote ?? [])
|
|
160
|
+
.filter((name) => name.toLocaleLowerCase().includes(filter))
|
|
161
|
+
.map((name) => `refs/remotes/${name}`)
|
|
162
|
+
]
|
|
163
|
+
setExpanded((previous) => new Set([...previous, ...keys.flatMap(branchAncestors)]))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const focusRow = (row: BranchRow | undefined): void => {
|
|
167
|
+
if (!row) return
|
|
168
|
+
setActiveKey(row.key)
|
|
169
|
+
const element = rowRefs.current.get(row.key)
|
|
170
|
+
element?.focus({ preventScroll: true })
|
|
171
|
+
element?.scrollIntoView({ block: 'nearest' })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const toggle = (row: BranchRow): void => {
|
|
175
|
+
setExpanded((previous) => {
|
|
176
|
+
const next = new Set(previous)
|
|
177
|
+
if (next.has(row.key)) next.delete(row.key)
|
|
178
|
+
else next.add(row.key)
|
|
179
|
+
return next
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const handleTreeKey = (event: KeyboardEvent<HTMLButtonElement>, row: BranchRow): void => {
|
|
184
|
+
if (event.nativeEvent.isComposing || event.altKey || event.ctrlKey || event.metaKey) return
|
|
185
|
+
const index = rows.indexOf(row)
|
|
186
|
+
switch (event.key) {
|
|
187
|
+
case 'ArrowDown':
|
|
188
|
+
focusRow(rows[Math.min(index + 1, rows.length - 1)])
|
|
189
|
+
break
|
|
190
|
+
case 'ArrowUp':
|
|
191
|
+
focusRow(rows[Math.max(index - 1, 0)])
|
|
192
|
+
break
|
|
193
|
+
case 'Home':
|
|
194
|
+
focusRow(rows[0])
|
|
195
|
+
break
|
|
196
|
+
case 'End':
|
|
197
|
+
focusRow(rows.at(-1))
|
|
198
|
+
break
|
|
199
|
+
case 'ArrowRight':
|
|
200
|
+
if (row.expandable) {
|
|
201
|
+
if (expanded.has(row.key)) focusRow(rows[index + 1])
|
|
202
|
+
else toggle(row)
|
|
203
|
+
}
|
|
204
|
+
break
|
|
205
|
+
case 'ArrowLeft':
|
|
206
|
+
if (row.expandable && expanded.has(row.key)) toggle(row)
|
|
207
|
+
else focusRow(rows.find((candidate) => candidate.key === row.parentKey))
|
|
208
|
+
break
|
|
209
|
+
default:
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
event.preventDefault()
|
|
213
|
+
event.stopPropagation()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<Popover
|
|
218
|
+
open={open}
|
|
219
|
+
onOpenChange={changeOpen}
|
|
220
|
+
onOpenChangeComplete={(next) => {
|
|
221
|
+
if (next && document.activeElement === searchRef.current) {
|
|
222
|
+
rowRefs.current.get(value)?.scrollIntoView({ block: 'nearest' })
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
>
|
|
226
|
+
<PopoverTrigger
|
|
227
|
+
render={
|
|
228
|
+
<Button
|
|
229
|
+
ref={triggerRef}
|
|
230
|
+
type="button"
|
|
231
|
+
variant="outline"
|
|
232
|
+
role="combobox"
|
|
233
|
+
aria-haspopup="dialog"
|
|
234
|
+
aria-label={ariaLabel}
|
|
235
|
+
aria-expanded={open}
|
|
236
|
+
title={selectedLabel || placeholder}
|
|
237
|
+
className={cn(
|
|
238
|
+
'w-full min-w-0 justify-between border-input px-2.5 font-normal focus-visible:-outline-offset-2',
|
|
239
|
+
className
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
<span className={cn('min-w-0 truncate', !value && 'text-muted-foreground')}>
|
|
243
|
+
{selectedLabel || placeholder || common('selectPlaceholder')}
|
|
244
|
+
</span>
|
|
245
|
+
<ChevronsUpDownIcon className="text-muted-foreground" />
|
|
246
|
+
</Button>
|
|
247
|
+
}
|
|
248
|
+
/>
|
|
249
|
+
<PopoverContent
|
|
250
|
+
align="start"
|
|
251
|
+
aria-label={ariaLabel}
|
|
252
|
+
initialFocus={searchRef}
|
|
253
|
+
finalFocus={triggerRef}
|
|
254
|
+
positionerClassName="z-[120]"
|
|
255
|
+
className="max-h-[min(30rem,var(--available-height))] w-[max(26rem,calc(var(--anchor-width)+8rem))] max-w-[min(calc(100vw-2rem),var(--available-width))] gap-0 overflow-hidden p-0"
|
|
256
|
+
>
|
|
257
|
+
<div className="shrink-0 border-b p-2">
|
|
258
|
+
<Input
|
|
259
|
+
ref={searchRef}
|
|
260
|
+
type="search"
|
|
261
|
+
aria-label={common('searchPlaceholder')}
|
|
262
|
+
aria-controls={treeId}
|
|
263
|
+
placeholder={common('searchPlaceholder')}
|
|
264
|
+
value={query}
|
|
265
|
+
onChange={(event) => changeSearch(event.target.value)}
|
|
266
|
+
onKeyDown={(event) => {
|
|
267
|
+
if (event.nativeEvent.isComposing || event.altKey || event.ctrlKey || event.metaKey)
|
|
268
|
+
return
|
|
269
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
|
270
|
+
event.preventDefault()
|
|
271
|
+
event.stopPropagation()
|
|
272
|
+
focusRow(event.key === 'ArrowDown' ? activeRow : rows.at(-1))
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
<div
|
|
277
|
+
id={treeId}
|
|
278
|
+
role="tree"
|
|
279
|
+
aria-label={ariaLabel}
|
|
280
|
+
className="min-h-0 overflow-y-auto overscroll-contain p-1"
|
|
281
|
+
>
|
|
282
|
+
{rows.map((row) => {
|
|
283
|
+
const selected = !row.expandable && row.key === value
|
|
284
|
+
const expandedRow = row.expandable ? expanded.has(row.key) : undefined
|
|
285
|
+
return (
|
|
286
|
+
<button
|
|
287
|
+
key={row.key}
|
|
288
|
+
ref={(element) => {
|
|
289
|
+
if (element) rowRefs.current.set(row.key, element)
|
|
290
|
+
else rowRefs.current.delete(row.key)
|
|
291
|
+
}}
|
|
292
|
+
type="button"
|
|
293
|
+
role="treeitem"
|
|
294
|
+
aria-label={row.fullName}
|
|
295
|
+
aria-level={row.level}
|
|
296
|
+
aria-posinset={row.position}
|
|
297
|
+
aria-setsize={row.siblingCount}
|
|
298
|
+
aria-selected={row.expandable ? undefined : selected}
|
|
299
|
+
aria-expanded={expandedRow}
|
|
300
|
+
tabIndex={activeRow?.key === row.key ? 0 : -1}
|
|
301
|
+
title={row.fullName}
|
|
302
|
+
className={cn(
|
|
303
|
+
'flex min-h-8 w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-sm outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [@media(pointer:coarse)]:min-h-10',
|
|
304
|
+
selected && 'bg-accent text-accent-foreground',
|
|
305
|
+
row.level === 1 && row.expandable && 'font-medium text-muted-foreground'
|
|
306
|
+
)}
|
|
307
|
+
style={{ paddingLeft: 8 + Math.min(row.level - 1, 8) * 12 }}
|
|
308
|
+
onFocus={() => setActiveKey(row.key)}
|
|
309
|
+
onKeyDown={(event) => handleTreeKey(event, row)}
|
|
310
|
+
onClick={() => {
|
|
311
|
+
setActiveKey(row.key)
|
|
312
|
+
if (row.expandable) toggle(row)
|
|
313
|
+
else {
|
|
314
|
+
onChange(row.key)
|
|
315
|
+
setOpen(false)
|
|
316
|
+
}
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{row.expandable ? (
|
|
320
|
+
expandedRow ? (
|
|
321
|
+
<ChevronDownIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
322
|
+
) : (
|
|
323
|
+
<ChevronRightIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
324
|
+
)
|
|
325
|
+
) : (
|
|
326
|
+
<span aria-hidden="true" className="size-3.5 shrink-0" />
|
|
327
|
+
)}
|
|
328
|
+
<span className="min-w-0 flex-1 break-all">{row.label}</span>
|
|
329
|
+
{selected ? <CheckIcon aria-hidden="true" className="size-3.5 shrink-0" /> : null}
|
|
330
|
+
</button>
|
|
331
|
+
)
|
|
332
|
+
})}
|
|
333
|
+
</div>
|
|
334
|
+
{!rows.length ? (
|
|
335
|
+
<p role="status" className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
336
|
+
{common('noMatches')}
|
|
337
|
+
</p>
|
|
338
|
+
) : null}
|
|
339
|
+
{activeRow && activeRow.fullName !== activeRow.label ? (
|
|
340
|
+
<div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs break-all text-muted-foreground">
|
|
341
|
+
{activeRow.fullName}
|
|
342
|
+
</div>
|
|
343
|
+
) : null}
|
|
344
|
+
</PopoverContent>
|
|
345
|
+
</Popover>
|
|
346
|
+
)
|
|
347
|
+
}
|