dsh-explorer 0.0.0-stage → 0.1.1

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 (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +106 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +7 -0
  5. package/lib/client.js +859 -0
  6. package/lib/index.js +854 -0
  7. package/package.json +91 -4
  8. package/src/client/ExplorerView.tsx +981 -0
  9. package/src/client/components/ExplorerContextMenu.tsx +157 -0
  10. package/src/client/components/ExplorerDeleteModal.tsx +124 -0
  11. package/src/client/components/ExplorerEmptyContextMenu.tsx +125 -0
  12. package/src/client/components/ExplorerHeader.tsx +81 -0
  13. package/src/client/components/ExplorerOptionsModal.tsx +230 -0
  14. package/src/client/components/ExplorerRow.tsx +258 -0
  15. package/src/client/components/ExplorerSearch.tsx +55 -0
  16. package/src/client/components/TreeNode.tsx +384 -0
  17. package/src/client/css-modules.d.ts +5 -0
  18. package/src/client/explorer.module.css +833 -0
  19. package/src/client/hooks/useKeyboardNav.ts +89 -0
  20. package/src/client/hooks/useLiveSync.ts +81 -0
  21. package/src/client/index.tsx +238 -0
  22. package/src/client/utils/api.ts +251 -0
  23. package/src/client/utils/conversation-draft.ts +644 -0
  24. package/src/client/utils/drag-drop.ts +209 -0
  25. package/src/client/utils/open-tab.ts +69 -0
  26. package/src/client/utils/options.ts +59 -0
  27. package/src/client/utils/vscode-theme.tsx +250 -0
  28. package/src/config.ts +11 -0
  29. package/src/fs-operations.ts +116 -0
  30. package/src/fs-search.ts +116 -0
  31. package/src/fs-tree.ts +155 -0
  32. package/src/fs-watch.ts +179 -0
  33. package/src/git-status.ts +167 -0
  34. package/src/index.ts +177 -0
  35. package/src/path-security.ts +22 -0
  36. package/src/session-path.ts +35 -0
  37. package/src/wire.ts +63 -0
@@ -0,0 +1,230 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import css from '../explorer.module.css'
3
+ import type { ExplorerOptions, ExplorerDensity, SingleClickAction } from '../utils/options.ts'
4
+
5
+ interface ExplorerOptionsModalProps {
6
+ options: ExplorerOptions
7
+ onChange: (updated: Partial<ExplorerOptions>) => void
8
+ onReset: () => void
9
+ onClose: () => void
10
+ }
11
+
12
+ export const ExplorerOptionsModal: React.FC<ExplorerOptionsModalProps> = ({
13
+ options,
14
+ onChange,
15
+ onReset,
16
+ onClose,
17
+ }) => {
18
+ const popoverRef = useRef<HTMLDivElement>(null)
19
+
20
+ useEffect(() => {
21
+ const handleMouseDown = (e: MouseEvent) => {
22
+ if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
23
+ onClose()
24
+ }
25
+ }
26
+ const handleKeyDown = (e: KeyboardEvent) => {
27
+ if (e.key === 'Escape') {
28
+ onClose()
29
+ }
30
+ }
31
+
32
+ document.addEventListener('mousedown', handleMouseDown, true)
33
+ document.addEventListener('keydown', handleKeyDown, true)
34
+ return () => {
35
+ document.removeEventListener('mousedown', handleMouseDown, true)
36
+ document.removeEventListener('keydown', handleKeyDown, true)
37
+ }
38
+ }, [onClose])
39
+
40
+ const handleDensityChange = (density: ExplorerDensity) => {
41
+ onChange({ density })
42
+ }
43
+
44
+ const handleScaleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ const scale = parseFloat(e.target.value)
46
+ if (!Number.isNaN(scale)) {
47
+ onChange({ scale: Math.round(scale * 100) / 100 })
48
+ }
49
+ }
50
+
51
+ const handleActionChange = (action: SingleClickAction) => {
52
+ onChange({ singleClickAction: action })
53
+ }
54
+
55
+ return (
56
+ <div ref={popoverRef} className={css.optionsPopover}>
57
+ <div className={css.optionsHeader}>
58
+ <div className={css.optionsHeaderTitle}>
59
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor" style={{ marginRight: '6px' }}>
60
+ <path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z" />
61
+ </svg>
62
+ EXPLORER OPTIONS
63
+ </div>
64
+ <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
65
+ <button className={css.optionsIconBtn} title="Reset to defaults" onClick={onReset}>
66
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
67
+ <path d="M13.65 2.35A7.958 7.958 0 0 0 8 0a8 8 0 1 0 8 8h-2a6 6 0 1 1-1.76-4.24l-2.24 2.24H16V0l-2.35 2.35z" />
68
+ </svg>
69
+ </button>
70
+ <button className={css.optionsIconBtn} title="Close" onClick={onClose}>
71
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
72
+ <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
73
+ </svg>
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <div className={css.optionsBody}>
79
+ <div className={css.optionsSection}>
80
+ <div className={css.optionsSectionTitle}>Scale & Density</div>
81
+ <div className={css.segmentGroup}>
82
+ <button
83
+ type="button"
84
+ className={`${css.segmentBtn} ${options.density === 'compact' ? css.segmentBtnActive : ''}`}
85
+ onClick={() => handleDensityChange('compact')}
86
+ >
87
+ Compact
88
+ </button>
89
+ <button
90
+ type="button"
91
+ className={`${css.segmentBtn} ${options.density === 'normal' ? css.segmentBtnActive : ''}`}
92
+ onClick={() => handleDensityChange('normal')}
93
+ >
94
+ Normal
95
+ </button>
96
+ <button
97
+ type="button"
98
+ className={`${css.segmentBtn} ${options.density === 'relaxed' ? css.segmentBtnActive : ''}`}
99
+ onClick={() => handleDensityChange('relaxed')}
100
+ >
101
+ Relaxed
102
+ </button>
103
+ </div>
104
+
105
+ <div className={css.optionsRow} style={{ marginTop: '6px' }}>
106
+ <div className={css.optionsLabelWrap}>
107
+ <span className={css.optionsLabel}>Zoom Scale</span>
108
+ <span className={css.optionsSubLabel}>Resize tree text and icons</span>
109
+ </div>
110
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
111
+ <span className={css.optionsBadge}>{Math.round(options.scale * 100)}%</span>
112
+ {options.scale !== 1.0 && (
113
+ <button
114
+ type="button"
115
+ className={css.optionsMiniBtn}
116
+ title="Reset scale to 100%"
117
+ onClick={() => onChange({ scale: 1.0 })}
118
+ >
119
+ 1x
120
+ </button>
121
+ )}
122
+ </div>
123
+ </div>
124
+ <input
125
+ type="range"
126
+ min="0.75"
127
+ max="1.35"
128
+ step="0.05"
129
+ value={options.scale}
130
+ onChange={handleScaleChange}
131
+ className={css.optionsSlider}
132
+ />
133
+ </div>
134
+
135
+ <div className={css.optionsSection}>
136
+ <div className={css.optionsSectionTitle}>Motion & Transitions</div>
137
+ <div className={css.optionsRow}>
138
+ <div className={css.optionsLabelWrap}>
139
+ <span className={css.optionsLabel}>Smooth Animations</span>
140
+ <span className={css.optionsSubLabel}>Fluid folder collapse & icon turns</span>
141
+ </div>
142
+ <div
143
+ className={`${css.switch} ${options.enableMotion ? css.switchActive : ''}`}
144
+ onClick={() => onChange({ enableMotion: !options.enableMotion })}
145
+ role="switch"
146
+ aria-checked={options.enableMotion}
147
+ >
148
+ <div className={`${css.switchKnob} ${options.enableMotion ? css.switchKnobActive : ''}`} />
149
+ </div>
150
+ </div>
151
+ </div>
152
+
153
+ <div className={css.optionsSection}>
154
+ <div className={css.optionsSectionTitle}>Workspace View</div>
155
+
156
+ <div className={css.optionsRow}>
157
+ <div className={css.optionsLabelWrap}>
158
+ <span className={css.optionsLabel}>Show Hidden Files</span>
159
+ <span className={css.optionsSubLabel}>Include dotfiles and config directories</span>
160
+ </div>
161
+ <div
162
+ className={`${css.switch} ${options.showHiddenFiles ? css.switchActive : ''}`}
163
+ onClick={() => onChange({ showHiddenFiles: !options.showHiddenFiles })}
164
+ role="switch"
165
+ aria-checked={options.showHiddenFiles}
166
+ >
167
+ <div className={`${css.switchKnob} ${options.showHiddenFiles ? css.switchKnobActive : ''}`} />
168
+ </div>
169
+ </div>
170
+
171
+ <div className={css.optionsRow}>
172
+ <div className={css.optionsLabelWrap}>
173
+ <span className={css.optionsLabel}>Git Status Indicators</span>
174
+ <span className={css.optionsSubLabel}>Highlight modified & untracked files</span>
175
+ </div>
176
+ <div
177
+ className={`${css.switch} ${options.showGitBadges ? css.switchActive : ''}`}
178
+ onClick={() => onChange({ showGitBadges: !options.showGitBadges })}
179
+ role="switch"
180
+ aria-checked={options.showGitBadges}
181
+ >
182
+ <div className={`${css.switchKnob} ${options.showGitBadges ? css.switchKnobActive : ''}`} />
183
+ </div>
184
+ </div>
185
+
186
+ <div className={css.optionsRow}>
187
+ <div className={css.optionsLabelWrap}>
188
+ <span className={css.optionsLabel}>Compact Folders</span>
189
+ <span className={css.optionsSubLabel}>Chain single-child directory paths</span>
190
+ </div>
191
+ <div
192
+ className={`${css.switch} ${options.compactFolders ? css.switchActive : ''}`}
193
+ onClick={() => onChange({ compactFolders: !options.compactFolders })}
194
+ role="switch"
195
+ aria-checked={options.compactFolders}
196
+ >
197
+ <div className={`${css.switchKnob} ${options.compactFolders ? css.switchKnobActive : ''}`} />
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <div className={css.optionsSection}>
203
+ <div className={css.optionsSectionTitle}>Interaction</div>
204
+ <div className={css.optionsRow}>
205
+ <div className={css.optionsLabelWrap}>
206
+ <span className={css.optionsLabel}>File Click Behavior</span>
207
+ <span className={css.optionsSubLabel}>Direct action when clicking a file</span>
208
+ </div>
209
+ </div>
210
+ <div className={css.segmentGroup} style={{ marginTop: '4px' }}>
211
+ <button
212
+ type="button"
213
+ className={`${css.segmentBtn} ${options.singleClickAction === 'select' ? css.segmentBtnActive : ''}`}
214
+ onClick={() => handleActionChange('select')}
215
+ >
216
+ Select / Focus
217
+ </button>
218
+ <button
219
+ type="button"
220
+ className={`${css.segmentBtn} ${options.singleClickAction === 'mention' ? css.segmentBtnActive : ''}`}
221
+ onClick={() => handleActionChange('mention')}
222
+ >
223
+ Insert @Mention
224
+ </button>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ )
230
+ }
@@ -0,0 +1,258 @@
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import css from '../explorer.module.css'
3
+ import type { FsEntry } from '../utils/api.ts'
4
+ import type { SingleClickAction } from '../utils/options.ts'
5
+ import { renderFileIcon } from '../utils/vscode-theme.tsx'
6
+ import { setupDragStart, setupDragEnd, type DragExplorerItem } from '../utils/drag-drop.ts'
7
+
8
+ interface ExplorerRowProps {
9
+ entry: FsEntry
10
+ depth: number
11
+ isExpanded: boolean
12
+ isSelected: boolean
13
+ isFocused: boolean
14
+ isRenaming?: boolean
15
+ gitStatus?: string
16
+ relativePath: string
17
+ compactChain?: FsEntry[]
18
+ indentStep?: number
19
+ iconSize?: number
20
+ enableMotion?: boolean
21
+ showGitBadges?: boolean
22
+ compactFolders?: boolean
23
+ singleClickAction?: SingleClickAction
24
+ onToggle: () => void
25
+ onSelect: (e: React.MouseEvent) => void
26
+ onDoubleClick?: () => void
27
+ onContextMenu: (e: React.MouseEvent) => void
28
+ onInsertReference: () => void
29
+ onCopyRelativePath: () => void
30
+ onStartRename?: () => void
31
+ onCancelRename?: () => void
32
+ onRenameSubmit: (newName: string) => void
33
+ onDelete: () => void
34
+ }
35
+
36
+ export const ExplorerRow: React.FC<ExplorerRowProps> = ({
37
+ entry,
38
+ depth,
39
+ isExpanded,
40
+ isSelected,
41
+ isFocused,
42
+ isRenaming = false,
43
+ gitStatus,
44
+ relativePath,
45
+ compactChain,
46
+ indentStep = 16,
47
+ iconSize = 16,
48
+ enableMotion = true,
49
+ showGitBadges = true,
50
+ compactFolders = true,
51
+ singleClickAction = 'select',
52
+ onToggle,
53
+ onSelect,
54
+ onDoubleClick,
55
+ onContextMenu,
56
+ onInsertReference,
57
+ onCopyRelativePath,
58
+ onStartRename,
59
+ onCancelRename,
60
+ onRenameSubmit,
61
+ onDelete,
62
+ }) => {
63
+ const [renameVal, setRenameVal] = useState(entry.name)
64
+ const inputRef = useRef<HTMLInputElement>(null)
65
+
66
+ useEffect(() => {
67
+ if (isRenaming) {
68
+ setRenameVal(entry.name)
69
+ requestAnimationFrame(() => {
70
+ if (inputRef.current) {
71
+ inputRef.current.focus()
72
+ const dotIndex = entry.isDir ? -1 : entry.name.lastIndexOf('.')
73
+ if (dotIndex > 0) {
74
+ inputRef.current.setSelectionRange(0, dotIndex)
75
+ } else {
76
+ inputRef.current.select()
77
+ }
78
+ }
79
+ })
80
+ }
81
+ }, [isRenaming, entry.name, entry.isDir])
82
+
83
+ const handleDragStart = (e: React.DragEvent) => {
84
+ const item: DragExplorerItem = {
85
+ path: entry.path,
86
+ relativePath,
87
+ isDir: entry.isDir,
88
+ name: entry.name,
89
+ }
90
+ setupDragStart(e, [item])
91
+ }
92
+
93
+ const handleRenameKeyDown = (e: React.KeyboardEvent) => {
94
+ if (e.key === 'Enter') {
95
+ e.stopPropagation()
96
+ const trimmed = renameVal.trim()
97
+ if (trimmed && trimmed !== entry.name) {
98
+ onRenameSubmit(trimmed)
99
+ } else {
100
+ onCancelRename?.()
101
+ }
102
+ } else if (e.key === 'Escape') {
103
+ e.stopPropagation()
104
+ setRenameVal(entry.name)
105
+ onCancelRename?.()
106
+ }
107
+ }
108
+
109
+ const handleRenameBlur = () => {
110
+ const trimmed = renameVal.trim()
111
+ if (trimmed && trimmed !== entry.name) {
112
+ onRenameSubmit(trimmed)
113
+ } else {
114
+ onCancelRename?.()
115
+ }
116
+ }
117
+
118
+ const displayName = compactFolders && compactChain && compactChain.length > 1
119
+ ? compactChain.map((c) => c.name).join('/')
120
+ : (compactFolders && entry.compactName
121
+ ? entry.compactName
122
+ : entry.name)
123
+
124
+ let gitClass = ''
125
+ let gitLetter = ''
126
+ if (gitStatus) {
127
+ if (gitStatus.includes('M')) {
128
+ gitClass = css.gitModified
129
+ gitLetter = 'M'
130
+ } else if (gitStatus.includes('?') || gitStatus.includes('A')) {
131
+ gitClass = css.gitUntracked
132
+ gitLetter = 'U'
133
+ } else if (gitStatus.includes('D')) {
134
+ gitClass = css.gitDeleted
135
+ gitLetter = 'D'
136
+ } else if (gitStatus.includes('R')) {
137
+ gitClass = css.gitModified
138
+ gitLetter = 'R'
139
+ }
140
+ }
141
+
142
+ const isIgnored = gitStatus?.includes('!')
143
+
144
+ return (
145
+ <div
146
+ className={`${css.row} ${isSelected ? css.rowSelected : ''} ${isFocused ? css.rowFocused : ''}`}
147
+ style={{ paddingLeft: `${depth * indentStep + 8}px` }}
148
+ onClick={(e) => {
149
+ if (isRenaming) return
150
+ onSelect(e)
151
+ if (entry.isDir) {
152
+ onToggle()
153
+ } else if (singleClickAction === 'mention') {
154
+ onInsertReference()
155
+ }
156
+ }}
157
+ onDoubleClick={(e) => {
158
+ if (isRenaming) return
159
+ if (!entry.isDir) {
160
+ e.stopPropagation()
161
+ onDoubleClick?.()
162
+ }
163
+ }}
164
+ onContextMenu={onContextMenu}
165
+ draggable={!isRenaming}
166
+ onDragStart={handleDragStart}
167
+ onDragEnd={setupDragEnd}
168
+ >
169
+ {Array.from({ length: depth }).map((_, i) => (
170
+ <span
171
+ key={i}
172
+ className={css.indentGuide}
173
+ style={{ left: `${i * indentStep + 15}px` }}
174
+ />
175
+ ))}
176
+
177
+ <span
178
+ className={`${css.chevron} ${isExpanded ? css.chevronExpanded : ''} ${!entry.isDir ? css.chevronHidden : ''}`}
179
+ style={!enableMotion ? { transition: 'none' } : undefined}
180
+ onClick={(e) => {
181
+ e.stopPropagation()
182
+ onToggle()
183
+ }}
184
+ >
185
+ <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor">
186
+ <path d="M6 3.5L10.5 8 6 12.5V3.5Z" />
187
+ </svg>
188
+ </span>
189
+
190
+ <span className={css.iconWrap}>
191
+ {renderFileIcon(entry.name, entry.isDir, isExpanded, iconSize)}
192
+ </span>
193
+
194
+ {isRenaming ? (
195
+ <input
196
+ ref={inputRef}
197
+ type="text"
198
+ className={css.inlineInput}
199
+ value={renameVal}
200
+ onChange={(e) => setRenameVal(e.target.value)}
201
+ onKeyDown={handleRenameKeyDown}
202
+ onBlur={handleRenameBlur}
203
+ onClick={(e) => e.stopPropagation()}
204
+ />
205
+ ) : (
206
+ <span
207
+ className={`${css.label} ${isIgnored ? css.labelDimmed : ''} ${showGitBadges ? gitClass : ''}`}
208
+ title={relativePath}
209
+ >
210
+ {displayName}
211
+ </span>
212
+ )}
213
+
214
+ {showGitBadges && gitLetter && <span className={`${css.gitBadge} ${gitClass}`}>{gitLetter}</span>}
215
+
216
+ {!isRenaming && (
217
+ <div className={css.hoverActions} onClick={(e) => e.stopPropagation()}>
218
+ <button
219
+ className={css.actionBtn}
220
+ title="Mention in Chat (@)"
221
+ onClick={(e) => {
222
+ e.stopPropagation()
223
+ onInsertReference()
224
+ }}
225
+ >
226
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
227
+ <path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2zM7 5h2v2h2v2H9v2H7V9H5V7h2V5z"/>
228
+ </svg>
229
+ </button>
230
+ <button
231
+ className={css.actionBtn}
232
+ title="Copy Relative Path"
233
+ onClick={(e) => {
234
+ e.stopPropagation()
235
+ onCopyRelativePath()
236
+ }}
237
+ >
238
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
239
+ <path d="M4 4h6v1H4v9h7v-4h1v5H3V4h1zm3-2h7v7h-1V3.7L7.7 9 7 8.3 12.3 3H7V2z"/>
240
+ </svg>
241
+ </button>
242
+ <button
243
+ className={css.actionBtn}
244
+ title="Rename (F2)"
245
+ onClick={(e) => {
246
+ e.stopPropagation()
247
+ onStartRename?.()
248
+ }}
249
+ >
250
+ <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor">
251
+ <path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
252
+ </svg>
253
+ </button>
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
@@ -0,0 +1,55 @@
1
+ import React, { useRef, useEffect } from 'react'
2
+ import css from '../explorer.module.css'
3
+
4
+ interface ExplorerSearchProps {
5
+ value: string
6
+ loading?: boolean
7
+ matchCount?: number | null
8
+ onChange: (val: string) => void
9
+ onClose: () => void
10
+ }
11
+
12
+ export const ExplorerSearch: React.FC<ExplorerSearchProps> = ({
13
+ value,
14
+ loading = false,
15
+ matchCount = null,
16
+ onChange,
17
+ onClose,
18
+ }) => {
19
+ const inputRef = useRef<HTMLInputElement>(null)
20
+
21
+ useEffect(() => {
22
+ inputRef.current?.focus()
23
+ }, [])
24
+
25
+ const handleKeyDown = (e: React.KeyboardEvent) => {
26
+ if (e.key === 'Escape') {
27
+ onClose()
28
+ }
29
+ }
30
+
31
+ return (
32
+ <div className={css.searchBar}>
33
+ <input
34
+ ref={inputRef}
35
+ type="text"
36
+ className={css.searchInput}
37
+ placeholder="Search files and folders by name..."
38
+ value={value}
39
+ onChange={(e) => onChange(e.target.value)}
40
+ onKeyDown={handleKeyDown}
41
+ />
42
+ {loading && <div className={css.searchSpinner} title="Searching workspace..." />}
43
+ {!loading && matchCount !== null && value.trim() !== '' && (
44
+ <span className={css.matchCountBadge} title={`${matchCount} matches found`}>
45
+ {matchCount}
46
+ </span>
47
+ )}
48
+ {value && (
49
+ <button className={css.clearSearchBtn} title="Clear search (Esc)" onClick={() => onChange('')}>
50
+ ✕
51
+ </button>
52
+ )}
53
+ </div>
54
+ )
55
+ }