Skip to content

Commit 40390db

Browse files
committed
Refactor ghost text logic into a dedicated use_ghost_text hook to improve component separation and maintainability
1 parent bb4ff64 commit 40390db

4 files changed

Lines changed: 188 additions & 145 deletions

File tree

‎packages/ui/src/components/editor/panel/PromptField/PromptField.tsx‎

Lines changed: 8 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Icon } from '../../common/Icon'
55
import { get_highlighted_text } from './utils/get-highlighted-text'
66
import { use_handlers } from './hooks/use-handlers'
77
import { use_dropdown } from './hooks/use-dropdown'
8+
import { use_ghost_text } from './hooks/use-ghost-text'
89
import { use_drag_drop } from './hooks/use-drag-drop'
910
import { DropdownMenu } from '../../common/DropdownMenu'
1011
import { use_is_narrow_viewport, use_is_mac } from '@shared/hooks'
@@ -77,12 +78,7 @@ export type PromptFieldProps = {
7778

7879
export const PromptField: React.FC<PromptFieldProps> = (props) => {
7980
const input_ref = useRef<HTMLDivElement>(null)
80-
const container_ref = useRef<HTMLDivElement>(null)
81-
const ghost_text_debounce_timer_ref = useRef<ReturnType<
82-
typeof setTimeout
83-
> | null>(null)
8481
const [caret_position, set_caret_position] = useState(0)
85-
const [ghost_text, set_ghost_text] = useState('')
8682
const [show_at_sign_tooltip, set_show_at_sign_tooltip] = useState(false)
8783
const [show_submit_tooltip, set_show_submit_tooltip] = useState(false)
8884
const [is_text_selecting, set_is_text_selecting] = useState(false)
@@ -95,6 +91,13 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
9591
handle_copy_click,
9692
handle_select_click
9793
} = use_dropdown(props)
94+
const { ghost_text, handle_accept_ghost_text } = use_ghost_text({
95+
value: props.value,
96+
input_ref,
97+
is_focused,
98+
currently_open_file_text: props.currently_open_file_text,
99+
caret_position
100+
})
98101
const {
99102
handle_clear,
100103
handle_input_change,
@@ -114,31 +117,6 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
114117

115118
const mouse_down_pos_ref = useRef<{ x: number; y: number } | null>(null)
116119

117-
function handle_accept_ghost_text() {
118-
if (!ghost_text || !input_ref.current) return
119-
120-
const ghost_node = input_ref.current.querySelector(
121-
'span[data-type="ghost-text"]'
122-
)
123-
if (ghost_node) {
124-
const text_node = document.createTextNode(ghost_node.textContent || '')
125-
ghost_node.parentNode?.replaceChild(text_node, ghost_node)
126-
127-
const selection = window.getSelection()
128-
if (selection) {
129-
const range = document.createRange()
130-
range.setStartAfter(text_node)
131-
range.collapse(true)
132-
selection.removeAllRanges()
133-
selection.addRange(range)
134-
}
135-
136-
input_ref.current.dispatchEvent(
137-
new Event('input', { bubbles: true, cancelable: true })
138-
)
139-
}
140-
}
141-
142120
const handle_mouse_down = useCallback(
143121
(e: React.MouseEvent<HTMLDivElement>) => {
144122
mouse_down_pos_ref.current = { x: e.clientX, y: e.clientY }
@@ -228,86 +206,6 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
228206
props.context_file_paths
229207
])
230208

231-
const identifiers = useMemo(() => {
232-
if (!props.currently_open_file_text) return new Set<string>()
233-
const matches =
234-
props.currently_open_file_text.match(/[a-zA-Z_][a-zA-Z0-9_]*/g)
235-
if (!matches) return new Set<string>()
236-
return new Set(matches.filter((id) => id.length >= 3))
237-
}, [props.currently_open_file_text])
238-
239-
useEffect(() => {
240-
// Reset timer on any change
241-
if (ghost_text_debounce_timer_ref.current) {
242-
clearTimeout(ghost_text_debounce_timer_ref.current)
243-
ghost_text_debounce_timer_ref.current = null
244-
}
245-
246-
// Calculate potential ghost text
247-
let potential_ghost_text = ''
248-
249-
if (input_ref.current && is_focused) {
250-
const selection = window.getSelection()
251-
if (selection && selection.rangeCount > 0) {
252-
const range = selection.getRangeAt(0)
253-
if (range.collapsed) {
254-
// Check if there's a character on the right side of the caret
255-
const post_caret_range = range.cloneRange()
256-
post_caret_range.selectNodeContents(input_ref.current)
257-
post_caret_range.setStart(range.endContainer, range.endOffset)
258-
const text_after_cursor = post_caret_range.toString()
259-
260-
// Only allow ghost text if there's nothing after cursor or only whitespace
261-
if (text_after_cursor.trim() === '') {
262-
const pre_caret_range = range.cloneRange()
263-
pre_caret_range.selectNodeContents(input_ref.current)
264-
pre_caret_range.setEnd(range.startContainer, range.startOffset)
265-
const text_before_cursor = pre_caret_range.toString()
266-
const last_word_match = text_before_cursor.match(/[\S]+$/)
267-
268-
if (last_word_match) {
269-
const last_word_with_prefix = last_word_match[0]
270-
const word_start_match =
271-
last_word_with_prefix.match(/[a-zA-Z_]/)
272-
if (word_start_match) {
273-
const last_word = last_word_with_prefix.substring(
274-
word_start_match.index ?? 0
275-
)
276-
if (last_word.length >= 2) {
277-
for (const id of identifiers) {
278-
if (id.startsWith(last_word) && id !== last_word) {
279-
potential_ghost_text = id.substring(last_word.length)
280-
break
281-
}
282-
}
283-
}
284-
}
285-
}
286-
}
287-
}
288-
}
289-
}
290-
291-
// Apply debounce logic
292-
if (potential_ghost_text) {
293-
if (ghost_text) {
294-
set_ghost_text(potential_ghost_text)
295-
} else {
296-
ghost_text_debounce_timer_ref.current = setTimeout(() => {
297-
set_ghost_text(potential_ghost_text)
298-
}, 500)
299-
}
300-
} else {
301-
set_ghost_text('')
302-
}
303-
304-
return () => {
305-
if (ghost_text_debounce_timer_ref.current) {
306-
clearTimeout(ghost_text_debounce_timer_ref.current)
307-
}
308-
}
309-
}, [props.value, caret_position, identifiers, is_focused, ghost_text])
310-
311209
useEffect(() => {
312210
if (input_ref.current && input_ref.current.innerHTML !== highlighted_html) {
313211
const selection_start = get_caret_position_from_div(input_ref.current)
@@ -383,37 +281,6 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
383281
document.removeEventListener('selectionchange', on_selection_change)
384282
}, [props.on_caret_position_change, props.value, props.context_file_paths])
385283

386-
useEffect(() => {
387-
const input = input_ref.current
388-
if (!input) return
389-
390-
const existing_ghost = input.querySelector('span[data-type="ghost-text"]')
391-
if (existing_ghost) {
392-
const parent = existing_ghost.parentNode
393-
if (parent) {
394-
parent.removeChild(existing_ghost)
395-
parent.normalize()
396-
}
397-
}
398-
399-
if (ghost_text) {
400-
const selection = window.getSelection()
401-
if (selection && selection.rangeCount > 0) {
402-
const range = selection.getRangeAt(0)
403-
if (range.collapsed) {
404-
const span = document.createElement('span')
405-
span.dataset.type = 'ghost-text'
406-
span.textContent = ghost_text
407-
span.style.color = 'var(--vscode-editorGhostText-foreground, #888)'
408-
span.style.pointerEvents = 'none'
409-
410-
range.insertNode(span)
411-
selection.collapse(range.startContainer, range.startOffset)
412-
}
413-
}
414-
}
415-
}, [ghost_text])
416-
417284
const placeholder = useMemo(() => {
418285
const active_history = props.chat_history
419286

@@ -475,7 +342,6 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
475342
}
476343
}
477344
}}
478-
ref={container_ref}
479345
onClick={() => input_ref.current?.focus()}
480346
>
481347
{props.value && (
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import {
2+
useState,
3+
useRef,
4+
useEffect,
5+
RefObject,
6+
useMemo,
7+
useCallback
8+
} from 'react'
9+
10+
type UseGhostTextParams = {
11+
value: string
12+
input_ref: RefObject<HTMLDivElement>
13+
is_focused: boolean
14+
currently_open_file_text?: string
15+
caret_position: number
16+
}
17+
18+
export const use_ghost_text = ({
19+
value,
20+
input_ref,
21+
is_focused,
22+
currently_open_file_text,
23+
caret_position
24+
}: UseGhostTextParams) => {
25+
const [ghost_text, set_ghost_text] = useState('')
26+
const ghost_text_debounce_timer_ref = useRef<ReturnType<
27+
typeof setTimeout
28+
> | null>(null)
29+
const [can_show_ghost_text, set_can_show_ghost_text] = useState(false)
30+
const prev_is_focused_ref = useRef(is_focused)
31+
const initial_value_on_focus_ref = useRef('')
32+
33+
useEffect(() => {
34+
if (is_focused && !prev_is_focused_ref.current) {
35+
set_can_show_ghost_text(false)
36+
initial_value_on_focus_ref.current = value
37+
} else if (
38+
is_focused &&
39+
!can_show_ghost_text &&
40+
value !== initial_value_on_focus_ref.current
41+
) {
42+
set_can_show_ghost_text(true)
43+
}
44+
prev_is_focused_ref.current = is_focused
45+
}, [is_focused, value, can_show_ghost_text])
46+
47+
const identifiers = useMemo(() => {
48+
if (!currently_open_file_text) return new Set<string>()
49+
const matches =
50+
currently_open_file_text.match(/[a-zA-Z_][a-zA-Z0-9_]*/g)
51+
if (!matches) return new Set<string>()
52+
return new Set(matches.filter((id) => id.length >= 3))
53+
}, [currently_open_file_text])
54+
55+
useEffect(() => {
56+
if (ghost_text_debounce_timer_ref.current) {
57+
clearTimeout(ghost_text_debounce_timer_ref.current)
58+
ghost_text_debounce_timer_ref.current = null
59+
}
60+
61+
let potential_ghost_text = ''
62+
63+
if (input_ref.current && is_focused && can_show_ghost_text) {
64+
const selection = window.getSelection()
65+
if (selection && selection.rangeCount > 0) {
66+
const range = selection.getRangeAt(0)
67+
if (range.collapsed) {
68+
const pre_caret_range = range.cloneRange()
69+
pre_caret_range.selectNodeContents(input_ref.current)
70+
pre_caret_range.setEnd(range.startContainer, range.startOffset)
71+
const text_before_cursor = pre_caret_range.toString()
72+
const last_word_match = text_before_cursor.match(/[\S]+$/)
73+
74+
if (last_word_match) {
75+
const last_word_with_prefix = last_word_match[0]
76+
const word_start_match =
77+
last_word_with_prefix.match(/[a-zA-Z_]/)
78+
if (word_start_match) {
79+
const last_word = last_word_with_prefix.substring(
80+
word_start_match.index ?? 0
81+
)
82+
if (last_word.length >= 2) {
83+
for (const id of identifiers) {
84+
if (id.startsWith(last_word) && id !== last_word) {
85+
potential_ghost_text = id.substring(last_word.length)
86+
break
87+
}
88+
}
89+
}
90+
}
91+
}
92+
}
93+
}
94+
}
95+
96+
if (potential_ghost_text) {
97+
if (ghost_text) {
98+
set_ghost_text(potential_ghost_text)
99+
} else {
100+
ghost_text_debounce_timer_ref.current = setTimeout(() => {
101+
set_ghost_text(potential_ghost_text)
102+
}, 500)
103+
}
104+
} else {
105+
set_ghost_text('')
106+
}
107+
108+
return () => {
109+
if (ghost_text_debounce_timer_ref.current) {
110+
clearTimeout(ghost_text_debounce_timer_ref.current)
111+
}
112+
}
113+
}, [
114+
value,
115+
caret_position,
116+
identifiers,
117+
is_focused,
118+
ghost_text,
119+
input_ref,
120+
can_show_ghost_text
121+
])
122+
123+
useEffect(() => {
124+
const input = input_ref.current
125+
if (!input) return
126+
127+
const existing_ghost = input.querySelector('span[data-type="ghost-text"]')
128+
if (existing_ghost) {
129+
const parent = existing_ghost.parentNode
130+
if (parent) {
131+
parent.removeChild(existing_ghost)
132+
parent.normalize()
133+
}
134+
}
135+
136+
if (ghost_text) {
137+
const selection = window.getSelection()
138+
if (selection && selection.rangeCount > 0) {
139+
const range = selection.getRangeAt(0)
140+
if (range.collapsed) {
141+
const span = document.createElement('span')
142+
span.dataset.type = 'ghost-text'
143+
span.textContent = ghost_text
144+
span.style.color = 'var(--vscode-editorGhostText-foreground, #888)'
145+
span.style.pointerEvents = 'none'
146+
147+
range.insertNode(span)
148+
selection.collapse(range.startContainer, range.startOffset)
149+
}
150+
}
151+
}
152+
}, [ghost_text, input_ref])
153+
154+
const handle_accept_ghost_text = useCallback(() => {
155+
if (!ghost_text || !input_ref.current) return
156+
157+
const ghost_node = input_ref.current.querySelector(
158+
'span[data-type="ghost-text"]'
159+
)
160+
if (ghost_node) {
161+
const text_node = document.createTextNode(ghost_node.textContent || '')
162+
ghost_node.parentNode?.replaceChild(text_node, ghost_node)
163+
164+
const selection = window.getSelection()
165+
if (selection) {
166+
const range = document.createRange()
167+
range.setStartAfter(text_node)
168+
range.collapse(true)
169+
selection.removeAllRanges()
170+
selection.addRange(range)
171+
}
172+
173+
input_ref.current.dispatchEvent(
174+
new Event('input', { bubbles: true, cancelable: true })
175+
)
176+
}
177+
}, [ghost_text, input_ref])
178+
179+
return { ghost_text, handle_accept_ghost_text }
180+
}

‎packages/ui/src/components/editor/panel/PromptField/hooks/use-handlers.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,12 +746,10 @@ export const use_handlers = (
746746
let is_before_ghost = false
747747

748748
if (startContainer === ghost_node.previousSibling) {
749-
// Caret is at the end of the text node before ghost
750749
if (startContainer.nodeType === Node.TEXT_NODE) {
751750
is_before_ghost = startOffset === startContainer.textContent?.length
752751
}
753752
} else if (startContainer === ghost_node.parentNode) {
754-
// Caret is in the parent, check if it's right before ghost node
755753
const ghost_index = Array.from(startContainer.childNodes).indexOf(ghost_node as ChildNode)
756754
is_before_ghost = startOffset === ghost_index
757755
}

0 commit comments

Comments
 (0)