提交 f38c5007 编写于 作者: J Jackson Kearl

Add SearchEditorInput instantiation manager and enable hot exit in search editors

上级 c4d53a19
......@@ -56,7 +56,7 @@ import { ExplorerViewPaneContainer } from 'vs/workbench/contrib/files/browser/ex
import { assertType } from 'vs/base/common/types';
import { SearchViewPaneContainer } from 'vs/workbench/contrib/search/browser/searchViewlet';
import { EditorDescriptor, Extensions as EditorExtensions, IEditorRegistry } from 'vs/workbench/browser/editor';
import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorCommands';
import { SearchEditorInput, SearchEditorInputFactory, SearchEditorContribution } from 'vs/workbench/contrib/search/browser/searchEditorInput';
import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor';
import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors';
import { Extensions as EditorInputExtensions, IEditorInputFactoryRegistry } from 'vs/workbench/common/editor';
......
......@@ -29,10 +29,9 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet';
import { SearchViewPaneContainer } from 'vs/workbench/contrib/search/browser/searchViewlet';
import { SearchPanel } from 'vs/workbench/contrib/search/browser/searchPanel';
import { ITreeNavigator } from 'vs/base/browser/ui/tree/tree';
import { createEditorFromSearchResult, openNewSearchEditor, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands';
import { createEditorFromSearchResult, openNewSearchEditor } from 'vs/workbench/contrib/search/browser/searchEditorActions';
import type { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput';
export function isSearchViewFocused(viewletService: IViewletService, panelService: IPanelService): boolean {
const searchView = getSearchView(viewletService, panelService);
......@@ -587,7 +586,6 @@ export class OpenResultsInEditorAction extends Action {
@IEditorService private editorService: IEditorService,
@IConfigurationService private configurationService: IConfigurationService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ITextFileService private readonly textFileService: ITextFileService
) {
super(id, label, 'codicon-go-to-file');
}
......@@ -604,7 +602,7 @@ export class OpenResultsInEditorAction extends Action {
async run() {
const searchView = getSearchView(this.viewletService, this.panelService);
if (searchView && this.configurationService.getValue<ISearchConfigurationProperties>('search').enableSearchEditorPreview) {
await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService);
await createEditorFromSearchResult(searchView.searchResult, searchView.searchIncludePattern.getValue(), searchView.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService);
}
}
}
......
......@@ -23,10 +23,10 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti
import { ILabelService } from 'vs/platform/label/common/label';
import { IStorageService } from 'vs/platform/storage/common/storage';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor';
import { EditorInput, EditorOptions } from 'vs/workbench/common/editor';
import { EditorOptions } from 'vs/workbench/common/editor';
import { ExcludePatternInputWidget, PatternInputWidget } from 'vs/workbench/contrib/search/browser/patternInputWidget';
import { SearchWidget } from 'vs/workbench/contrib/search/browser/searchWidget';
import { ITextQueryBuilderOptions, QueryBuilder } from 'vs/workbench/contrib/search/common/queryBuilder';
......@@ -34,19 +34,15 @@ import { getOutOfWorkspaceEditorResources } from 'vs/workbench/contrib/search/co
import { SearchModel } from 'vs/workbench/contrib/search/common/searchModel';
import { IPatternInfo, ISearchConfigurationProperties, ITextQuery } from 'vs/workbench/services/search/common/search';
import { Delayer } from 'vs/base/common/async';
import { serializeSearchResultForEditor, SearchConfiguration, SearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorCommands';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { serializeSearchResultForEditor } from 'vs/workbench/contrib/search/browser/searchEditorSerialization';
import { IContextKeyService, IContextKey } from 'vs/platform/contextkey/common/contextkey';
import { InSearchEditor, InputBoxFocusedKey } from 'vs/workbench/contrib/search/common/constants';
import { IEditorProgressService, LongRunningOperation } from 'vs/platform/progress/common/progress';
import type { SearchEditorInput, SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput';
import { searchEditorFindMatchBorder, searchEditorFindMatch } from 'vs/platform/theme/common/colorRegistry';
const RESULT_LINE_REGEX = /^(\s+)(\d+)(:| )(\s+)(.*)$/;
// Using \r\n on Windows inserts an extra newline between results.
const lineDelimiter = '\n';
export class SearchEditor extends BaseEditor {
static readonly ID: string = 'workbench.editor.searchEditor';
......@@ -78,7 +74,6 @@ export class SearchEditor extends BaseEditor {
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IContextViewService private readonly contextViewService: IContextViewService,
@ICommandService private readonly commandService: ICommandService,
@ITextFileService private readonly textFileService: ITextFileService,
@IContextKeyService readonly contextKeyService: IContextKeyService,
@IEditorProgressService readonly progressService: IEditorProgressService,
) {
......@@ -304,16 +299,17 @@ export class SearchEditor extends BaseEditor {
return;
}
(assertIsDefined(this._input) as SearchEditorInput).setConfig(config);
const labelFormatter = (uri: URI): string => this.labelService.getUriLabel(uri, { relative: true });
const results = serializeSearchResultForEditor(searchModel.searchResult, config.includes, config.excludes, config.contextLines, labelFormatter, true);
const textModel = assertIsDefined(this.searchResultEditor.getModel());
this.modelService.updateModel(textModel, results.text.join(lineDelimiter));
this.modelService.updateModel(textModel, results.text);
this.getInput()?.setDirty(this.getInput()?.resource.scheme !== 'search-editor');
this.hideHeader();
textModel.deltaDecorations([], results.matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } })));
(assertIsDefined(this._input) as SearchEditorInput).reloadModel();
searchModel.dispose();
}
......@@ -327,7 +323,8 @@ export class SearchEditor extends BaseEditor {
.length
?? 0;
this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines + 1, 1)]);
// const length = this.searchResultEditor.getModel()?.getLineLength(headerLines);
this.searchResultEditor.setHiddenAreas([new Range(1, 1, headerLines, 1)]);
}
layout(dimension: DOM.Dimension) {
......@@ -352,38 +349,26 @@ export class SearchEditor extends BaseEditor {
return this._input as SearchEditorInput;
}
async setInput(newInput: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise<void> {
async setInput(newInput: SearchEditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise<void> {
await super.setInput(newInput, options, token);
this.inSearchEditorContextKey.set(true);
if (!(newInput instanceof SearchEditorInput)) { return; }
const model = assertIsDefined(this.modelService.getModel(newInput.resource));
const { model } = await newInput.reloadModel();
this.searchResultEditor.setModel(model);
const backup = await newInput.resolveBackup();
if (backup) {
model.setValueFromTextBuffer(backup);
} else {
if (newInput.resource.scheme !== 'search-editor') {
if (model.getValue() === '') {
model.setValue((await this.textFileService.read(newInput.resource)).value);
}
}
}
this.hideHeader();
this.pauseSearching = true;
this.queryEditorWidget.setValue(newInput.config.query, true);
this.queryEditorWidget.searchInput.setCaseSensitive(newInput.config.caseSensitive);
this.queryEditorWidget.searchInput.setRegex(newInput.config.regexp);
this.queryEditorWidget.searchInput.setWholeWords(newInput.config.wholeWord);
this.queryEditorWidget.setContextLines(newInput.config.contextLines);
this.inputPatternExcludes.setValue(newInput.config.excludes);
this.inputPatternIncludes.setValue(newInput.config.includes);
this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(newInput.config.useIgnores);
this.toggleIncludesExcludes(newInput.config.showIncludesExcludes);
const { query } = await newInput.reloadModel();
this.queryEditorWidget.setValue(query.query, true);
this.queryEditorWidget.searchInput.setCaseSensitive(query.caseSensitive);
this.queryEditorWidget.searchInput.setRegex(query.regexp);
this.queryEditorWidget.searchInput.setWholeWords(query.wholeWord);
this.queryEditorWidget.setContextLines(query.contextLines);
this.inputPatternExcludes.setValue(query.excludes);
this.inputPatternIncludes.setValue(query.includes);
this.inputPatternExcludes.setUseExcludesAndIgnoreFiles(query.useIgnores);
this.toggleIncludesExcludes(query.showIncludesExcludes);
this.focusInput();
this.pauseSearching = false;
......@@ -415,3 +400,12 @@ export class SearchEditor extends BaseEditor {
this.inSearchEditorContextKey.set(false);
}
}
registerThemingParticipant((theme, collector) => {
collector.addRule(`.monaco-editor .searchEditorFindMatch { background-color: ${theme.getColor(searchEditorFindMatch)}; }`);
const findMatchHighlightBorder = theme.getColor(searchEditorFindMatchBorder);
if (findMatchHighlightBorder) {
collector.addRule(`.monaco-editor .searchEditorFindMatch { border: 1px ${theme.type === 'hc' ? 'dotted' : 'solid'} ${findMatchHighlightBorder}; box-sizing: border-box; }`);
}
});
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { assertIsDefined } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import 'vs/css!./media/searchEditor';
import { isDiffEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { TrackedRangeStickiness } from 'vs/editor/common/model';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ILabelService } from 'vs/platform/label/common/label';
import { SearchResult } from 'vs/workbench/contrib/search/common/searchModel';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { SearchEditor } from 'vs/workbench/contrib/search/browser/searchEditor';
import { getOrMakeSearchEditorInput } from 'vs/workbench/contrib/search/browser/searchEditorInput';
import { serializeSearchResultForEditor, serializeSearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorSerialization';
export const openNewSearchEditor =
async (editorService: IEditorService, instantiationService: IInstantiationService) => {
const activeEditor = editorService.activeTextEditorWidget;
let activeModel: ICodeEditor | undefined;
if (isDiffEditor(activeEditor)) {
if (activeEditor.getOriginalEditor().hasTextFocus()) {
activeModel = activeEditor.getOriginalEditor();
} else {
activeModel = activeEditor.getModifiedEditor();
}
} else {
activeModel = activeEditor as ICodeEditor | undefined;
}
const selection = activeModel?.getSelection();
let selected = (selection && activeModel?.getModel()?.getValueInRange(selection)) ?? '';
const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text: serializeSearchConfiguration({ query: selected }) });
await editorService.openEditor(input, { pinned: true });
};
export const createEditorFromSearchResult =
async (searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, labelService: ILabelService, editorService: IEditorService, instantiationService: IInstantiationService) => {
if (!searchResult.query) {
console.error('Expected searchResult.query to be defined. Got', searchResult);
return;
}
const labelFormatter = (uri: URI): string => labelService.getUriLabel(uri, { relative: true });
const { text, matchRanges } = serializeSearchResultForEditor(searchResult, rawIncludePattern, rawExcludePattern, 0, labelFormatter, true);
const input = instantiationService.invokeFunction(getOrMakeSearchEditorInput, { text });
const editor = await editorService.openEditor(input, { pinned: true }) as SearchEditor;
const model = assertIsDefined(editor.getModel());
model.deltaDecorations([], matchRanges.map(range => ({ range, options: { className: 'searchEditorFindMatch', stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } })));
};
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./media/searchEditor';
import { coalesce, flatten } from 'vs/base/common/arrays';
import { repeat } from 'vs/base/common/strings';
import { URI } from 'vs/base/common/uri';
import { Range } from 'vs/editor/common/core/range';
import { FileMatch, Match, searchMatchComparer, SearchResult } from 'vs/workbench/contrib/search/common/searchModel';
import { ITextQuery } from 'vs/workbench/services/search/common/search';
import { localize } from 'vs/nls';
import type { ITextModel } from 'vs/editor/common/model';
import type { SearchConfiguration } from 'vs/workbench/contrib/search/browser/searchEditorInput';
// Using \r\n on Windows inserts an extra newline between results.
const lineDelimiter = '\n';
const translateRangeLines =
(n: number) =>
(range: Range) =>
new Range(range.startLineNumber + n, range.startColumn, range.endLineNumber + n, range.endColumn);
const matchToSearchResultFormat = (match: Match): { line: string, ranges: Range[], lineNumber: string }[] => {
const getLinePrefix = (i: number) => `${match.range().startLineNumber + i}`;
const fullMatchLines = match.fullPreviewLines();
const largestPrefixSize = fullMatchLines.reduce((largest, _, i) => Math.max(getLinePrefix(i).length, largest), 0);
const results: { line: string, ranges: Range[], lineNumber: string }[] = [];
fullMatchLines
.forEach((sourceLine, i) => {
const lineNumber = getLinePrefix(i);
const paddingStr = repeat(' ', largestPrefixSize - lineNumber.length);
const prefix = ` ${lineNumber}: ${paddingStr}`;
const prefixOffset = prefix.length;
const line = (prefix + sourceLine).replace(/\r?\n?$/, '');
const rangeOnThisLine = ({ start, end }: { start?: number; end?: number; }) => new Range(1, (start ?? 1) + prefixOffset, 1, (end ?? sourceLine.length + 1) + prefixOffset);
const matchRange = match.range();
const matchIsSingleLine = matchRange.startLineNumber === matchRange.endLineNumber;
let lineRange;
if (matchIsSingleLine) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn, end: matchRange.endColumn })); }
else if (i === 0) { lineRange = (rangeOnThisLine({ start: matchRange.startColumn })); }
else if (i === fullMatchLines.length - 1) { lineRange = (rangeOnThisLine({ end: matchRange.endColumn })); }
else { lineRange = (rangeOnThisLine({})); }
results.push({ lineNumber: lineNumber, line, ranges: [lineRange] });
});
return results;
};
type SearchResultSerialization = { text: string[], matchRanges: Range[] };
function fileMatchToSearchResultFormat(fileMatch: FileMatch, labelFormatter: (x: URI) => string): SearchResultSerialization {
const serializedMatches = flatten(fileMatch.matches()
.sort(searchMatchComparer)
.map(match => matchToSearchResultFormat(match)));
const uriString = labelFormatter(fileMatch.resource);
let text: string[] = [`${uriString}:`];
let matchRanges: Range[] = [];
const targetLineNumberToOffset: Record<string, number> = {};
const context: { line: string, lineNumber: number }[] = [];
fileMatch.context.forEach((line, lineNumber) => context.push({ line, lineNumber }));
context.sort((a, b) => a.lineNumber - b.lineNumber);
let lastLine: number | undefined = undefined;
const seenLines = new Set<string>();
serializedMatches.forEach(match => {
if (!seenLines.has(match.line)) {
while (context.length && context[0].lineNumber < +match.lineNumber) {
const { line, lineNumber } = context.shift()!;
if (lastLine !== undefined && lineNumber !== lastLine + 1) {
text.push('');
}
text.push(` ${lineNumber} ${line}`);
lastLine = lineNumber;
}
targetLineNumberToOffset[match.lineNumber] = text.length;
seenLines.add(match.line);
text.push(match.line);
lastLine = +match.lineNumber;
}
matchRanges.push(...match.ranges.map(translateRangeLines(targetLineNumberToOffset[match.lineNumber])));
});
while (context.length) {
const { line, lineNumber } = context.shift()!;
text.push(` ${lineNumber} ${line}`);
}
return { text, matchRanges };
}
const contentPatternToSearchResultHeader = (pattern: ITextQuery | null, includes: string, excludes: string, contextLines: number): string[] => {
if (!pattern) { return []; }
const removeNullFalseAndUndefined = <T>(a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[];
const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
return removeNullFalseAndUndefined([
`# Query: ${escapeNewlines(pattern.contentPattern.pattern)}`,
(pattern.contentPattern.isCaseSensitive || pattern.contentPattern.isWordMatch || pattern.contentPattern.isRegExp || pattern.userDisabledExcludesAndIgnoreFiles)
&& `# Flags: ${coalesce([
pattern.contentPattern.isCaseSensitive && 'CaseSensitive',
pattern.contentPattern.isWordMatch && 'WordMatch',
pattern.contentPattern.isRegExp && 'RegExp',
pattern.userDisabledExcludesAndIgnoreFiles && 'IgnoreExcludeSettings'
]).join(' ')}`,
includes ? `# Including: ${includes}` : undefined,
excludes ? `# Excluding: ${excludes}` : undefined,
contextLines ? `# ContextLines: ${contextLines}` : undefined
]);
};
export const serializeSearchConfiguration = (config: Partial<SearchConfiguration>): string => {
const removeNullFalseAndUndefined = <T>(a: (T | null | false | undefined)[]) => a.filter(a => a !== false && a !== null && a !== undefined) as T[];
const escapeNewlines = (str: string) => str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
return removeNullFalseAndUndefined([
`# Query: ${escapeNewlines(config.query ?? '')}`,
(config.caseSensitive || config.wholeWord || config.regexp || config.useIgnores === false)
&& `# Flags: ${coalesce([
config.caseSensitive && 'CaseSensitive',
config.wholeWord && 'WordMatch',
config.regexp && 'RegExp',
(config.useIgnores === false) && 'IgnoreExcludeSettings'
]).join(' ')}`,
config.includes ? `# Including: ${config.includes}` : undefined,
config.excludes ? `# Excluding: ${config.excludes}` : undefined,
config.contextLines ? `# ContextLines: ${config.contextLines}` : undefined,
''
]).join(lineDelimiter);
};
export const extractSearchQuery = (model: ITextModel): SearchConfiguration => {
const header = model.getValueInRange(new Range(1, 1, 6, 1)).split(lineDelimiter);
const query: SearchConfiguration = {
query: '',
includes: '',
excludes: '',
regexp: false,
caseSensitive: false,
useIgnores: true,
wholeWord: false,
contextLines: 0,
showIncludesExcludes: false,
};
const unescapeNewlines = (str: string) => {
let out = '';
for (let i = 0; i < str.length; i++) {
if (str[i] === '\\') {
i++;
const escaped = str[i];
if (escaped === 'n') {
out += '\n';
}
else if (escaped === '\\') {
out += '\\';
}
else {
throw Error(localize('invalidQueryStringError', "All backslashes in Query string must be escaped (\\\\)"));
}
} else {
out += str[i];
}
}
return out;
};
const parseYML = /^# ([^:]*): (.*)$/;
for (const line of header) {
const parsed = parseYML.exec(line);
if (!parsed) { continue; }
const [, key, value] = parsed;
switch (key) {
case 'Query': query.query = unescapeNewlines(value); break;
case 'Including': query.includes = value; break;
case 'Excluding': query.excludes = value; break;
case 'ContextLines': query.contextLines = +value; break;
case 'Flags': {
query.regexp = value.indexOf('RegExp') !== -1;
query.caseSensitive = value.indexOf('CaseSensitive') !== -1;
query.useIgnores = value.indexOf('IgnoreExcludeSettings') === -1;
query.wholeWord = value.indexOf('WordMatch') !== -1;
}
}
}
query.showIncludesExcludes = !!(query.includes || query.excludes || !query.useIgnores);
return query;
};
export const serializeSearchResultForEditor =
(searchResult: SearchResult, rawIncludePattern: string, rawExcludePattern: string, contextLines: number, labelFormatter: (x: URI) => string, includeHeader: boolean): { matchRanges: Range[], text: string } => {
const header = includeHeader
? contentPatternToSearchResultHeader(searchResult.query, rawIncludePattern, rawExcludePattern, contextLines)
: [];
const allResults =
flattenSearchResultSerializations(
flatten(
searchResult.folderMatches().sort(searchMatchComparer)
.map(folderMatch => folderMatch.matches().sort(searchMatchComparer)
.map(fileMatch => fileMatchToSearchResultFormat(fileMatch, labelFormatter)))));
return {
matchRanges: allResults.matchRanges.map(translateRangeLines(header.length)),
text: header
.concat(allResults.text.length ? allResults.text : ['No Results'])
.join(lineDelimiter)
};
};
const flattenSearchResultSerializations = (serializations: SearchResultSerialization[]): SearchResultSerialization => {
let text: string[] = [];
let matchRanges: Range[] = [];
serializations.forEach(serialized => {
serialized.matchRanges.map(translateRangeLines(text.length)).forEach(range => matchRanges.push(range));
serialized.text.forEach(line => text.push(line));
text.push(''); // new line
});
return { text, matchRanges };
};
......@@ -64,7 +64,7 @@ import { IOpenerService } from 'vs/platform/opener/common/opener';
import { MultiCursorSelectionController } from 'vs/editor/contrib/multicursor/multicursor';
import { Selection } from 'vs/editor/common/core/selection';
import { SIDE_BAR_BACKGROUND, PANEL_BACKGROUND } from 'vs/workbench/common/theme';
import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorCommands';
import { createEditorFromSearchResult } from 'vs/workbench/contrib/search/browser/searchEditorActions';
import { ILabelService } from 'vs/platform/label/common/label';
import { Color, RGBA } from 'vs/base/common/color';
......@@ -1559,7 +1559,7 @@ export class SearchView extends ViewPane {
this.messageDisposables.push(dom.addDisposableListener(openInEditorLink, dom.EventType.CLICK, (e: MouseEvent) => {
dom.EventHelper.stop(e, false);
createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.textFileService, this.instantiationService);
createEditorFromSearchResult(this.searchResult, this.searchIncludePattern.getValue(), this.searchExcludePattern.getValue(), this.labelService, this.editorService, this.instantiationService);
}));
} else {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册