@jetcrab/pi-desk 1.1.22-dev → 1.1.23-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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/client/l2_biz/project-preview/l2-project-preview-dialog.tsx +1 -1
  3. package/src/client/l2_biz/project-preview/l2-project-preview-page.tsx +1 -1
  4. package/src/client/l2_biz/project-preview/l2-project-preview.tsx +28 -34
  5. package/src/client/l3_modules/project-files/l3-project-document-view.tsx +162 -151
  6. package/src/client/l3_modules/project-files/l3-project-file-browser.module.css +30 -0
  7. package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +137 -111
  8. package/src/client/l3_modules/project-files/l3-project-file-tabs.tsx +26 -6
  9. package/src/client/l3_modules/project-files/views/l3-project-search-controls.tsx +118 -123
  10. package/src/client/l3_modules/project-files/views/l3-project-search-results.tsx +29 -25
  11. package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
  12. package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
  13. package/temp/build/pi-desk/release/.next/required-server-files.js +2 -2
  14. package/temp/build/pi-desk/release/.next/required-server-files.json +2 -2
  15. package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  16. package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
  17. package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
  18. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  19. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  20. package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  21. package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
  23. package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
  24. package/temp/build/pi-desk/release/.next/server/app/page.js +2 -2
  25. package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
  26. package/temp/build/pi-desk/release/.next/server/app/project-preview/page.js +1 -1
  27. package/temp/build/pi-desk/release/.next/server/app/project-preview/page_client-reference-manifest.js +1 -1
  28. package/temp/build/pi-desk/release/.next/server/chunks/20460.js +2 -2
  29. package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
  30. package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
  31. package/temp/build/pi-desk/release/.next/static/chunks/9038-05fd2688407b39c1.js +15 -0
  32. package/temp/build/pi-desk/release/.next/static/chunks/app/{page-e8a5274c115f528d.js → page-54040b9fd54ee8b3.js} +2 -2
  33. package/temp/build/pi-desk/release/.next/static/chunks/app/project-preview/{page-d70ee4e9409b970a.js → page-bc18765121950e65.js} +1 -1
  34. package/temp/build/pi-desk/release/.next/static/chunks/{webpack-b60891a214e0925d.js → webpack-48eecbb66781c006.js} +1 -1
  35. package/temp/build/pi-desk/release/.next/static/css/10e063cfb3cda7bd.css +1 -0
  36. package/temp/build/pi-desk/release/.next/static/css/2c3b0c0ff1f49c68.css +1 -0
  37. package/temp/build/pi-desk/release/.next/trace-build +1 -1
  38. package/temp/build/pi-desk/release/.next/static/chunks/9038-03144ed96d613f75.js +0 -15
  39. package/temp/build/pi-desk/release/.next/static/css/6dbae2922f88ca31.css +0 -1
  40. package/temp/build/pi-desk/release/.next/static/css/f646ca6c07245c4c.css +0 -1
  41. /package/temp/build/pi-desk/release/.next/static/{MV6_lBt7Mzmi1rBNI-3dx → gAYTx4KJJR3vTtGYexj4J}/_buildManifest.js +0 -0
  42. /package/temp/build/pi-desk/release/.next/static/{MV6_lBt7Mzmi1rBNI-3dx → gAYTx4KJJR3vTtGYexj4J}/_ssgManifest.js +0 -0
@@ -43,6 +43,7 @@ import {
43
43
  } from './l3-project-git-model'
44
44
  import type { L3DirectoryState, L3ProjectFileRuntime } from './l3-project-file-runtime'
45
45
  import { L3ProjectPathMenu } from './l3-project-path-menu'
