@jetcrab/pi-desk 1.1.24-dev → 1.1.28-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 (59) hide show
  1. package/package.json +2 -2
  2. package/src/app/project-preview/page.tsx +13 -0
  3. package/src/client/l1_entry/pages/l1-project-preview-page.tsx +1 -5
  4. package/src/client/l2_biz/project-preview/hooks/l2-use-project-preview.ts +25 -0
  5. package/src/client/l2_biz/project-preview/l2-project-preview-storage.ts +7 -7
  6. package/src/client/l2_biz/project-preview/l2-project-preview.tsx +8 -5
  7. package/src/client/l2_biz/project-preview/runtime/l2-project-history-runtime.ts +14 -11
  8. package/src/client/l2_biz/project-preview/views/l2-project-branch-select.tsx +347 -0
  9. package/src/client/l2_biz/project-preview/views/l2-project-change-tree.tsx +28 -11
  10. package/src/client/l2_biz/project-preview/views/l2-project-history-list.tsx +35 -8
  11. package/src/client/l2_biz/project-preview/views/l2-project-history-view.tsx +46 -48
  12. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +2 -1
  13. package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-view.tsx +6 -2
  14. package/src/client/l3_modules/conversation/l3-conversation-viewport.tsx +18 -8
  15. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +7 -4
  16. package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +181 -89
  17. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +80 -28
  18. package/src/common/l2_biz/project-preview/l2-project-history-messages.ts +4 -2
  19. package/src/common/l2_biz/project-preview/l2-project-preview-navigation.ts +14 -0
  20. package/src/server/l4_foundation/process/l4-process-tree.js +159 -40
  21. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  22. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  23. package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
  24. package/temp/build/pi-desk/release/.next/required-server-files.js +2 -2
  25. package/temp/build/pi-desk/release/.next/required-server-files.json +2 -2
  26. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  27. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  28. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  29. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  30. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  31. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  33. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  34. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  35. package/temp/build/pi-desk/release/.next/server/app/page.js +2 -2
  36. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/temp/build/pi-desk/release/.next/server/app/project-preview/page.js +1 -1
  38. package/temp/build/pi-desk/release/.next/server/app/project-preview/page_client-reference-manifest.js +1 -1
  39. package/temp/build/pi-desk/release/.next/server/chunks/27344.js +15 -0
  40. package/temp/build/pi-desk/release/.next/server/chunks/35985.js +1 -1
  41. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  42. package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
  43. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  44. package/temp/build/pi-desk/release/.next/static/chunks/15302-6e070e13fa65c863.js +1 -0
  45. package/temp/build/pi-desk/release/.next/static/chunks/19991.bcca96d396b83030.js +1 -0
  46. package/temp/build/pi-desk/release/.next/static/chunks/65232-ef4e5a4174f62faf.js +15 -0
  47. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-ff44dcf27c2ba1c2.js → page-ce2ebf7d8ea7a330.js} +3 -3
  48. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-038b6c14e03ed878.js +1 -0
  49. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-48eecbb66781c006.js → webpack-c0b805df46fcf8b3.js} +1 -1
  50. package/temp/build/pi-desk/release/.next/static/css/c43b04c244a64fb4.css +1 -0
  51. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  52. package/temp/build/pi-desk/release/.next/server/chunks/20460.js +0 -15
  53. package/temp/build/pi-desk/release/.next/static/chunks/15302-9c807afc6c60de0c.js +0 -1
  54. package/temp/build/pi-desk/release/.next/static/chunks/19991.8aa2e4b6df15fad6.js +0 -1
  55. package/temp/build/pi-desk/release/.next/static/chunks/9038-05fd2688407b39c1.js +0 -15
  56. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/page-bc18765121950e65.js +0 -1
  57. package/temp/build/pi-desk/release/.next/static/css/10e063cfb3cda7bd.css +0 -1
  58. /package/temp/build/pi-desk/release/.next/static/{w3CeIboVupqJdq890TlcG → bj2dB_Z10YB_CqK_VNhDx}/_buildManifest.js +0 -0
  59. /package/temp/build/pi-desk/release/.next/static/{w3CeIboVupqJdq890TlcG → bj2dB_Z10YB_CqK_VNhDx}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jetcrab/pi-desk",
3
- "version": "1.1.24-dev",
3
+ "version": "1.1.28-dev",
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.9-dev"
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, useEffect, useRef } from 'react'
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
- activeDiff: z
74
- .object({
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
- diffPath={diffPath}
99
+ diffs={state.diffs}
100
+ activeDiff={state.activeDiff}
102
101
  mobile={narrow}
103
102
  actions={actions}
104
- onActivate={(path) => files.openFile(path)}
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 index = Math.floor(this.scroll.changes / 36)
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 % 36
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 * 36 + offset
438
- : Math.min(this.scroll.changes, Math.max(0, (this.changeRows.length - 1) * 36))
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
+ }