Skip to content

Commit dacfb46

Browse files
authored
feat: terminal search optimization (#4384)
1 parent 305bfb1 commit dacfb46

10 files changed

Lines changed: 313 additions & 95 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
@option-size: 20px;
2+
3+
.terminalSearch {
4+
display: flex;
5+
justify-content: center;
6+
align-items: center;
7+
position: absolute;
8+
z-index: 999;
9+
right: 16px;
10+
border-radius: 2px;
11+
padding: 6px 8px;
12+
box-shadow: rgba(0, 0, 0, 0.133) 0px 3.2px 7.2px 0px, rgba(0, 0, 0, 0.11) 0px 0.6px 1.8px 0px;
13+
background: var(--kt-panelTitle-background);
14+
15+
.searchField {
16+
&:focus {
17+
border-color: var(--focusBorder);
18+
}
19+
20+
.optionBtn {
21+
width: @option-size;
22+
height: @option-size;
23+
margin: 0 1px;
24+
box-sizing: border-box;
25+
display: flex;
26+
justify-content: center;
27+
user-select: none;
28+
background-repeat: no-repeat;
29+
background-position: center;
30+
border: 1px solid transparent;
31+
cursor: pointer;
32+
opacity: 0.7;
33+
34+
&:hover {
35+
opacity: 1;
36+
}
37+
38+
&::before {
39+
display: inline-block;
40+
height: @option-size - 2;
41+
line-height: @option-size - 2;
42+
}
43+
44+
&.select {
45+
background-color: var(--inputOption-activeBackground);
46+
border-color: var(--inputOption-activeBorder);
47+
opacity: 1;
48+
}
49+
}
50+
}
51+
52+
.panelBtn {
53+
padding: 0px 6px;
54+
cursor: pointer;
55+
}
56+
57+
.searchResult {
58+
margin: 0 4px;
59+
font-size: 14px;
60+
}
61+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import cls from 'classnames';
2+
import React from 'react';
3+
4+
import { ValidateInput } from '@opensumi/ide-components';
5+
import { getIcon, localize, useInjectable } from '@opensumi/ide-core-browser';
6+
7+
import { ISearchResult, ITerminalSearchService } from '../../common';
8+
9+
import styles from './search.module.less';
10+
11+
export const TerminalSearch: React.FC<{}> = React.memo((props) => {
12+
const searchService = useInjectable<ITerminalSearchService>(ITerminalSearchService);
13+
const [UIState, setUIState] = React.useState(searchService.UIState);
14+
const [searchResult, setSearchResult] = React.useState<ISearchResult | null>(null);
15+
const [inputText, setInputText] = React.useState(searchService.text || '');
16+
const inputRef = React.useRef<HTMLInputElement>(null);
17+
18+
React.useEffect(() => {
19+
const dispose = searchService.onVisibleChange((show) => {
20+
if (show && inputRef.current) {
21+
inputRef.current.focus();
22+
23+
if (inputRef.current.value.length > 0) {
24+
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
25+
}
26+
}
27+
});
28+
return () => dispose.dispose();
29+
}, [searchService]);
30+
31+
React.useEffect(() => {
32+
if (!searchService.onResultChange) {
33+
return;
34+
}
35+
36+
const dispose = searchService.onResultChange((event) => {
37+
setSearchResult(event);
38+
});
39+
40+
return () => dispose.dispose();
41+
}, [searchService]);
42+
43+
const searchInput = React.useCallback(
44+
(event: React.ChangeEvent<HTMLInputElement>) => {
45+
searchService.text = event.target.value;
46+
searchService.search();
47+
setInputText(event.target.value);
48+
},
49+
[searchService],
50+
);
51+
52+
const searchKeyDown = React.useCallback(
53+
(event: React.KeyboardEvent<HTMLInputElement>) => {
54+
if (event.key === 'Enter') {
55+
searchService.search();
56+
}
57+
58+
if (event.key === 'Escape') {
59+
searchService.close();
60+
searchService.clear();
61+
}
62+
},
63+
[searchService],
64+
);
65+
66+
const toggleMatchCase = React.useCallback(() => {
67+
searchService.updateUIState({ isMatchCase: !UIState.isMatchCase });
68+
setUIState(searchService.UIState);
69+
}, [searchService, UIState]);
70+
71+
const toggleRegex = React.useCallback(() => {
72+
searchService.updateUIState({ isUseRegexp: !UIState.isUseRegexp });
73+
setUIState(searchService.UIState);
74+
}, [searchService, UIState]);
75+
76+
const toggleWholeWord = React.useCallback(() => {
77+
searchService.updateUIState({ isWholeWord: !UIState.isWholeWord });
78+
setUIState(searchService.UIState);
79+
}, [searchService, UIState]);
80+
81+
const searchNext = React.useCallback(() => {
82+
searchService.searchNext();
83+
}, [searchService]);
84+
85+
const searchPrev = React.useCallback(() => {
86+
searchService.searchPrevious();
87+
}, [searchService]);
88+
89+
const close = React.useCallback(() => {
90+
searchService.close();
91+
}, [searchService]);
92+
93+
return (
94+
<div className={styles.terminalSearch}>
95+
<ValidateInput
96+
className={styles.searchField}
97+
autoFocus
98+
id='search-input-field'
99+
title={localize('search.input.placeholder')}
100+
type='text'
101+
value={inputText}
102+
placeholder={localize('common.find')}
103+
onKeyDown={searchKeyDown}
104+
onChange={searchInput}
105+
ref={inputRef}
106+
validateMessage={undefined}
107+
addonAfter={[
108+
<span
109+
key={localize('search.caseDescription')}
110+
className={cls(getIcon('ab'), styles['match-case'], styles.optionBtn, {
111+
[styles.select]: UIState.isMatchCase,
112+
})}
113+
title={localize('search.caseDescription')}
114+
onClick={toggleMatchCase}
115+
></span>,
116+
<span
117+
key={localize('search.wordsDescription')}
118+
className={cls(getIcon('abl'), styles['whole-word'], styles.optionBtn, {
119+
[styles.select]: UIState.isWholeWord,
120+
})}
121+
title={localize('search.wordsDescription')}
122+
onClick={toggleWholeWord}
123+
></span>,
124+
<span
125+
key={localize('search.regexDescription')}
126+
className={cls(getIcon('regex'), styles['use-regexp'], styles.optionBtn, {
127+
[styles.select]: UIState.isUseRegexp,
128+
})}
129+
title={localize('search.regexDescription')}
130+
onClick={toggleRegex}
131+
></span>,
132+
]}
133+
/>
134+
<div className={styles.searchResult}>
135+
{searchResult ? `${searchResult.resultIndex + 1}/${searchResult.resultCount}` : '0/0'}
136+
</div>
137+
<div className={cls(styles.panelBtn, getIcon('up'))} onClick={searchPrev}></div>
138+
<div className={cls(styles.panelBtn, getIcon('down'))} onClick={searchNext}></div>
139+
<div className={cls(styles.panelBtn, getIcon('close'))} onClick={close}></div>
140+
</div>
141+
);
142+
});

packages/terminal-next/src/browser/component/terminal.module.less

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,36 +44,6 @@
4444
width: 100%;
4545
}
4646

47-
.terminalSearch {
48-
display: flex;
49-
justify-content: center;
50-
align-items: center;
51-
position: absolute;
52-
z-index: 999;
53-
right: 16px;
54-
border-radius: 2px;
55-
box-shadow: rgba(0, 0, 0, 0.133) 0px 3.2px 7.2px 0px, rgba(0, 0, 0, 0.11) 0px 0.6px 1.8px 0px;
56-
background: var(--kt-panelTitle-background);
57-
58-
input {
59-
height: 28px;
60-
padding: 0px 8px;
61-
font-size: 12px;
62-
border-style: solid;
63-
border-width: 1px;
64-
border-color: transparent;
65-
66-
&:focus {
67-
border-color: var(--focusBorder);
68-
}
69-
}
70-
71-
.closeBtn {
72-
padding: 0px 6px;
73-
cursor: pointer;
74-
}
75-
}
76-
7747
.terminalFake {
7848
position: absolute;
7949
z-index: -999;

packages/terminal-next/src/browser/component/terminal.view.tsx

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import cls from 'classnames';
21
import debounce from 'lodash/debounce';
32
import React from 'react';
43

5-
import { FRAME_THREE, getIcon, localize, useAutorun, useEventEffect, useInjectable } from '@opensumi/ide-core-browser';
4+
import { FRAME_THREE, useAutorun, useEventEffect, useInjectable } from '@opensumi/ide-core-browser';
65

76
import {
87
ITerminalController,
@@ -15,6 +14,7 @@ import {
1514
} from '../../common';
1615

1716
import ResizeView, { ResizeDirection } from './resize.view';
17+
import { TerminalSearch } from './search.view';
1818
import styles from './terminal.module.less';
1919
import TerminalWidget from './terminal.widget';
2020

@@ -26,27 +26,13 @@ export default () => {
2626
const errorService = useInjectable<ITerminalErrorService>(ITerminalErrorService);
2727
const network = useInjectable<ITerminalNetwork>(ITerminalNetwork);
2828

29-
const inputRef = React.useRef<HTMLInputElement>(null);
3029
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
3130

3231
const view = useInjectable<ITerminalGroupViewService>(ITerminalGroupViewService);
3332
const currentGroupId = useAutorun(view.currentGroupId);
3433
const currentGroupIndex = useAutorun(view.currentGroupIndex);
3534
const groups = useAutorun(view.groups);
3635

37-
React.useEffect(() => {
38-
const dispose = searchService.onVisibleChange((show) => {
39-
if (show && inputRef.current) {
40-
inputRef.current.focus();
41-
42-
if (inputRef.current.value.length > 0) {
43-
inputRef.current.setSelectionRange(0, inputRef.current.value.length);
44-
}
45-
}
46-
});
47-
return () => dispose.dispose();
48-
}, [searchService, inputRef.current]);
49-
5036
const [themeBackground, setThemeBackground] = React.useState(controller.themeBackground);
5137

5238
useEventEffect(controller.onThemeBackgroundChange, (themeBackground) => {
@@ -78,31 +64,6 @@ export default () => {
7864
setIsVisible(visible);
7965
});
8066

81-
const [inputText, setInputText] = React.useState('');
82-
83-
const searchInput = React.useCallback(
84-
(event: React.ChangeEvent<HTMLInputElement>) => {
85-
searchService.text = event.target.value;
86-
searchService.search();
87-
setInputText(event.target.value);
88-
},
89-
[searchService],
90-
);
91-
92-
const searchKeyDown = React.useCallback(
93-
(event: React.KeyboardEvent<HTMLInputElement>) => {
94-
if (event.key === 'Enter') {
95-
searchService.search();
96-
}
97-
98-
if (event.key === 'Escape') {
99-
searchService.close();
100-
searchService.clear();
101-
}
102-
},
103-
[searchService],
104-
);
105-
10667
React.useEffect(() => {
10768
if (wrapperRef.current) {
10869
controller.initContextKey(wrapperRef.current);
@@ -117,21 +78,7 @@ export default () => {
11778
style={{ backgroundColor: themeBackground }}
11879
data-group-current={currentGroupId}
11980
>
120-
{isVisible && (
121-
<div className={styles.terminalSearch}>
122-
<div className='kt-input-box'>
123-
<input
124-
autoFocus
125-
ref={inputRef}
126-
placeholder={localize('common.find')}
127-
value={inputText}
128-
onChange={searchInput}
129-
onKeyDown={searchKeyDown}
130-
/>
131-
</div>
132-
<div className={cls(styles.closeBtn, getIcon('close'))} onClick={() => searchService.close()}></div>
133-
</div>
134-
)}
81+
{isVisible && <TerminalSearch />}
13582
{groups.map((group, index) => {
13683
if (!group.activated.get()) {
13784
return;

packages/terminal-next/src/browser/terminal.client.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ISearchOptions } from '@xterm/addon-search';
2+
13
import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di';
24
import { IEventBus, QuickPickService, TerminalClientAttachEvent, localize } from '@opensumi/ide-core-browser';
35
import { PreferenceService } from '@opensumi/ide-core-browser/lib/preferences/types';
@@ -729,9 +731,18 @@ export class TerminalClient extends Disposable implements ITerminalClient {
729731
return this.xterm.raw.paste(text);
730732
}
731733

732-
findNext(text: string) {
734+
findNext(text: string, searchOptions: ISearchOptions = {}) {
735+
this._checkReady();
736+
return this.xterm.findNext(text, searchOptions);
737+
}
738+
739+
findPrevious(text: string, searchOptions: ISearchOptions = {}) {
733740
this._checkReady();
734-
return this.xterm.findNext(text);
741+
return this.xterm.findPrevious(text, searchOptions);
742+
}
743+
744+
get onSearchResultsChange() {
745+
return this.xterm.onSearchResultsChange;
735746
}
736747

737748
closeSearch() {

0 commit comments

Comments
 (0)