46
+ import styles from './l3-project-file-browser.module.css'
46
47
  import {
47
48
  buildL3ProjectPathTree,
48
49
  compactL3PathTreeDirectory,
@@ -60,6 +61,7 @@ interface L3ProjectFileBrowserProps {
60
61
  onOpenGitBranches: (repositoryRoot: string) => void
61
62
  onFileOpened?: () => void
62
63
  rootTitle?: React.ReactNode
64
+ showRootTitle?: boolean
63
65
  gitReadOnly?: boolean
64
66
  }
65
67
 
@@ -155,6 +157,7 @@ export function L3ProjectFileBrowser({
155
157
  onOpenGitBranches,
156
158
  onFileOpened,
157
159
  rootTitle,
160
+ showRootTitle = true,
158
161
  gitReadOnly = false
159
162
  }: L3ProjectFileBrowserProps): React.JSX.Element {
160
163
  const { t } = useTranslation('projectFiles')
@@ -581,7 +584,7 @@ export function L3ProjectFileBrowser({
581
584
  type="button"
582
585
  disabled={gitReadOnly || repository.state !== 'ready'}
583
586
  className={cn(
584
- buttonVariants({ variant: 'outline', size: 'sm' }),
587
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
585
588
  'max-w-28 shrink gap-1 font-mono text-xs text-muted-foreground'
586
589
  )}
587
590
  title={
@@ -589,6 +592,7 @@ export function L3ProjectFileBrowser({
589
592
  ? t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')
590
593
  : selectL4LocalizedText(repository.message, locale)
591
594
  }
595
+ aria-label={t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')}
592
596
  onClick={() => onOpenGitBranches(root)}
593
597
  >
594
598
  <GitBranchIcon className="size-3 shrink-0" />
@@ -678,6 +682,98 @@ export function L3ProjectFileBrowser({
678
682
  repositories.length === 1 && repositories[0]?.root === '' ? repositories[0] : null
679
683
  const showLayoutToggle = repositories.length > 0 && !singleProjectRootRepository
680
684
  const effectiveLayout = showLayoutToggle ? (gitWorkspace?.layout ?? 'directory') : 'directory'
685
+ const rootBranchButton = singleProjectRootRepository ? (
686
+ <button
687
+ type="button"
688
+ disabled={gitReadOnly || singleProjectRootRepository.state !== 'ready'}
689
+ className={cn(
690
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
691
+ 'max-w-32 shrink gap-1 font-mono text-xs text-muted-foreground',
692
+ !showRootTitle && 'min-w-0 flex-1 justify-start'
693
+ )}
694
+ aria-label={t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')}
695
+ title={
696
+ singleProjectRootRepository.state === 'ready'
697
+ ? `${t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')} · ${getL3GitHeadLabel(singleProjectRootRepository.head)}`
698
+ : selectL4LocalizedText(singleProjectRootRepository.message, locale)
699
+ }
700
+ onClick={() => onOpenGitBranches('')}
701
+ >
702
+ <GitBranchIcon className="size-3 shrink-0" />
703
+ <span className="truncate">
704
+ {singleProjectRootRepository.state === 'ready'
705
+ ? getL3GitHeadLabel(singleProjectRootRepository.head)
706
+ : t('unavailable')}
707
+ </span>
708
+ </button>
709
+ ) : null
710
+
711
+ const controls = (
712
+ <div
713
+ className="flex min-h-8 min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
714
+ tabIndex={showRootTitle ? undefined : 0}
715
+ aria-label={t('projectFiles', { name: projectName })}
716
+ >
717
+ {showLayoutToggle ? (
718
+ <div className="flex shrink-0 gap-0.5" role="group" aria-label={t('layout')}>
719
+ {(['directory', 'repository'] as const).map((layout) => (
720
+ <button
721
+ key={layout}
722
+ data-testid={`project-file-layout-${layout}`}
723
+ type="button"
724
+ aria-pressed={effectiveLayout === layout}
725
+ className={cn(
726
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
727
+ effectiveLayout === layout && 'bg-accent text-accent-foreground'
728
+ )}
729
+ onClick={() => {
730
+ runtime.cancelDirectoryExpansion()
731
+ gitRuntime.setLayout(cwd, layout)
732
+ }}
733
+ >
734
+ {t(layout === 'directory' ? 'directory' : 'repository')}
735
+ </button>
736
+ ))}
737
+ </div>
738
+ ) : null}
739
+ {repositories.length > 0 ? (
740
+ <button
741
+ data-testid="project-git-changes-toggle"
742
+ type="button"
743
+ aria-pressed={gitWorkspace?.changesOnly ?? false}
744
+ className={cn(
745
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
746
+ 'min-w-0 gap-1',
747
+ gitWorkspace?.changesOnly && 'bg-accent text-accent-foreground'
748
+ )}
749
+ onClick={() => {
750
+ runtime.cancelDirectoryExpansion()
751
+ gitRuntime.setChangesOnly(cwd, !(gitWorkspace?.changesOnly ?? false))
752
+ }}
753
+ >
754
+ <ListFilterIcon className="size-3 shrink-0" />
755
+ <span className="truncate">{t('changesOnly')}</span>
756
+ <span className="rounded-md bg-muted px-1 font-mono text-xs">
757
+ {projectChanges.length}
758
+ </span>
759
+ </button>
760
+ ) : null}
761
+ {!showRootTitle ? rootBranchButton : null}
762
+ <button
763
+ ref={searchButtonRef}
764
+ type="button"
765
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'ml-auto')}
766
+ aria-label={t('searchProject')}
767
+ title={t('searchProject')}
768
+ onClick={() => {
769
+ setSearchOpen(true)
770
+ requestAnimationFrame(() => searchInputRef.current?.focus())
771
+ }}
772
+ >
773
+ <SearchIcon className="size-3.5" />
774
+ </button>
775
+ </div>
776
+ )
681
777
 
682
778
  return (
683
779
  <section
@@ -706,8 +802,42 @@ export function L3ProjectFileBrowser({
706
802
  }
707
803
  />
708
804
  ) : (
709
- <div className="space-y-1.5 border-b px-2.5 py-2">
710
- <div className="flex min-h-7 min-w-0 items-center gap-2">
805
+ <div className="border-b px-1">
806
+ {showRootTitle ? (
807
+ <div className="flex min-h-7 min-w-0 items-center gap-1">
808
+ <L3ProjectPathMenu
809
+ cwd={cwd}
810
+ path=""
811
+ type="directory"
812
+ onReveal={
813
+ runtime.canReveal ? (path, type) => runtime.revealFile(path, type) : undefined
814
+ }
815
+ trigger={
816
+ rootTitle ? (
817
+ <div className="flex min-w-0 flex-1">{rootTitle}</div>
818
+ ) : (
819
+ <div tabIndex={0} className="flex min-w-0 flex-1 items-baseline gap-1">
820
+ <p className="shrink-0 truncate text-xs font-semibold" title={projectName}>
821
+ {projectName}
822
+ </p>
823
+ <span className="text-xs text-muted-foreground">·</span>
824
+ <p
825
+ className="min-w-0 truncate font-mono text-xs text-muted-foreground"
826
+ title={cwd}
827
+ >
828
+ {compactPath(cwd)}
829
+ </p>
830
+ </div>
831
+ )
832
+ }
833
+ />
834
+ {rootBranchButton}
835
+ </div>
836
+ ) : null}
837
+
838
+ {showRootTitle ? (
839
+ controls
840
+ ) : (
711
841
  <L3ProjectPathMenu
712
842
  cwd={cwd}
713
843
  path=""
@@ -715,113 +845,9 @@ export function L3ProjectFileBrowser({
715
845
  onReveal={
716
846
  runtime.canReveal ? (path, type) => runtime.revealFile(path, type) : undefined
717
847
  }
718
- trigger={
719
- rootTitle ? (
720
- <div className="flex min-w-0 flex-1">{rootTitle}</div>
721
- ) : (
722
- <div tabIndex={0} className="flex min-w-0 flex-1 items-baseline gap-1">
723
- <p className="shrink-0 truncate text-xs font-semibold" title={projectName}>
724
- {projectName}
725
- </p>
726
- <span className="text-xs text-muted-foreground">·</span>
727
- <p
728
- className="min-w-0 truncate font-mono text-xs text-muted-foreground"
729
- title={cwd}
730
- >
731
- {compactPath(cwd)}
732
- </p>
733
- </div>
734
- )
735
- }
848
+ trigger={controls}
736
849
  />
737
- {singleProjectRootRepository ? (
738
- <button
739
- type="button"
740
- disabled={gitReadOnly || singleProjectRootRepository.state !== 'ready'}
741
- className={cn(
742
- buttonVariants({ variant: 'outline', size: 'sm' }),
743
- 'max-w-32 shrink gap-1 font-mono text-xs text-muted-foreground'
744
- )}
745
- title={
746
- singleProjectRootRepository.state === 'ready'
747
- ? t(gitReadOnly ? 'gitBranchReadOnly' : 'gitBranches')
748
- : selectL4LocalizedText(singleProjectRootRepository.message, locale)
749
- }
750
- onClick={() => onOpenGitBranches('')}
751
- >
752
- <GitBranchIcon className="size-3 shrink-0" />
753
- <span className="truncate">
754
- {singleProjectRootRepository.state === 'ready'
755
- ? getL3GitHeadLabel(singleProjectRootRepository.head)
756
- : t('unavailable')}
757
- </span>
758
- </button>
759
- ) : null}
760
- </div>
761
-
762
- <div className="flex min-h-8 min-w-0 items-center gap-1.5">
763
- {showLayoutToggle ? (
764
- <div
765
- className="flex shrink-0 rounded-md border bg-background/55 p-0.5"
766
- role="group"
767
- aria-label={t('layout')}
768
- >
769
- {(['directory', 'repository'] as const).map((layout) => (
770
- <button
771
- key={layout}
772
- data-testid={`project-file-layout-${layout}`}
773
- type="button"
774
- aria-pressed={effectiveLayout === layout}
775
- className={cn(
776
- buttonVariants({ variant: 'ghost', size: 'sm' }),
777
- effectiveLayout === layout && 'bg-accent text-accent-foreground'
778
- )}
779
- onClick={() => {
780
- runtime.cancelDirectoryExpansion()
781
- gitRuntime.setLayout(cwd, layout)
782
- }}
783
- >
784
- {t(layout === 'directory' ? 'directory' : 'repository')}
785
- </button>
786
- ))}
787
- </div>
788
- ) : null}
789
- {repositories.length > 0 ? (
790
- <button
791
- data-testid="project-git-changes-toggle"
792
- type="button"
793
- aria-pressed={gitWorkspace?.changesOnly ?? false}
794
- className={cn(
795
- buttonVariants({ variant: 'outline', size: 'sm' }),
796
- 'min-w-0 gap-1',
797
- gitWorkspace?.changesOnly && 'bg-accent text-accent-foreground'
798
- )}
799
- onClick={() => {
800
- runtime.cancelDirectoryExpansion()
801
- gitRuntime.setChangesOnly(cwd, !(gitWorkspace?.changesOnly ?? false))
802
- }}
803
- >
804
- <ListFilterIcon className="size-3 shrink-0" />
805
- <span className="truncate">{t('changesOnly')}</span>
806
- <span className="rounded-md bg-muted px-1 font-mono text-xs">
807
- {projectChanges.length}
808
- </span>
809
- </button>
810
- ) : null}
811
- <button
812
- ref={searchButtonRef}
813
- type="button"
814
- className={cn(buttonVariants({ variant: 'outline', size: 'icon-sm' }), 'ml-auto')}
815
- aria-label={t('searchProject')}
816
- title={t('searchProject')}
817
- onClick={() => {
818
- setSearchOpen(true)
819
- requestAnimationFrame(() => searchInputRef.current?.focus())
820
- }}
821
- >
822
- <SearchIcon className="size-3.5" />
823
- </button>
824
- </div>
850
+ )}
825
851
  </div>
826
852
  )}
827
853
 
@@ -852,8 +878,8 @@ export function L3ProjectFileBrowser({
852
878
  />
853
879
  ) : (
854
880
  <L4ScrollArea
855
- className="min-h-0 flex-1"
856
- viewportClassName="py-1.5"
881
+ className={cn(styles.tree, 'min-h-0 flex-1')}
882
+ viewportClassName="py-0.5"
857
883
  viewportRef={scrollRef}
858
884
  onViewportScroll={(event) => runtime.setTreeScrollTop(event.currentTarget.scrollTop)}
859
885
  >
@@ -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, useSyncExternalStore } from 'react'
4
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } 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'
@@ -23,6 +23,7 @@ interface L3ProjectFileTabsProps {
23
23
  activePath: string | null
24
24
  diffPath: string | null
25
25
  mobile: boolean
26
+ actions?: ReactNode
26
27
  onActivate: (path: string) => void
27
28
  onClose: (path: string | null, scope: L3FileTabCloseScope) => void
28
29
  }
@@ -49,6 +50,7 @@ export function L3ProjectFileTabs({
49
50
  activePath,
50
51
  diffPath,
51
52
  mobile,
53
+ actions,
52
54
  onActivate,
53
55
  onClose
54
56
  }: L3ProjectFileTabsProps): React.JSX.Element | null {
@@ -60,7 +62,9 @@ export function L3ProjectFileTabs({
60
62
  const [expanded, setExpanded] = useState(false)
61
63
  const [overflow, setOverflow] = useState(false)
62
64
  const [clippedIndexes, setClippedIndexes] = useState<readonly number[]>([])
63
- const maxHeight = (mobile ? 2 : 3) * 36
65
+ const merged = actions !== undefined
66
+ const rowHeight = merged && !mobile ? 32 : 36
67
+ const maxHeight = (mobile ? 2 : 3) * rowHeight
64
68
  const tabs: { path: string | null; label: string; title: string }[] = paths.map((path) => ({
65
69
  path,
66
70
  label: fileLabel(path, paths),
@@ -95,8 +99,14 @@ export function L3ProjectFileTabs({
95
99
 
96
100
  if (tabs.length === 0) return null
97
101
  return (
98
- <div className="shrink-0 border-b bg-muted" data-testid="file-tabs">
99
- <div className="overflow-hidden" style={{ maxHeight: expanded ? undefined : maxHeight }}>
102
+ <div
103
+ className={cn('shrink-0 bg-muted', merged ? 'flex flex-wrap items-start' : 'border-b')}
104
+ data-testid="file-tabs"
105
+ >
106
+ <div
107
+ className={cn('overflow-hidden', merged && 'min-w-0 flex-[1_1_12rem]')}
108
+ style={{ maxHeight: expanded ? undefined : maxHeight }}
109
+ >
100
110
  <div
101
111
  ref={rowsRef}
102
112
  role="tablist"
@@ -137,7 +147,8 @@ export function L3ProjectFileTabs({
137
147
  inert={clipped || undefined}
138
148
  title={tab.title}
139
149
  className={cn(
140
- 'flex h-9 max-w-full items-center border-r border-b text-sm',
150
+ 'flex max-w-full items-center text-sm',
151
+ merged ? (mobile ? 'h-9' : 'h-8') : 'h-9 border-r border-b',
141
152
  mobile ? 'w-[min(50%,12rem)]' : 'max-w-60',
142
153
  clipped && 'invisible',
143
154
  selected
@@ -154,7 +165,7 @@ export function L3ProjectFileTabs({
154
165
  type="button"
155
166
  role="tab"
156
167
  aria-selected={selected}
157
- className="flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2 pl-3 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
168
+ className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 pl-3 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
158
169
  onClick={() => {
159
170
  if (suppressTouchClickRef.current) {
160
171
  suppressTouchClickRef.current = false
@@ -260,6 +271,15 @@ export function L3ProjectFileTabs({
260
271
  })}
261
272
  </div>
262
273
  </div>
274
+ {merged && actions ? (
275
+ <div
276
+ className="ml-auto flex min-h-8 max-w-full flex-wrap items-center justify-end gap-1 px-1"
277
+ role="group"
278
+ aria-label={t('fileActions')}
279
+ >
280
+ {actions}
281
+ </div>
282
+ ) : null}
263
283
  {overflow ? (
264
284
  <Button
265
285
  variant="ghost"
@@ -59,65 +59,54 @@ export function L3ProjectSearchControls({
59
59
  )
60
60
 
61
61
  return (
62
- <div className="shrink-0 border-b px-2.5 py-2">
63
- <div className="rounded-lg border border-input bg-card p-1 focus-within:border-ring focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-ring">
64
- <div className="flex min-h-7 items-center justify-between gap-1">
65
- <div
66
- className="flex min-w-0 items-center gap-1"
67
- role="radiogroup"
68
- aria-label={t('searchScope')}
69
- >
70
- {SCOPES.map((scope, index) => (
71
- <Button
72
- key={scope}
73
- type="button"
74
- role="radio"
75
- aria-checked={input.scope === scope}
76
- tabIndex={input.scope === scope ? 0 : -1}
77
- disabled={composition !== null}
78
- variant="ghost"
79
- size="sm"
80
- data-testid={`project-search-scope-${scope}`}
81
- className={cn(
82
- 'min-h-7 px-2 text-xs',
83
- input.scope === scope && 'bg-accent text-accent-foreground'
84
- )}
85
- onClick={() => onOptionsChange({ scope })}
86
- onKeyDown={(event) => {
87
- const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
88
- const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'
89
- if (!forward && !backward && event.key !== 'Home' && event.key !== 'End') return
90
- event.preventDefault()
91
- const next =
92
- event.key === 'Home'
93
- ? 0
94
- : event.key === 'End'
95
- ? 2
96
- : (index + (forward ? 1 : 2)) % 3
97
- onOptionsChange({ scope: SCOPES[next] })
98
- event.currentTarget.parentElement
99
- ?.querySelectorAll<HTMLButtonElement>('[role="radio"]')
100
- [next]?.focus()
101
- }}
102
- >
103
- {t(
104
- scope === 'all'
105
- ? 'searchScopeAll'
106
- : scope === 'files'
107
- ? 'searchScopeFiles'
108
- : 'searchScopeContent'
109
- )}
110
- </Button>
111
- ))}
112
- </div>
62
+ <div className="shrink-0 px-1 pt-1">
63
+ <InputGroup className="rounded-md">
64
+ <InputGroupAddon className="pl-1.5 py-0">
65
+ <SearchIcon className="size-3.5" />
66
+ </InputGroupAddon>
67
+ <InputGroupInput
68
+ ref={inputRef}
69
+ data-testid="project-file-search"
70
+ className="min-h-7 min-w-0 px-1.5 py-0.5 text-sm placeholder:text-xs"
71
+ value={composition ?? input.query}
72
+ placeholder={placeholder}
73
+ aria-label={placeholder}
74
+ aria-invalid={invalid || undefined}
75
+ autoComplete="off"
76
+ spellCheck={false}
77
+ onChange={(event) => {
78
+ if (composing.current) setComposition(event.target.value)
79
+ else onQueryChange(event.target.value)
80
+ }}
81
+ onCompositionStart={(event) => {
82
+ composing.current = true
83
+ setComposition(event.currentTarget.value)
84
+ onComposingChange(true)
85
+ }}
86
+ onCompositionEnd={(event) => {
87
+ composing.current = false
88
+ setComposition(null)
89
+ onQueryChange(event.currentTarget.value)
90
+ onComposingChange(false)
91
+ }}
92
+ onKeyDown={(event) => {
93
+ if (event.nativeEvent.isComposing || composing.current) return
94
+ if (event.key === 'Enter') {
95
+ event.preventDefault()
96
+ onOpenFirst()
97
+ } else if (event.key === 'ArrowDown') {
98
+ event.preventDefault()
99
+ onFocusResults()
100
+ }
101
+ }}
102
+ />
103
+ <InputGroupAddon align="inline-end" className="pr-0.5 py-0 has-[>button]:mr-0">
113
104
  <Tooltip>
114
105
  <TooltipTrigger
115
106
  render={
116
- <Button
117
- type="button"
118
- variant="ghost"
119
- size="icon-sm"
120
- className="size-7 shrink-0"
107
+ <InputGroupButton
108
+ size="icon-xs"
109
+ className="min-h-7"
121
110
  aria-label={t('closeSearch')}
122
111
  onClick={onClose}
123
112
  />
@@ -127,74 +116,80 @@ export function L3ProjectSearchControls({
127
116
  </TooltipTrigger>
128
117
  <TooltipContent>{t('closeSearch')}</TooltipContent>
129
118
  </Tooltip>
130
- </div>
131
- <InputGroup className="min-h-8 rounded-none border-0 bg-transparent has-[[data-slot=input-group-control]:focus-visible]:outline-none">
132
- <InputGroupAddon className="pl-1.5 py-0">
133
- <SearchIcon className="size-3.5" />
134
- </InputGroupAddon>
135
- <InputGroupInput
136
- ref={inputRef}
137
- data-testid="project-file-search"
138
- className="min-w-0 px-1.5 text-sm placeholder:text-xs"
139
- value={composition ?? input.query}
140
- placeholder={placeholder}
141
- aria-label={placeholder}
142
- aria-invalid={invalid || undefined}
143
- autoComplete="off"
144
- spellCheck={false}
145
- onChange={(event) => {
146
- if (composing.current) setComposition(event.target.value)
147
- else onQueryChange(event.target.value)
148
- }}
149
- onCompositionStart={(event) => {
150
- composing.current = true
151
- setComposition(event.currentTarget.value)
152
- onComposingChange(true)
153
- }}
154
- onCompositionEnd={(event) => {
155
- composing.current = false
156
- setComposition(null)
157
- onQueryChange(event.currentTarget.value)
158
- onComposingChange(false)
159
- }}
160
- onKeyDown={(event) => {
161
- if (event.nativeEvent.isComposing || composing.current) return
162
- if (event.key === 'Enter') {
119
+ </InputGroupAddon>
120
+ </InputGroup>
121
+ <div className="flex min-h-7 flex-wrap items-center justify-between gap-x-1">
122
+ <div className="flex min-w-0 items-center" role="radiogroup" aria-label={t('searchScope')}>
123
+ {SCOPES.map((scope, index) => (
124
+ <Button
125
+ key={scope}
126
+ type="button"
127
+ role="radio"
128
+ aria-checked={input.scope === scope}
129
+ tabIndex={input.scope === scope ? 0 : -1}
130
+ disabled={composition !== null}
131
+ variant="ghost"
132
+ size="sm"
133
+ data-testid={`project-search-scope-${scope}`}
134
+ className={cn(
135
+ 'px-1 text-xs',
136
+ input.scope === scope && 'bg-accent text-accent-foreground'
137
+ )}
138
+ onClick={() => onOptionsChange({ scope })}
139
+ onKeyDown={(event) => {
140
+ const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown'
141
+ const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp'
142
+ if (!forward && !backward && event.key !== 'Home' && event.key !== 'End') return
163
143
  event.preventDefault()
164
- onOpenFirst()
165
- } else if (event.key === 'ArrowDown') {
166
- event.preventDefault()
167
- onFocusResults()
168
- }
169
- }}
170
- />
171
- <InputGroupAddon align="inline-end" className="gap-0 pr-1 py-0">
172
- {OPTIONS.map(({ key, label, text }) => (
173
- <Tooltip key={key}>
174
- <TooltipTrigger
175
- render={
176
- <InputGroupButton
177
- size="icon-xs"
178
- disabled={composition !== null}
179
- aria-label={t(label)}
180
- aria-pressed={input[key]}
181
- data-testid={`project-search-option-${key}`}
182
- className={cn(
183
- 'border border-transparent font-mono text-xs text-muted-foreground',
184
- key === 'wholeWord' && 'underline underline-offset-4',
185
- input[key] && 'border-input bg-accent text-accent-foreground'
186
- )}
187
- onClick={() => onOptionsChange({ [key]: !input[key] })}
188
- />
189
- }
190
- >
191
- {text}
192
- </TooltipTrigger>
193
- <TooltipContent>{t(label)}</TooltipContent>
194
- </Tooltip>
195
- ))}
196
- </InputGroupAddon>
197
- </InputGroup>
144
+ const next =
145
+ event.key === 'Home'
146
+ ? 0
147
+ : event.key === 'End'
148
+ ? 2
149
+ : (index + (forward ? 1 : 2)) % 3
150
+ onOptionsChange({ scope: SCOPES[next] })
151
+ event.currentTarget.parentElement
152
+ ?.querySelectorAll<HTMLButtonElement>('[role="radio"]')
153
+ [next]?.focus()
154
+ }}
155
+ >
156
+ {t(
157
+ scope === 'all'
158
+ ? 'searchScopeAll'
159
+ : scope === 'files'
160
+ ? 'searchScopeFiles'
161
+ : 'searchScopeContent'
162
+ )}
163
+ </Button>
164
+ ))}
165
+ </div>
166
+ <div className="ml-auto flex items-center">
167
+ {OPTIONS.map(({ key, label, text }) => (
168
+ <Tooltip key={key}>
169
+ <TooltipTrigger
170
+ render={
171
+ <Button
172
+ type="button"
173
+ variant="ghost"
174
+ size="icon-sm"
175
+ disabled={composition !== null}
176
+ aria-label={t(label)}
177
+ aria-pressed={input[key]}
178
+ data-testid={`project-search-option-${key}`}
179
+ className={cn(
180
+ 'font-mono text-xs text-muted-foreground',
181
+ key === 'wholeWord' && 'underline underline-offset-4'
182
+ )}
183
+ onClick={() => onOptionsChange({ [key]: !input[key] })}
184
+ />
185
+ }
186
+ >
187
+ {text}
188
+ </TooltipTrigger>
189
+ <TooltipContent>{t(label)}</TooltipContent>
190
+ </Tooltip>
191
+ ))}
192
+ </div>
198
193
  </div>
199
194
  </div>
200
195
  )