#30955 Implement virtual editor for workspace settings in MR workspace
This commit is contained in:
parent
f4208eff49
commit
600e0dbc10
|
@ -35,6 +35,10 @@
|
|||
"fileMatch": "vscode://defaultsettings/settings.json",
|
||||
"url": "vscode://schemas/settings"
|
||||
},
|
||||
{
|
||||
"fileMatch": "vscode://settings/workspaceSettings.json",
|
||||
"url": "vscode://schemas/settings"
|
||||
},
|
||||
{
|
||||
"fileMatch": "%APP_SETTINGS_HOME%/settings.json",
|
||||
"url": "vscode://schemas/settings"
|
||||
|
|
|
@ -422,13 +422,13 @@ class SideBySidePreferencesWidget extends Widget {
|
|||
|
||||
this.defaultPreferencesEditorContainer = DOM.append(parentElement, DOM.$('.default-preferences-editor-container'));
|
||||
this.defaultPreferencesEditorContainer.style.position = 'absolute';
|
||||
this.defaultPreferencesEditor = this.instantiationService.createInstance(DefaultPreferencesEditor);
|
||||
this.defaultPreferencesEditor = this._register(this.instantiationService.createInstance(DefaultPreferencesEditor));
|
||||
this.defaultPreferencesEditor.create(new Builder(this.defaultPreferencesEditorContainer));
|
||||
this.defaultPreferencesEditor.setVisible(true);
|
||||
|
||||
this.editablePreferencesEditorContainer = DOM.append(parentElement, DOM.$('.editable-preferences-editor-container'));
|
||||
this.editablePreferencesEditorContainer.style.position = 'absolute';
|
||||
this.editablePreferencesEditor = this.instantiationService.createInstance(EditableSettingsEditor);
|
||||
this.editablePreferencesEditor = this._register(this.instantiationService.createInstance(EditableSettingsEditor));
|
||||
this.editablePreferencesEditor.create(new Builder(this.editablePreferencesEditorContainer));
|
||||
this.editablePreferencesEditor.setVisible(true);
|
||||
|
||||
|
@ -582,14 +582,22 @@ export class EditableSettingsEditor extends BaseTextEditor {
|
|||
.then(editorModel => this.getControl().setModel((<ResourceEditorModel>editorModel).textEditorModel)));
|
||||
}
|
||||
|
||||
clearInput(): void {
|
||||
this.modelDisposables = dispose(this.modelDisposables);
|
||||
super.clearInput();
|
||||
}
|
||||
|
||||
private onDidModelChange(): void {
|
||||
this.modelDisposables = dispose(this.modelDisposables);
|
||||
const model = getCodeEditor(this).getModel();
|
||||
this.modelDisposables.push(model.onDidChangeContent(() => this.save(model.uri)));
|
||||
}
|
||||
|
||||
private save(resource: URI): void {
|
||||
this.textFileService.save(resource);
|
||||
if (model) {
|
||||
this.preferencesService.createPreferencesEditorModel(model.uri)
|
||||
.then(preferencesEditorModel => {
|
||||
const settingsEditorModel = <SettingsEditorModel>preferencesEditorModel;
|
||||
this.modelDisposables.push(settingsEditorModel);
|
||||
this.modelDisposables.push(model.onDidChangeContent(() => settingsEditorModel.save()));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { ResourceMap } from 'vs/base/common/map';
|
|||
import * as labels from 'vs/base/common/labels';
|
||||
import * as strings from 'vs/base/common/strings';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { EditorInput } from 'vs/workbench/common/editor';
|
||||
import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService';
|
||||
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
|
||||
|
@ -28,7 +29,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti
|
|||
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
|
||||
import { IConfigurationEditingService, ConfigurationTarget } from 'vs/workbench/services/configuration/common/configurationEditing';
|
||||
import { IPreferencesService, IPreferencesEditorModel, ISetting, getSettingsTargetName } from 'vs/workbench/parts/preferences/common/preferences';
|
||||
import { SettingsEditorModel, DefaultSettingsEditorModel, DefaultKeybindingsEditorModel, defaultKeybindingsContents } from 'vs/workbench/parts/preferences/common/preferencesModels';
|
||||
import { SettingsEditorModel, DefaultSettingsEditorModel, DefaultKeybindingsEditorModel, defaultKeybindingsContents, WorkspaceConfigModel } from 'vs/workbench/parts/preferences/common/preferencesModels';
|
||||
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
||||
import { DefaultPreferencesEditorInput, PreferencesEditorInput } from 'vs/workbench/parts/preferences/browser/preferencesEditor';
|
||||
import { KeybindingsEditorInput } from 'vs/workbench/parts/preferences/browser/keybindingsEditor';
|
||||
|
@ -57,6 +58,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
private defaultPreferencesEditorModels: ResourceMap<TPromise<IPreferencesEditorModel<any>>>;
|
||||
private lastOpenedSettingsInput: PreferencesEditorInput = null;
|
||||
|
||||
private _onDispose: Emitter<void> = new Emitter<void>();
|
||||
|
||||
constructor(
|
||||
@IWorkbenchEditorService private editorService: IWorkbenchEditorService,
|
||||
@IEditorGroupService private editorGroupService: IEditorGroupService,
|
||||
|
@ -99,6 +102,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
|
||||
readonly defaultSettingsResource = URI.from({ scheme: network.Schemas.vscode, authority: 'defaultsettings', path: '/settings.json' });
|
||||
readonly defaultKeybindingsResource = URI.from({ scheme: network.Schemas.vscode, authority: 'defaultsettings', path: '/keybindings.json' });
|
||||
private readonly workspaceConfigSettingsResource = URI.from({ scheme: network.Schemas.vscode, authority: 'settings', path: '/workspaceSettings.json' });
|
||||
|
||||
get userSettingsResource(): URI {
|
||||
return this.getEditableSettingsURI(ConfigurationTarget.USER);
|
||||
|
@ -108,6 +112,15 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
return this.getEditableSettingsURI(ConfigurationTarget.WORKSPACE);
|
||||
}
|
||||
|
||||
resolveContent(uri: URI): TPromise<string> {
|
||||
const workspaceSettingsUri = this.getEditableSettingsURI(ConfigurationTarget.WORKSPACE);
|
||||
if (workspaceSettingsUri && workspaceSettingsUri.fsPath === uri.fsPath) {
|
||||
return this.resolveSettingsContentFromWorkspaceConfiguration();
|
||||
}
|
||||
return this.createPreferencesEditorModel(uri)
|
||||
.then(preferencesEditorModel => preferencesEditorModel ? preferencesEditorModel.content : null);
|
||||
}
|
||||
|
||||
createPreferencesEditorModel(uri: URI): TPromise<IPreferencesEditorModel<any>> {
|
||||
let promise = this.defaultPreferencesEditorModels.get(uri);
|
||||
if (promise) {
|
||||
|
@ -132,6 +145,12 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
return promise;
|
||||
}
|
||||
|
||||
if (this.workspaceConfigSettingsResource.fsPath === uri.fsPath) {
|
||||
promise = this.createEditableSettingsEditorModel(ConfigurationTarget.WORKSPACE);
|
||||
this.defaultPreferencesEditorModels.set(uri, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
if (this.getEditableSettingsURI(ConfigurationTarget.USER).fsPath === uri.fsPath) {
|
||||
return this.createEditableSettingsEditorModel(ConfigurationTarget.USER);
|
||||
}
|
||||
|
@ -243,12 +262,29 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
private createEditableSettingsEditorModel(configurationTarget: ConfigurationTarget): TPromise<SettingsEditorModel> {
|
||||
const settingsUri = this.getEditableSettingsURI(configurationTarget);
|
||||
if (settingsUri) {
|
||||
if (settingsUri.fsPath === this.workspaceConfigSettingsResource.fsPath) {
|
||||
return TPromise.join([this.textModelResolverService.createModelReference(settingsUri), this.textModelResolverService.createModelReference(this.contextService.getWorkspace().configuration)])
|
||||
.then(([reference, workspaceConfigReference]) => this.instantiationService.createInstance(WorkspaceConfigModel, reference, workspaceConfigReference, configurationTarget, this._onDispose.event));
|
||||
}
|
||||
return this.textModelResolverService.createModelReference(settingsUri)
|
||||
.then(reference => this.instantiationService.createInstance(SettingsEditorModel, reference, configurationTarget));
|
||||
}
|
||||
return TPromise.wrap<SettingsEditorModel>(null);
|
||||
}
|
||||
|
||||
private resolveSettingsContentFromWorkspaceConfiguration(): TPromise<string> {
|
||||
if (this.contextService.hasMultiFolderWorkspace()) {
|
||||
return this.textModelResolverService.createModelReference(this.contextService.getWorkspace().configuration)
|
||||
.then(reference => {
|
||||
const model = reference.object.textEditorModel;
|
||||
const settingsContent = WorkspaceConfigModel.getSettingsContentFromConfigContent(model.getValue());
|
||||
reference.dispose();
|
||||
return TPromise.as(settingsContent ? settingsContent : this.getEmptyEditableSettingsContent(ConfigurationTarget.WORKSPACE));
|
||||
});
|
||||
}
|
||||
return TPromise.as(null);
|
||||
}
|
||||
|
||||
private getEmptyEditableSettingsContent(target: ConfigurationTarget | URI): string {
|
||||
if (target === ConfigurationTarget.USER) {
|
||||
const emptySettingsHeader = nls.localize('emptySettingsHeader', "Place your settings in this file to overwrite the default settings");
|
||||
|
@ -275,7 +311,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
return this.toResource(paths.join('.vscode', 'settings.json'), workspace.roots[0]);
|
||||
}
|
||||
if (this.contextService.hasMultiFolderWorkspace()) {
|
||||
return workspace.configuration;
|
||||
return this.workspaceConfigSettingsResource;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -289,9 +325,6 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
private createSettingsIfNotExists(target: ConfigurationTarget | URI): TPromise<void> {
|
||||
const resource = this.getEditableSettingsURI(target);
|
||||
if (this.contextService.hasMultiFolderWorkspace() && target === ConfigurationTarget.WORKSPACE) {
|
||||
if (!this.configurationService.keys().workspace.length) {
|
||||
return this.jsonEditingService.write(resource, { key: 'settings', value: {} }, true).then(null, () => { });
|
||||
}
|
||||
return TPromise.as(null);
|
||||
}
|
||||
const editableSettingsEmptyContent = this.getEmptyEditableSettingsContent(target);
|
||||
|
@ -367,6 +400,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic
|
|||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._onDispose.fire();
|
||||
this.defaultPreferencesEditorModels.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
@ -73,6 +73,7 @@ export interface IPreferencesService {
|
|||
workspaceSettingsResource: URI;
|
||||
defaultKeybindingsResource: URI;
|
||||
|
||||
resolveContent(uri: URI): TPromise<string>;
|
||||
createPreferencesEditorModel<T>(uri: URI): TPromise<IPreferencesEditorModel<T>>;
|
||||
|
||||
openSettings(target: ConfigurationTarget | URI): TPromise<IEditor>;
|
||||
|
|
|
@ -47,12 +47,11 @@ export class PreferencesContentProvider implements IWorkbenchContribution {
|
|||
return TPromise.as(this.modelService.createModel(modelContent, mode, uri));
|
||||
}
|
||||
}
|
||||
return this.preferencesService.createPreferencesEditorModel(uri)
|
||||
.then(preferencesModel => {
|
||||
if (preferencesModel) {
|
||||
return this.preferencesService.resolveContent(uri)
|
||||
.then(content => {
|
||||
if (content !== null && content !== void 0) {
|
||||
let mode = this.modeService.getOrCreateMode('json');
|
||||
const model = this.modelService.createModel(preferencesModel.content, mode, uri);
|
||||
preferencesModel.dispose();
|
||||
const model = this.modelService.createModel(content, mode, uri);
|
||||
return TPromise.as(model);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -9,6 +9,7 @@ import { assign } from 'vs/base/common/objects';
|
|||
import { distinct } from 'vs/base/common/arrays';
|
||||
import URI from 'vs/base/common/uri';
|
||||
import { IReference } from 'vs/base/common/lifecycle';
|
||||
import Event from 'vs/base/common/event';
|
||||
import { Registry } from 'vs/platform/registry/common/platform';
|
||||
import { visit, JSONVisitor } from 'vs/base/common/json';
|
||||
import { IModel } from 'vs/editor/common/editorCommon';
|
||||
|
@ -19,8 +20,12 @@ import { ISettingsEditorModel, IKeybindingsEditorModel, ISettingsGroup, ISetting
|
|||
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
||||
import { ConfigurationTarget } from 'vs/workbench/services/configuration/common/configurationEditing';
|
||||
import { IMatch, or, matchesContiguousSubString, matchesPrefix, matchesCamelCase, matchesWords } from 'vs/base/common/filters';
|
||||
import { ITextEditorModel } from 'vs/editor/common/services/resolverService';
|
||||
import { ITextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService';
|
||||
import { IRange } from 'vs/editor/common/core/range';
|
||||
import { ITextFileService, StateChange } from "vs/workbench/services/textfile/common/textfiles";
|
||||
import { TPromise } from "vs/base/common/winjs.base";
|
||||
import { Queue } from "vs/base/common/async";
|
||||
import { IFileService } from 'vs/platform/files/common/files';
|
||||
|
||||
class SettingMatches {
|
||||
|
||||
|
@ -233,19 +238,21 @@ export abstract class AbstractSettingsModel extends EditorModel {
|
|||
export class SettingsEditorModel extends AbstractSettingsModel implements ISettingsEditorModel {
|
||||
|
||||
private _settingsGroups: ISettingsGroup[];
|
||||
private model: IModel;
|
||||
protected settingsModel: IModel;
|
||||
private queue: Queue<void>;
|
||||
|
||||
constructor(reference: IReference<ITextEditorModel>, private _configurationTarget: ConfigurationTarget) {
|
||||
constructor(reference: IReference<ITextEditorModel>, private _configurationTarget: ConfigurationTarget, @ITextFileService protected textFileService: ITextFileService) {
|
||||
super();
|
||||
this.model = reference.object.textEditorModel;
|
||||
this.settingsModel = reference.object.textEditorModel;
|
||||
this._register(this.onDispose(() => reference.dispose()));
|
||||
this._register(this.model.onDidChangeContent(() => {
|
||||
this._register(this.settingsModel.onDidChangeContent(() => {
|
||||
this._settingsGroups = null;
|
||||
}));
|
||||
this.queue = new Queue<void>();
|
||||
}
|
||||
|
||||
public get uri(): URI {
|
||||
return this.model.uri;
|
||||
return this.settingsModel.uri;
|
||||
}
|
||||
|
||||
public get configurationTarget(): ConfigurationTarget {
|
||||
|
@ -260,19 +267,27 @@ export class SettingsEditorModel extends AbstractSettingsModel implements ISetti
|
|||
}
|
||||
|
||||
public get content(): string {
|
||||
return this.model.getValue();
|
||||
return this.settingsModel.getValue();
|
||||
}
|
||||
|
||||
public filterSettings(filter: string): IFilterResult {
|
||||
return this.doFilterSettings(filter, this.settingsGroups);
|
||||
}
|
||||
|
||||
public save(): TPromise<any> {
|
||||
return this.queue.queue(() => this.doSave());
|
||||
}
|
||||
|
||||
protected doSave(): TPromise<any> {
|
||||
return this.textFileService.save(this.uri);
|
||||
}
|
||||
|
||||
protected findValueMatches(filter: string, setting: ISetting): IRange[] {
|
||||
return this.model.findMatches(filter, setting.valueRange, false, false, null, false).map(match => match.range);
|
||||
return this.settingsModel.findMatches(filter, setting.valueRange, false, false, null, false).map(match => match.range);
|
||||
}
|
||||
|
||||
private parse() {
|
||||
const model = this.model;
|
||||
const model = this.settingsModel;
|
||||
const settings: ISetting[] = [];
|
||||
let overrideSetting: ISetting = null;
|
||||
|
||||
|
@ -439,6 +454,144 @@ export class SettingsEditorModel extends AbstractSettingsModel implements ISetti
|
|||
}
|
||||
}
|
||||
|
||||
export class WorkspaceConfigModel extends SettingsEditorModel implements ISettingsEditorModel {
|
||||
|
||||
private workspaceConfigModel: IModel;
|
||||
private workspaceConfigEtag: string;
|
||||
|
||||
constructor(
|
||||
reference: IReference<ITextEditorModel>,
|
||||
workspaceConfigModelReference: IReference<ITextEditorModel>,
|
||||
_configurationTarget: ConfigurationTarget,
|
||||
onDispose: Event<void>,
|
||||
@IFileService private fileService: IFileService,
|
||||
@ITextModelService private textModelResolverService: ITextModelService,
|
||||
@ITextFileService textFileService: ITextFileService
|
||||
) {
|
||||
super(reference, _configurationTarget, textFileService);
|
||||
|
||||
this._register(workspaceConfigModelReference);
|
||||
this.workspaceConfigModel = workspaceConfigModelReference.object.textEditorModel;
|
||||
|
||||
// Only listen to state changes. Content changes without saving are not synced.
|
||||
this._register(this.textFileService.models.get(this.workspaceConfigModel.uri).onDidStateChange(statChange => this._onWorkspaceConfigFileStateChanged(statChange)));
|
||||
this.onDispose(() => super.dispose());
|
||||
}
|
||||
|
||||
protected doSave(): TPromise<any> {
|
||||
if (this.textFileService.isDirty(this.workspaceConfigModel.uri)) {
|
||||
// Throw an error?
|
||||
return TPromise.as(null);
|
||||
}
|
||||
|
||||
const content = this.createWorkspaceConfigContentFromSettingsModel();
|
||||
if (content !== this.workspaceConfigModel.getValue()) {
|
||||
return this.fileService.updateContent(this.workspaceConfigModel.uri, content)
|
||||
.then(stat => this.workspaceConfigEtag = stat.etag);
|
||||
}
|
||||
|
||||
return TPromise.as(null);
|
||||
}
|
||||
|
||||
private createWorkspaceConfigContentFromSettingsModel(): string {
|
||||
const workspaceConfigContent = this.workspaceConfigModel.getValue();
|
||||
const { settingsPropertyEndsAt, nodeAfterSettingStartsAt } = WorkspaceConfigModel.parseWorkspaceConfigContent(workspaceConfigContent);
|
||||
const workspaceConfigEndsAt = workspaceConfigContent.lastIndexOf('}');
|
||||
|
||||
// Settings property exist in Workspace Configuration and has Ending Brace
|
||||
if (settingsPropertyEndsAt !== -1 && workspaceConfigEndsAt > settingsPropertyEndsAt) {
|
||||
|
||||
// Place settings at the end
|
||||
let from = workspaceConfigContent.indexOf(':', settingsPropertyEndsAt) + 1;
|
||||
let to = workspaceConfigEndsAt;
|
||||
let settingsContent = this.settingsModel.getValue();
|
||||
|
||||
// There is a node after settings property
|
||||
// Place settings before that node
|
||||
if (nodeAfterSettingStartsAt !== -1) {
|
||||
settingsContent += ',';
|
||||
to = nodeAfterSettingStartsAt;
|
||||
}
|
||||
|
||||
return workspaceConfigContent.substring(0, from) + settingsContent + workspaceConfigContent.substring(to);
|
||||
}
|
||||
|
||||
// Settings property does not exist. Place it at the end
|
||||
return workspaceConfigContent.substring(0, workspaceConfigEndsAt) + `,\n"settings": ${this.settingsModel.getValue()}\n` + workspaceConfigContent.substring(workspaceConfigEndsAt);
|
||||
}
|
||||
|
||||
private _onWorkspaceConfigFileStateChanged(stateChange: StateChange): void {
|
||||
let hasToUpdate = false;
|
||||
switch (stateChange) {
|
||||
case StateChange.SAVED:
|
||||
hasToUpdate = this.workspaceConfigEtag !== this.textFileService.models.get(this.workspaceConfigModel.uri).getETag();
|
||||
break;
|
||||
}
|
||||
if (hasToUpdate) {
|
||||
this.onWorkspaceConfigFileContentChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private onWorkspaceConfigFileContentChanged(): void {
|
||||
this.workspaceConfigEtag = this.textFileService.models.get(this.workspaceConfigModel.uri).getETag();
|
||||
const settingsValue = WorkspaceConfigModel.getSettingsContentFromConfigContent(this.workspaceConfigModel.getValue());
|
||||
if (settingsValue) {
|
||||
this.settingsModel.setValue(settingsValue);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Not disposable by default
|
||||
}
|
||||
|
||||
static getSettingsContentFromConfigContent(workspaceConfigContent: string): string {
|
||||
const { settingsPropertyEndsAt, nodeAfterSettingStartsAt } = WorkspaceConfigModel.parseWorkspaceConfigContent(workspaceConfigContent);
|
||||
|
||||
const workspaceConfigEndsAt = workspaceConfigContent.lastIndexOf('}');
|
||||
|
||||
if (settingsPropertyEndsAt !== -1) {
|
||||
const from = workspaceConfigContent.indexOf(':', settingsPropertyEndsAt) + 1;
|
||||
const to = nodeAfterSettingStartsAt !== -1 ? nodeAfterSettingStartsAt : workspaceConfigEndsAt;
|
||||
return workspaceConfigContent.substring(from, to);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static parseWorkspaceConfigContent(content: string): { settingsPropertyEndsAt: number, nodeAfterSettingStartsAt: number } {
|
||||
|
||||
let settingsPropertyEndsAt = -1;
|
||||
let nodeAfterSettingStartsAt = -1;
|
||||
|
||||
let rootProperties = [];
|
||||
let ancestors = [];
|
||||
let currentProperty = '';
|
||||
|
||||
visit(content, <JSONVisitor>{
|
||||
onObjectProperty: (name: string, offset: number, length: number) => {
|
||||
currentProperty = name;
|
||||
if (ancestors.length === 1) {
|
||||
rootProperties.push(name);
|
||||
if (rootProperties[rootProperties.length - 1] === 'settings') {
|
||||
settingsPropertyEndsAt = offset + length;
|
||||
}
|
||||
if (rootProperties[rootProperties.length - 2] === 'settings') {
|
||||
nodeAfterSettingStartsAt = offset;
|
||||
}
|
||||
}
|
||||
},
|
||||
onObjectBegin: (offset: number, length: number) => {
|
||||
ancestors.push(currentProperty);
|
||||
},
|
||||
onObjectEnd: (offset: number, length: number) => {
|
||||
ancestors.pop();
|
||||
}
|
||||
}, { allowTrailingComma: true });
|
||||
|
||||
return { settingsPropertyEndsAt, nodeAfterSettingStartsAt };
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultSettingsEditorModel extends AbstractSettingsModel implements ISettingsEditorModel {
|
||||
|
||||
private _allSettingsGroups: ISettingsGroup[];
|
||||
|
|
Loading…
Reference in a new issue