Skip to content

Commit 26b9436

Browse files
committed
Refactor ChatInput to use raw value and HTML reconstruction for input handling, enabling proper deletion and navigation of highlighted file keywords
1 parent 9358255 commit 26b9436

3 files changed

Lines changed: 198 additions & 84 deletions

File tree

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

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,21 @@ const set_caret_position_for_div = (element: HTMLElement, position: number) => {
7373
char_count = next_char_count
7474
}
7575
} else {
76+
if (
77+
node.nodeType === Node.ELEMENT_NODE &&
78+
(node as HTMLElement).getAttribute('contenteditable') === 'false'
79+
) {
80+
const text_len = node.textContent?.length ?? 0
81+
const next_char_count = char_count + text_len
82+
if (position >= char_count && position <= next_char_count) {
83+
range.setStartAfter(node)
84+
range.collapse(true)
85+
found = true
86+
} else {
87+
char_count = next_char_count
88+
}
89+
return // Do not iterate over children
90+
}
7691
for (let i = 0; i < node.childNodes.length; i++) {
7792
find_text_node_and_offset(node.childNodes[i])
7893
if (found) break
@@ -256,13 +271,13 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
256271

257272
const highlighted_html = useMemo(() => {
258273
return get_highlighted_text({
259-
text: display_text,
274+
text: props.value,
260275
is_in_code_completions_mode: props.is_in_code_completions_mode,
261276
has_active_selection: props.has_active_selection,
262277
context_file_paths: props.context_file_paths ?? []
263278
})
264279
}, [
265-
display_text,
280+
props.value,
266281
props.is_in_code_completions_mode,
267282
props.has_active_selection,
268283
props.context_file_paths
@@ -384,29 +399,83 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
384399
}
385400
}
386401

387-
// Handle backspace on shortened filenames
388-
if (e.key == 'Backspace' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
389-
const display_value = e.currentTarget.textContent ?? ''
390-
const cursor_pos = caret_position
402+
if (e.key == 'Backspace') {
403+
const selection = window.getSelection()
404+
if (
405+
input_ref.current &&
406+
selection &&
407+
selection.rangeCount > 0 &&
408+
selection.isCollapsed
409+
) {
410+
const range = selection.getRangeAt(0)
411+
let node_before_cursor: Node | null = null
412+
413+
if (
414+
range.startContainer.nodeType === Node.TEXT_NODE &&
415+
range.startOffset === 0
416+
) {
417+
node_before_cursor = range.startContainer.previousSibling
418+
} else if (
419+
range.startContainer.nodeType === Node.ELEMENT_NODE &&
420+
range.startOffset > 0
421+
) {
422+
node_before_cursor =
423+
range.startContainer.childNodes[range.startOffset - 1]
424+
}
391425

392-
// Check if we're at the end of a word that looks like a filename
393-
if (cursor_pos > 0) {
394-
const text_before = display_value.substring(0, cursor_pos)
395-
const filename_match = text_before.match(
396-
/([^\s,;:!?`]*\.[^\s,;:!?`]+)$/
397-
)
426+
if (
427+
node_before_cursor &&
428+
node_before_cursor.nodeType === Node.ELEMENT_NODE
429+
) {
430+
const el = node_before_cursor as HTMLElement
431+
if (el.classList.contains(styles['file-keyword'])) {
432+
e.preventDefault()
398433

399-
if (filename_match) {
400-
const filename = filename_match[1]
401-
const matching_path = props.context_file_paths?.find(
402-
(path) => path.endsWith('/' + filename) || path === filename
403-
)
434+
const display_pos = get_caret_position_from_div(input_ref.current)
435+
const raw_pos = map_display_pos_to_raw_pos(
436+
display_pos,
437+
props.value,
438+
props.context_file_paths ?? []
439+
)
404440

405-
if (matching_path) {
406-
e.preventDefault()
407-
const new_value = props.value.replace(`\`${matching_path}\``, '')
408-
props.on_change(new_value)
409-
return
441+
let start_of_path = -1
442+
let end_of_path = -1
443+
444+
end_of_path = raw_pos - 1
445+
if (end_of_path >= 0 && props.value[end_of_path] === '`') {
446+
const text_before = props.value.substring(0, end_of_path)
447+
start_of_path = text_before.lastIndexOf('`')
448+
}
449+
450+
if (
451+
start_of_path !== -1 &&
452+
end_of_path !== -1 &&
453+
start_of_path < end_of_path
454+
) {
455+
const path_in_backticks = props.value.substring(
456+
start_of_path + 1,
457+
end_of_path
458+
)
459+
if (props.context_file_paths?.includes(path_in_backticks)) {
460+
const new_value =
461+
props.value.substring(0, start_of_path) +
462+
props.value.substring(end_of_path + 1)
463+
const new_raw_cursor_pos = start_of_path
464+
props.on_change(new_value)
465+
466+
setTimeout(() => {
467+
if (input_ref.current) {
468+
const display_pos = map_raw_pos_to_display_pos(
469+
new_raw_cursor_pos,
470+
new_value,
471+
props.context_file_paths ?? []
472+
)
473+
set_caret_position_for_div(input_ref.current, display_pos)
474+
}
475+
}, 0)
476+
return
477+
}
478+
}
410479
}
411480
}
412481
}
@@ -490,12 +559,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
490559
e.preventDefault()
491560
e.stopPropagation()
492561

493-
const filename = text_element.textContent
494-
if (filename && props.on_go_to_file && props.context_file_paths) {
495-
const file_path = props.context_file_paths.find(
496-
(p) => p.endsWith('/' + filename) || p == filename
497-
)
498-
if (file_path) {
562+
const file_keyword_element = text_element.closest<HTMLElement>(
563+
`.${styles['file-keyword']}`
564+
)
565+
if (file_keyword_element) {
566+
const file_path = file_keyword_element.getAttribute('title')
567+
if (file_path && props.on_go_to_file) {
499568
props.on_go_to_file(file_path)
500569
}
501570
}

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

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ const set_caret_position_for_div = (element: HTMLElement, position: number) => {
3434
char_count = next_char_count
3535
}
3636
} else {
37+
if (
38+
node.nodeType === Node.ELEMENT_NODE &&
39+
(node as HTMLElement).getAttribute('contenteditable') === 'false'
40+
) {
41+
const text_len = node.textContent?.length ?? 0
42+
const next_char_count = char_count + text_len
43+
if (position >= char_count && position <= next_char_count) {
44+
range.setStartAfter(node)
45+
range.collapse(true)
46+
found = true
47+
} else {
48+
char_count = next_char_count
49+
}
50+
return // Do not iterate over children
51+
}
3752
for (let i = 0; i < node.childNodes.length; i++) {
3853
find_text_node_and_offset(node.childNodes[i])
3954
if (found) break
@@ -53,6 +68,39 @@ const set_caret_position_for_div = (element: HTMLElement, position: number) => {
5368
}
5469
}
5570

71+
const reconstruct_raw_value_from_node = (node: Node): string => {
72+
if (node.nodeType === Node.TEXT_NODE) {
73+
return node.textContent || ''
74+
}
75+
76+
if (node.nodeType === Node.ELEMENT_NODE) {
77+
const el = node as HTMLElement
78+
79+
if (el.dataset.type === 'file-keyword') {
80+
const path = el.getAttribute('title')
81+
if (path) {
82+
return `\`${path}\``
83+
}
84+
}
85+
86+
if (el.dataset.type === 'selection-keyword') {
87+
return el.textContent || ''
88+
}
89+
90+
if (el.childNodes.length > 0) {
91+
let content = ''
92+
for (const child of Array.from(el.childNodes)) {
93+
content += reconstruct_raw_value_from_node(child)
94+
}
95+
return content
96+
}
97+
98+
return el.textContent || ''
99+
}
100+
101+
return ''
102+
}
103+
56104
export const use_handlers = (props: ChatInputProps) => {
57105
const [history_index, set_history_index] = useState(-1)
58106
const [is_history_enabled, set_is_history_enabled] = useState(!props.value)
@@ -68,39 +116,18 @@ export const use_handlers = (props: ChatInputProps) => {
68116
}
69117

70118
const handle_input_change = (e: React.FormEvent<HTMLDivElement>) => {
71-
let new_display_value = e.currentTarget.textContent ?? ''
72-
if (new_display_value == '\n') {
73-
new_display_value = ''
74-
}
75-
76-
// Convert display text back to the original format with backticks
77-
// This is a reverse transformation
78-
let new_value = new_display_value
79-
const context_file_paths = props.context_file_paths ?? []
80-
81-
// Find all potential filenames in the text and check if they should be wrapped in backticks
82-
const words = new_display_value.split(/(\s+)/)
83-
const converted_words = words.map((word) => {
84-
// Check if this looks like a filename
85-
if (/^[^\s,;:!?`]*\.[^\s,;:!?`]+$/.test(word)) {
86-
// Check if any context path ends with this filename
87-
const matching_path = context_file_paths.find(
88-
(path) => path.endsWith('/' + word) || path === word
89-
)
90-
if (matching_path) {
91-
return `\`${matching_path}\``
92-
}
93-
}
94-
return word
95-
})
96-
new_value = converted_words.join('')
119+
const new_value = reconstruct_raw_value_from_node(e.currentTarget)
97120

121+
if (new_value === props.value) {
122+
return
123+
}
98124
props.on_change(new_value)
99125
set_history_index(-1)
100126

101127
// Clear saved value on any input change
102128
set_saved_value_before_at_sign(null)
103129

130+
const new_display_value = e.currentTarget.textContent ?? ''
104131
const caret_position = get_caret_position_from_div(e.currentTarget)
105132
if (new_display_value.charAt(caret_position - 1) == '@') {
106133
set_saved_value_before_at_sign(props.value)

‎packages/ui/src/components/editor/panel/ChatInput/utils/get-highlighted-text.tsx‎

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,39 @@ const escape_html = (str: string): string =>
99
.replace(/"/g, '&quot;')
1010
.replace(/'/g, '&#039;')
1111

12+
const process_text_part_for_files = (
13+
text: string,
14+
context_file_paths: string[]
15+
): string => {
16+
const file_path_regex = /`([^\s`]*\.[^\s`]+)`/g
17+
const parts = text.split(file_path_regex)
18+
19+
return parts
20+
.map((part, index) => {
21+
// part at odd index is a file_path
22+
if (index % 2 == 1) {
23+
const file_path = part
24+
if (context_file_paths.includes(file_path)) {
25+
const filename = file_path.split('/').pop() || file_path
26+
return `<span class="${
27+
styles['file-keyword']
28+
}" contentEditable="false" data-type="file-keyword" title="${escape_html(
29+
file_path
30+
)}"><span class="${
31+
styles['file-keyword__icon']
32+
}"></span><span class="${styles['file-keyword__text']}">${escape_html(
33+
filename
34+
)}</span></span>`
35+
}
36+
// If not a context file, return with backticks
37+
return `\`${escape_html(file_path)}\``
38+
}
39+
// part at even index is regular text
40+
return escape_html(part)
41+
})
42+
.join('')
43+
}
44+
1245
export const get_highlighted_text = (params: {
1346
text: string
1447
is_in_code_completions_mode: boolean
@@ -19,16 +52,18 @@ export const get_highlighted_text = (params: {
1952
const regex = /(#SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+")/g
2053
const parts = params.text.split(regex)
2154
return parts
22-
.map((part, index) => {
55+
.map((part) => {
2356
if (
2457
part &&
2558
/^#SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"$/.test(part)
2659
) {
27-
return `<span class="${styles['selection-keyword']}">${escape_html(
60+
return `<span class="${
61+
styles['selection-keyword']
62+
}" contentEditable="false" data-type="selection-keyword">${escape_html(
2863
part
2964
)}</span>`
3065
}
31-
return escape_html(part)
66+
return process_text_part_for_files(part, params.context_file_paths)
3267
})
3368
.join('')
3469
}
@@ -43,46 +78,29 @@ export const get_highlighted_text = (params: {
4378
const className = cn(styles['selection-keyword'], {
4479
[styles['selection-keyword--error']]: !params.has_active_selection
4580
})
46-
return `<span class="${className}">${escape_html(part)}</span>`
81+
return `<span class="${className}" contentEditable="false" data-type="selection-keyword">${escape_html(
82+
part
83+
)}</span>`
4784
}
4885
if (part && /^#Changes:[^\s,;:!?]+$/.test(part)) {
49-
return `<span class="${styles['selection-keyword']}">${escape_html(
86+
return `<span class="${
87+
styles['selection-keyword']
88+
}" contentEditable="false" data-type="selection-keyword">${escape_html(
5089
part
5190
)}</span>`
5291
}
5392
if (
5493
part &&
5594
/^#SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+"$/.test(part)
5695
) {
57-
return `<span class="${styles['selection-keyword']}">${escape_html(
96+
return `<span class="${
97+
styles['selection-keyword']
98+
}" contentEditable="false" data-type="selection-keyword">${escape_html(
5899
part
59100
)}</span>`
60101
}
61102

62-
const filename_regex = /([^\s,;:!?`]*\.[^\s,;:!?`]+)/g
63-
return part
64-
.split(filename_regex)
65-
.map((sub_part, index) => {
66-
if (index % 2 == 1) {
67-
const is_context_file = params.context_file_paths.some(
68-
(path) => path.endsWith('/' + sub_part) || path == sub_part
69-
)
70-
if (is_context_file) {
71-
const full_path = params.context_file_paths.find(
72-
(path) => path.endsWith('/' + sub_part) || path == sub_part
73-
)
74-
return `<span class="${styles['file-keyword']}"${
75-
full_path ? ` title="${escape_html(full_path)}"` : ''
76-
}><span class="${
77-
styles['file-keyword__icon']
78-
}"></span><span class="${
79-
styles['file-keyword__text']
80-
}">${escape_html(sub_part)}</span></span>`
81-
}
82-
}
83-
return escape_html(sub_part)
84-
})
85-
.join('')
103+
return process_text_part_for_files(part, params.context_file_paths)
86104
})
87105
.join('')
88106
}

0 commit comments

Comments
 (0)