Skip to content

Commit 7e29d95

Browse files
committed
Add support for pasting selected code as fragments with line count indicators in the prompt field
1 parent c436b14 commit 7e29d95

14 files changed

Lines changed: 174 additions & 50 deletions

File tree

‎packages/ui/src/components/editor/panel/PromptField/PromptField.module.scss‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@
217217
content: '\eb84';
218218
}
219219

220+
&--pasted-lines &__icon::before {
221+
content: '\eb84';
222+
}
223+
220224
&--selection-error {
221225
background-color: color-mix(
222226
in srgb,
@@ -352,7 +356,6 @@
352356
gap: 3px;
353357
height: 20px;
354358
border-radius: 5px;
355-
pointer-events: all;
356359

357360
> svg {
358361
width: 10px;

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const Empty = () => (
1515
is_connected={true}
1616
is_in_code_completions_mode={false}
1717
current_selection=""
18-
has_active_editor={true}
18+
currently_open_file_path="/path/to/file"
1919
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
2020
is_web_mode={false}
2121
on_at_sign_click={() => console.log('@ clicked')}
@@ -41,7 +41,7 @@ export const WithText = () => (
4141
is_connected={true}
4242
is_in_code_completions_mode={false}
4343
current_selection=""
44-
has_active_editor={true}
44+
currently_open_file_path="/path/to/file"
4545
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
4646
is_web_mode={false}
4747
on_at_sign_click={() => console.log('@ clicked')}
@@ -67,7 +67,7 @@ export const LongText = () => (
6767
is_connected={true}
6868
is_in_code_completions_mode={false}
6969
current_selection=""
70-
has_active_editor={true}
70+
currently_open_file_path="/path/to/file"
7171
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
7272
is_web_mode={false}
7373
on_at_sign_click={() => console.log('@ clicked')}
@@ -92,8 +92,8 @@ export const WithPlaceholderSavedContext = () => (
9292
on_copy={() => console.log('Copied')}
9393
is_connected={true}
9494
is_in_code_completions_mode={false}
95-
has_active_editor={true}
9695
current_selection=""
96+
currently_open_file_path="/path/to/file"
9797
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
9898
on_search_click={() => console.log('Search clicked')}
9999
on_at_sign_click={() => console.log('@ clicked')}
@@ -118,8 +118,8 @@ export const WithPlaceholderSelection = () => (
118118
on_copy={() => console.log('Copied')}
119119
is_connected={true}
120120
is_in_code_completions_mode={false}
121-
has_active_editor={true}
122121
current_selection="Selected text content"
122+
currently_open_file_path="/path/to/file"
123123
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
124124
on_search_click={() => console.log('Search clicked')}
125125
on_at_sign_click={() => console.log('@ clicked')}
@@ -144,8 +144,8 @@ export const WithCommit = () => (
144144
on_copy={() => console.log('Copied')}
145145
is_connected={true}
146146
is_in_code_completions_mode={false}
147-
has_active_editor={true}
148147
current_selection=""
148+
currently_open_file_path="/path/to/file"
149149
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
150150
on_search_click={() => console.log('Search clicked')}
151151
on_at_sign_click={() => console.log('@ clicked')}
@@ -170,8 +170,8 @@ export const WithCommitWithQuotes = () => (
170170
on_copy={() => console.log('Copied')}
171171
is_connected={true}
172172
is_in_code_completions_mode={false}
173-
has_active_editor={true}
174173
current_selection=""
174+
currently_open_file_path="/path/to/file"
175175
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
176176
on_search_click={() => console.log('Search clicked')}
177177
on_at_sign_click={() => console.log('@ clicked')}
@@ -195,8 +195,8 @@ export const WithContextAtCommit = () => (
195195
on_copy={() => console.log('Copied')}
196196
is_connected={true}
197197
is_in_code_completions_mode={false}
198-
has_active_editor={true}
199198
current_selection=""
199+
currently_open_file_path="/path/to/file"
200200
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
201201
on_search_click={() => console.log('Search clicked')}
202202
on_at_sign_click={() => console.log('@ clicked')}
@@ -224,7 +224,7 @@ export const WithEditFormatSelector = () => {
224224
is_connected={true}
225225
is_in_code_completions_mode={false}
226226
current_selection=""
227-
has_active_editor={true}
227+
currently_open_file_path="/path/to/file"
228228
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
229229
is_web_mode={false}
230230
on_at_sign_click={() => console.log('@ clicked')}
@@ -252,7 +252,7 @@ export const WithFilePaths = () => (
252252
is_connected={true}
253253
is_in_code_completions_mode={false}
254254
current_selection=""
255-
has_active_editor={true}
255+
currently_open_file_path="/path/to/file"
256256
on_caret_position_change={(pos) => console.log('Caret position:', pos)}
257257
is_web_mode={false}
258258
on_at_sign_click={() => console.log('@ clicked')}

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ export type PromptFieldProps = {
3333
is_connected: boolean
3434
is_in_code_completions_mode: boolean
3535
current_selection: string
36-
has_active_editor: boolean
3736
on_caret_position_change: (caret_position: number) => void
3837
is_web_mode: boolean
3938
on_search_click: () => void
@@ -50,6 +49,7 @@ export type PromptFieldProps = {
5049
edit_format?: EditFormat
5150
on_edit_format_change?: (format: EditFormat) => void
5251
context_file_paths?: string[]
52+
currently_open_file_path?: string
5353
currently_open_file_text?: string
5454
invocation_count: number
5555
on_invocation_count_change: (count: number) => void
@@ -149,20 +149,42 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
149149
(e: React.ClipboardEvent<HTMLDivElement>) => {
150150
e.preventDefault()
151151
const text = e.clipboardData.getData('text/plain')
152-
153152
const selection = window.getSelection()
154153
if (!selection || !selection.rangeCount) return
155154
const range = selection.getRangeAt(0)
155+
156+
let text_to_insert = text
157+
if (
158+
props.current_selection &&
159+
text === props.current_selection &&
160+
props.currently_open_file_path
161+
) {
162+
text_to_insert = `<fragment path="${props.currently_open_file_path}">\n${text}\n</fragment>`
163+
let has_space_after = false
164+
if (range.endContainer.nodeType === Node.TEXT_NODE) {
165+
const content = range.endContainer.textContent || ''
166+
if (
167+
range.endOffset < content.length &&
168+
/\s/.test(content[range.endOffset])
169+
) {
170+
has_space_after = true
171+
}
172+
}
173+
if (!has_space_after) {
174+
text_to_insert += ' '
175+
}
176+
}
177+
156178
range.deleteContents()
157-
const text_node = document.createTextNode(text)
179+
const text_node = document.createTextNode(text_to_insert)
158180
range.insertNode(text_node)
159181
range.setStartAfter(text_node)
160182
range.setEndAfter(text_node)
161183
selection.removeAllRanges()
162184
selection.addRange(range)
163185
input_ref.current?.dispatchEvent(new Event('input', { bubbles: true }))
164186
},
165-
[]
187+
[props.current_selection, props.currently_open_file_path]
166188
)
167189

168190
const is_mac = use_is_mac()
@@ -315,7 +337,7 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
315337
</div>
316338
)}
317339

318-
{props.is_in_code_completions_mode && !props.has_active_editor && (
340+
{props.is_in_code_completions_mode && !props.currently_open_file_path && (
319341
<div className={styles.error}>
320342
<div className={styles.error__inner}>Place cursor for completion</div>
321343
</div>
@@ -325,7 +347,7 @@ export const PromptField: React.FC<PromptFieldProps> = (props) => {
325347
className={cn(styles.container__inner, {
326348
[styles['container__inner--disabled']]:
327349
props.is_in_code_completions_mode &&
328-
(!!props.current_selection || !props.has_active_editor),
350+
(!!props.current_selection || !props.currently_open_file_path),
329351
[styles['container__inner--selecting']]: is_text_selecting
330352
})}
331353
onKeyDown={handle_container_key_down}

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

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const getKeywordRanges = (
2121
const ranges: { start: number; end: number }[] = []
2222
// This regex is a combination of all keyword types
2323
const regex =
24-
/`([^\s`]*\.[^\s`]+)`|(#Changes:[^\s,;:!?]+)|(#Selection)|(#SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+")|(#(?:Commit|ContextAtCommit):[^:]+:[^\s"]+\s+"(?:\\.|[^"\\])*")/g
24+
/`([^\s`]*\.[^\s`]+)`|(#Changes:[^\s,;:!?]+)|(#Selection)|(#SavedContext:(?:WorkspaceState|JSON)\s+"[^"]+")|(#(?:Commit|ContextAtCommit):[^:]+:[^\s"]+\s+"(?:\\.|[^"\\])*")|(<fragment path="[^"]+">\n[\s\S]*?\n<\/fragment>)/g
2525

2626
let match
2727
while ((match = regex.exec(text)) !== null) {
@@ -126,6 +126,23 @@ const reconstruct_raw_value_from_node = (node: Node): string => {
126126
'\\"'
127127
)}"${suffix}`
128128
}
129+
} else if (el.dataset.type == 'pasted-lines-keyword') {
130+
const path = el.dataset.path
131+
const content = el.dataset.content
132+
if (!path || content === undefined) return ''
133+
134+
const line_count = content.split('\n').length
135+
const lines_text = line_count == 1 ? 'line' : 'lines'
136+
const label = `Pasted ${line_count} ${lines_text}`
137+
const index = inner_content.indexOf(label)
138+
139+
if (index != -1) {
140+
const prefix = inner_content.substring(0, index)
141+
const suffix = inner_content.substring(index + label.length)
142+
return `${prefix}<fragment path="${path}">\n${content}\n</fragment>${suffix}`
143+
}
144+
145+
return `<fragment path="${path}">\n${content}\n</fragment>`
129146
}
130147

131148
return inner_content
@@ -287,6 +304,20 @@ export const use_handlers = (
287304
const search_pattern = `#ContextAtCommit:${repo_name}:${commit_hash} "${commit_message.replace(/"/g, '\\"')}"`
288305
const start_index = props.value.indexOf(search_pattern)
289306

307+
if (start_index !== -1) {
308+
apply_keyword_deletion(start_index, start_index + search_pattern.length)
309+
}
310+
} else if (keyword_type == 'pasted-lines-keyword') {
311+
const path = keyword_element.dataset.path
312+
const content = keyword_element.dataset.content
313+
if (!path || content === undefined) return
314+
315+
// Since we can't reliably search for multiline content with indexOf easily if formatting varies,
316+
// and we just clicked a specific element, we might need a better way.
317+
// However, we construct exact strings.
318+
const search_pattern = `<fragment path="${path}">\n${content}\n</fragment>`
319+
const start_index = props.value.indexOf(search_pattern)
320+
290321
if (start_index !== -1) {
291322
apply_keyword_deletion(start_index, start_index + search_pattern.length)
292323
}
@@ -536,6 +567,27 @@ export const use_handlers = (
536567
return false
537568
}
538569

570+
const handle_pasted_lines_keyword_deletion = (
571+
raw_pos: number,
572+
context_file_paths: string[]
573+
): boolean => {
574+
const text_before_cursor = props.value.substring(0, raw_pos)
575+
const match = text_before_cursor.match(
576+
/<fragment path="[^"]+">\n[\s\S]*?\n<\/fragment>$/
577+
)
578+
579+
if (match) {
580+
const start_of_match = raw_pos - match[0].length
581+
const new_value =
582+
props.value.substring(0, start_of_match) +
583+
props.value.substring(raw_pos)
584+
const new_raw_cursor_pos = start_of_match
585+
update_value(new_value, new_raw_cursor_pos)
586+
return true
587+
}
588+
return false
589+
}
590+
539591
const handle_keyword_deletion_by_backspace = (
540592
el: HTMLElement,
541593
display_pos: number
@@ -570,6 +622,10 @@ export const use_handlers = (
570622
return handle_commit_keyword_deletion(raw_pos, context_file_paths)
571623
}
572624

625+
if (el.dataset.type == 'pasted-lines-keyword') {
626+
return handle_pasted_lines_keyword_deletion(raw_pos, context_file_paths)
627+
}
628+
573629
return false
574630
}
575631

@@ -607,7 +663,8 @@ export const use_handlers = (
607663
parent.dataset.type == 'selection-keyword' ||
608664
parent.dataset.type == 'saved-context-keyword' ||
609665
parent.dataset.type == 'commit-keyword' ||
610-
parent.dataset.type == 'contextatcommit-keyword'
666+
parent.dataset.type == 'contextatcommit-keyword' ||
667+
parent.dataset.type == 'pasted-lines-keyword'
611668
) {
612669
const rangeAfter = document.createRange()
613670
rangeAfter.selectNodeContents(parent)
@@ -632,7 +689,8 @@ export const use_handlers = (
632689
el.dataset.type == 'selection-keyword' ||
633690
el.dataset.type == 'saved-context-keyword' ||
634691
el.dataset.type == 'commit-keyword' ||
635-
el.dataset.type == 'contextatcommit-keyword'
692+
el.dataset.type == 'contextatcommit-keyword' ||
693+
el.dataset.type == 'pasted-lines-keyword'
636694
) {
637695
const display_pos = get_caret_position_from_div(input_ref.current)
638696
if (handle_keyword_deletion_by_backspace(el, display_pos)) {

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const process_text_part_for_files = (
2828
return `<span class="${cn(
2929
styles['keyword'],
3030
styles['keyword--file']
31-
)}" data-type="file-keyword" data-path="${escape_html(
31+
)}" contentEditable="false" data-type="file-keyword" data-path="${escape_html(
3232
file_path
3333
)}" title="${escape_html(file_path)}"><span class="${
3434
styles['keyword__icon']
@@ -54,14 +54,39 @@ export const get_highlighted_text = (params: {
5454

5555
const commit_regex_part =
5656
'#(?:Commit|ContextAtCommit):[^:]+:[^\\s"]+\\s+"(?:\\\\.|[^"\\\\])*"'
57+
58+
const fragment_regex_part =
59+
'<fragment path="[^"]+">\\n[\\s\\S]*?\\n<\\/fragment>'
60+
5761
const regex = new RegExp(
58-
`(#Selection|#Changes:[^\\s,;:!?]+|${saved_context_regex_part}|${commit_regex_part})`,
62+
`(${fragment_regex_part}|#Selection|#Changes:[^\\s,;:!?]+|${saved_context_regex_part}|${commit_regex_part})`,
5963
'g'
6064
)
6165
const parts = params.text.split(regex)
6266

6367
const result = parts
6468
.map((part) => {
69+
const fragment_match = part.match(
70+
/^<fragment path="([^"]+)">\n([\s\S]*?)\n<\/fragment>$/
71+
)
72+
if (part && fragment_match) {
73+
const path = fragment_match[1]
74+
const content = fragment_match[2]
75+
const line_count = content.split('\n').length
76+
const lines_text = line_count === 1 ? 'line' : 'lines'
77+
78+
return `<span class="${cn(
79+
styles['keyword'],
80+
styles['keyword--pasted-lines']
81+
)}" contentEditable="false" data-type="pasted-lines-keyword" data-path="${escape_html(
82+
path
83+
)}" data-content="${escape_html(content)}"><span class="${
84+
styles['keyword__icon']
85+
}" data-role="keyword-icon"></span><span class="${
86+
styles['keyword__text']
87+
}" data-role="keyword-text">Pasted ${line_count} ${lines_text}</span></span>`
88+
}
89+
6590
if (part == '#Selection') {
6691
const className = cn(styles['keyword'], styles['keyword--selection'], {
6792
[styles['keyword--selection-error']]: !params.current_selection
@@ -71,14 +96,14 @@ export const get_highlighted_text = (params: {
7196
: `Selection: ${params.current_selection
7297
.substring(0, 100)
7398
.replace(/\n/g, ' ')}...`
74-
return `<span class="${className}" data-type="selection-keyword" title="${escape_html(title)}"><span class="${styles['keyword__icon']}" data-role="keyword-icon"></span><span class="${styles['keyword__text']}" data-role="keyword-text">Selection</span></span>`
99+
return `<span class="${className}" contentEditable="false" data-type="selection-keyword" title="${escape_html(title)}"><span class="${styles['keyword__icon']}" data-role="keyword-icon"></span><span class="${styles['keyword__text']}" data-role="keyword-text">Selection</span></span>`
75100
}
76101
if (part && /^#Changes:[^\s,;:!?]+$/.test(part)) {
77102
const branch_name = part.substring('#Changes:'.length)
78103
return `<span class="${cn(
79104
styles['keyword'],
80105
styles['keyword--changes']
81-
)}" data-type="changes-keyword" data-branch-name="${escape_html(
106+
)}" contentEditable="false" data-type="changes-keyword" data-branch-name="${escape_html(
82107
branch_name
83108
)}"><span class="${
84109
styles['keyword__icon']
@@ -97,7 +122,7 @@ export const get_highlighted_text = (params: {
97122
return `<span class="${cn(
98123
styles['keyword'],
99124
styles['keyword--saved-context']
100-
)}" data-type="saved-context-keyword" data-context-type="${context_type}" data-context-name="${escape_html(
125+
)}" contentEditable="false" data-type="saved-context-keyword" data-context-type="${context_type}" data-context-name="${escape_html(
101126
context_name
102127
)}"><span class="${
103128
styles['keyword__icon']
@@ -119,7 +144,7 @@ export const get_highlighted_text = (params: {
119144
return `<span class="${cn(
120145
styles['keyword'],
121146
styles[`keyword--${symbol.toLowerCase()}`]
122-
)}" data-type="${symbol.toLowerCase()}-keyword" data-repo-name="${escape_html(
147+
)}" contentEditable="false" data-type="${symbol.toLowerCase()}-keyword" data-repo-name="${escape_html(
123148
repo_name
124149
)}" data-commit-hash="${escape_html(
125150
commit_hash

0 commit comments

Comments
 (0)