Skip to content

Enable settings import for Workbench #7604

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 30 additions & 20 deletions src/vs/workbench/contrib/positronWelcome/browser/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@
*--------------------------------------------------------------------------------------------*/

import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { isWeb } from '../../../../base/common/platform.js';
import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js';
import { localize } from '../../../../nls.js';
import { Action2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js';
import { IPathService } from '../../../services/path/common/pathService.js';
import { IPreferencesService } from '../../../services/preferences/common/preferences.js';
import { COMPARE_WITH_SAVED_COMMAND_ID } from '../../files/browser/fileConstants.js';
import { getCodeSettingsPath, setImportWasPrompted } from './helpers.js';
import { ITerminalService } from '../../terminal/browser/terminal.js';
import { getCodeSettingsPathNative, getCodeSettingsPathWeb, mergeSettingsJson, setImportWasPrompted } from './helpers.js';

export class PositronImportSettings extends Action2 {
/**
Expand Down Expand Up @@ -48,49 +49,58 @@ export class PositronImportSettings extends Action2 {
*/
override async run(accessor: ServicesAccessor): Promise<void> {
const pathService = accessor.get(IPathService);
const commandService = accessor.get(ICommandService);
const prefService = accessor.get(IPreferencesService);
const fileService = accessor.get(IFileService);
const editorService = accessor.get(IEditorService);
const notificationService = accessor.get(INotificationService);
const loggingService = accessor.get(ILogService);
const terminalService = accessor.get(ITerminalService);
const fileConfigurationService = accessor.get(IFilesConfigurationService);

const disposables = new DisposableStore();

const positronSettingsPath = await prefService.getEditableSettingsURI(ConfigurationTarget.USER);
if (!positronSettingsPath) {
loggingService.trace('No Positron settings found');
return;
}

const codeSettingsPath = await getCodeSettingsPath(pathService);
const codeSettingsPath = await (
isWeb ? getCodeSettingsPathWeb(pathService, terminalService) :
getCodeSettingsPathNative(pathService)
);
if (!codeSettingsPath) {
loggingService.trace('No Visual Studio Code settings found');
return;
}

const codeSettingsContent = await fileService
.readFile(codeSettingsPath)
.then(content => content.value.toString());
const mergedSettings = await mergeSettingsJson(
fileService,
positronSettingsPath,
codeSettingsPath,
);


if (await fileService.exists(positronSettingsPath)) {
await commandService.executeCommand(COMPARE_WITH_SAVED_COMMAND_ID, positronSettingsPath);
} else {
if (!await fileService.exists(positronSettingsPath)) {
await fileService.createFile(positronSettingsPath);
await editorService.openEditor({
resource: positronSettingsPath
});
}

await editorService.openEditor({
resource: positronSettingsPath,
});

const editor = editorService.activeEditor;
const model = editorService.activeTextEditorControl?.getModel();

if (model && 'original' in model && 'modified' in model) {
model.modified.setValue('// Settings imported from Visual Studio Code\n' + codeSettingsContent);
} else if (model && 'setValue' in model) {
model.setValue('// Settings imported from Visual Studio Code\n' + codeSettingsContent);
if (editor) {
disposables.add(
fileConfigurationService.disableAutoSave(editor)
);
}

const disposables = new DisposableStore();
if (model && 'setValue' in model) {
model.setLanguage('jsonl');
model.setValue('// Settings imported from Visual Studio Code\n' + mergedSettings);
}

const notification = notificationService.prompt(
Severity.Info,
Expand Down
202 changes: 189 additions & 13 deletions src/vs/workbench/contrib/positronWelcome/browser/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
import { URI } from '../../../../base/common/uri.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from '../../../../platform/storage/common/storage.js';
import { IPathService } from '../../../services/path/common/pathService.js';
import { PositronImportSettings } from './actions.js';
import * as platform from '../../../../base/common/platform.js';
import { localize } from '../../../../nls.js';
import { env } from '../../../../base/common/process.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { parse } from '../../../../base/common/jsonc.js';
import { ITerminalService } from '../../terminal/browser/terminal.js';
import { untildify } from '../../../../base/common/labels.js';

const WAS_PROMPTED_KEY = 'positron.welcome.promptedImport';

Expand All @@ -21,11 +25,12 @@ export async function getImportWasPrompted(
return storageService.getBoolean(WAS_PROMPTED_KEY, StorageScope.PROFILE, false);
}

export function setImportWasPrompted(
export async function setImportWasPrompted(
storageService: IStorageService,
state: boolean = true
) {
storageService.store(WAS_PROMPTED_KEY, state, StorageScope.PROFILE, StorageTarget.MACHINE);
await storageService.flush(WillSaveStateReason.SHUTDOWN);
}

export async function promptImport(
Expand Down Expand Up @@ -69,34 +74,205 @@ export async function promptImport(
);
}

export async function getCodeSettingsPath(
pathService: IPathService, os: platform.OperatingSystem = platform.OS): Promise<URI> {
export async function getCodeSettingsPathWeb(
pathService: IPathService,
terminalService: ITerminalService,
): Promise<URI> {
const homedir = await pathService.userHome();

const terminalBackend = terminalService.getPrimaryBackend();
const terminalEnv = await terminalBackend?.getEnvironment();
if (!terminalEnv) {
throw new Error('Unable to get terminal environment');
}
const codeDataDir = terminalEnv['RS_VSCODE_USER_DATA_DIR'] ? URI.file(untildify(terminalEnv['RS_VSCODE_USER_DATA_DIR'], homedir.fsPath)) : URI.joinPath(homedir, '.vscode-server');
return URI.joinPath(codeDataDir, 'User', 'settings.json').with({ scheme: pathService.defaultUriScheme });
}

export async function getCodeSettingsPathNative(
pathService: IPathService,
os: platform.OperatingSystem = platform.OS
): Promise<URI> {
const path = await pathService.path;
const homedir = await pathService.userHome();

let codeDataDir;
switch (os) {
case platform.OperatingSystem.Windows:
if (env['APPDATA']) {
return URI.file(path.join(env['APPDATA'], 'Code', 'User', 'settings.json'));
codeDataDir = URI.file(path.join(env['APPDATA'], 'Code'));
} else if (env['USERPROFILE']) {
const userProfile = env['USERPROFILE'];
return URI.file(path.join(userProfile, 'AppData', 'Roaming', 'Code', 'User', 'settings.json'));
codeDataDir = URI.file(path.join(userProfile, 'AppData', 'Roaming', 'Code'));
} else {
return URI.joinPath(homedir, 'AppData', 'Roaming', 'Code', 'User', 'settings.json');
codeDataDir = URI.joinPath(homedir, 'AppData', 'Roaming', 'Code');
}

break;
case platform.OperatingSystem.Macintosh:
return URI.joinPath(homedir, 'Library', 'Application Support', 'Code', 'User', 'settings.json');

codeDataDir = URI.joinPath(homedir, 'Library', 'Application Support', 'Code');
break;
case platform.OperatingSystem.Linux:
return URI.joinPath(
codeDataDir = URI.joinPath(
(env['XDG_CONFIG_HOME'] ?
URI.file(env['XDG_CONFIG_HOME']) :
URI.joinPath(homedir, '.config')
), 'Code', 'User', 'settings.json'
), 'Code'
);

break;
default:
throw new Error('Platform not supported');
}

return URI.joinPath(codeDataDir, 'User', 'settings.json');
}

/**
* Merge two JSON settings files.
* Returns the merged settings as a string with git merge conflict markers.
*
* @param fileService File service to read the files
* @param existing URI to existing settings file
* @param incoming URI to incoming settings file
* @returns Merged settings JSON as a string
*/
export async function mergeSettingsJson(
fileService: IFileService,
existing: URI,
incoming: URI
): Promise<string> {
// Read the contents of the existing and incoming settings files
const existingContents = await fileService.readFile(existing);
const incomingContents = await fileService.readFile(incoming);

// Parse the contents as JSON
// Using the `jsonc.parse` function to handle comments and trailing commas
const existingJson = parse<Record<string, any>>(existingContents.value.toString());
const incomingJson = parse<Record<string, any>>(incomingContents.value.toString());

// Merge the two JSON objects
const mergedJson = mergeObjects(existingJson, incomingJson);
// Serialize the merged JSON object to a string with git merge conflict markers
const serializedOutput = serializeWithMergeMarkers(mergedJson);
return serializedOutput;
}

/**
* Merges two objects, optionally handling nested objects recursively.
* In case of conflicts, it marks them using a special structure for later serialization.
*
* @param existing The existing object to merge.
* @param incoming The incoming object to merge.
* @returns The merged object with conflicts marked.
*/
function mergeObjects(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
const merged: Record<string, any> = {};
// Create a set of all keys from both objects
const allKeys = new Set([...Object.keys(existing), ...Object.keys(incoming)]);

for (const key of allKeys) {
if (key in existing && key in incoming) {
// The key exists in both objects
if (
typeof existing[key] === 'object' &&
typeof incoming[key] === 'object' &&
!Array.isArray(existing[key]) &&
!Array.isArray(incoming[key]) &&
existing[key] !== null &&
incoming[key] !== null
) {
// Both values are objects, so we need to merge them recursively
// and mark the conflict if it exists
merged[key] = mergeObjects(existing[key], incoming[key]);
} else if (
JSON.stringify(existing[key]) !== JSON.stringify(incoming[key])
) {
// Otherwise, if a scalar or array with different values, mark as conflict
merged[key] = {
conflict: true,
existing: existing[key],
incoming: incoming[key]
};
} else {
// If the values are the same, just take one of them
merged[key] = existing[key];
}
} else if (key in existing) {
// The key exists only in the existing object
merged[key] = existing[key];
} else if (key in incoming) {
// The key exists only in the incoming object
merged[key] = incoming[key];
}
}

return merged;
}

/**
* Serializes a JSON object to a string, adding git merge conflict markers
* for conflicting keys.
*
* @param json The JSON object to serialize.
* @param level The current indentation level (used for nested objects).
* @returns The serialized JSON string with merge markers.
*/
function serializeWithMergeMarkers(json: Record<string, any>, level: number = 1): string {
// Start with opening brace for the top level
let result = (level === 1) ? '{\n' : '';
// Get all keys in the object
const keys = Object.keys(json);

// The proper indentation for all keys, including top-level
const keyIndent = '\t'.repeat(level);

for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = json[key];

// Serialize the key back into JSON-friendly format
const serializedKey = JSON.stringify(key);
const isLastKey = i === keys.length - 1;
// End with a comma if not the last line
const lineEnd = isLastKey ? '' : ',';

if (typeof value === 'object' && value !== null && value.conflict) {
// This is a conflict, so we need to serialize it with merge markers
// Serialize the existing and incoming values
// making sure to indent them properly
const serializedExisting = JSON.stringify(value.existing, null, '\t')
.replace(
/\n/g,
'\n' + '\t'.repeat(level)
);
const serializedIncoming = JSON.stringify(value.incoming, null, '\t')
.replace(
/\n/g,
'\n' + '\t'.repeat(level)
);

// Add the merge markers
result += `<<<<<<< Existing\n`;
result += `${keyIndent}${serializedKey}: ${serializedExisting}${lineEnd}\n`;
result += `=======\n`;
result += `${keyIndent}${serializedKey}: ${serializedIncoming}${lineEnd}\n`;
result += `>>>>>>> Incoming\n`;
} else if (Array.isArray(value) || typeof value !== 'object') {
// This is a simple value or an array, so we can serialize it directly
const serializedValue = JSON.stringify(value, null, '\t')
.replace(
/\n/g,
'\n' + '\t'.repeat(level)
);
result += `${keyIndent}${serializedKey}: ${serializedValue}${lineEnd}\n`;
} else {
// This is a nested object, so we need to serialize it recursively
result += `${keyIndent}${serializedKey}: {\n`;
result += serializeWithMergeMarkers(value, level + 1);
result += `${keyIndent}}${lineEnd}\n`;
}
}


// Add closing brace with proper indentation for top level
return (level === 1) ? result + '}' : result;
}
Loading