custom-css-hot-reload
Version:
Customize your VSCode UI with live CSS updates, no restart needed!
451 lines (392 loc) • 13.1 kB
JavaScript
/* eslint-disable max-len */
/* eslint-disable no-undef */
/* eslint-disable no-param-reassign */
const vscode = require('vscode');
const path = require('path');
const msg = require('./messages').messages;
const uuid = require('uuid');
const getInjectionJs = require('./get-injection-code');
const fetch = require('node-fetch');
const Url = require('url');
const { debounce } = require('throttle-debounce');
const os = require('os');
const fs = require('fs');
class CustomCSSHotReload {
BackupPostFix = 'bak-custom-css-hot-reload';
constructor(context) {
this.context = context;
this.appDir = require.main ? path.dirname(require.main.filename) : globalThis._VSCODE_FILE_ROOT;
this.base = path.join(this.appDir, 'vs', 'code');
this.htmlFile = this.getHtmlFilePath();
this.debouncedUpdateFiles = debounce(100, this.updateFiles.bind(this), { atBegin: false });
this.config = vscode.workspace.getConfiguration('custom_css_hot_reload');
}
init() {
if (!this.appDir) {
vscode.window.showInformationMessage(msg.unableToLocateVsCodeInstallationPath);
}
this.setupStatusBar();
this.setupEventListeners();
this.debouncedUpdateFiles();
this.registerCommands();
}
getHtmlFilePath() {
let htmlFile = path.join(this.base, 'electron-sandbox', 'workbench', 'workbench-dev.html');
if (!fs.existsSync(htmlFile)) {
htmlFile = path.join(this.base, 'electron-sandbox', 'workbench', 'workbench.html');
}
if (!fs.existsSync(htmlFile)) {
htmlFile = path.join(this.base, 'electron-sandbox', 'workbench', 'workbench-apc-extension.html');
}
if (!fs.existsSync(htmlFile)) {
htmlFile = path.join(this.base, 'electron-sandbox', 'workbench', 'workbench.esm.html');
}
if (!fs.existsSync(htmlFile)) {
vscode.window.showInformationMessage(msg.unableToLocateVsCodeInstallationPath);
}
return htmlFile;
}
getBackupFilePath(uuid) {
return path.join(this.base, 'electron-sandbox', 'workbench', `workbench.${uuid}.${this.BackupPostFix}`);
}
async install(options) {
const uuidSession = uuid.v4();
if (options.createBackup) {
await this.createBackup(uuidSession);
}
await this.performPatch(uuidSession, options);
}
async cmdUpdate(options) {
await this.cmdUninstall();
await this.install(options);
}
async cmdUninstall() {
this.disposeStylesWithoutRestart();
const backupUuid = await this.getBackupUuid(this.htmlFile);
if (!backupUuid) {
return;
}
const backupPath = this.getBackupFilePath(backupUuid);
await this.restoreBackup(backupPath);
await this.deleteBackupFiles();
}
async getBackupUuid(htmlFilePath) {
try {
const htmlContent = await fs.promises.readFile(htmlFilePath, 'utf-8');
const m = htmlContent.match(/<!-- !! CUSTOM-CSS-HOT-RELOAD-SESSION-ID ([0-9a-fA-F-]+) !! -->/);
if (!m) {
return null;
}
return m[1];
} catch (e) {
vscode.window.showInformationMessage(msg.somethingWrong + e);
throw e;
}
}
async restoreBackup(backupFilePath) {
try {
if (fs.existsSync(backupFilePath)) {
await fs.promises.unlink(this.htmlFile);
await fs.promises.copyFile(backupFilePath, this.htmlFile);
}
} catch (e) {
console.log('e:', e);
vscode.window.showInformationMessage(msg.admin);
throw e;
}
}
async createBackup(uuidSession) {
try {
let html = await fs.promises.readFile(this.htmlFile, 'utf-8');
html = this.clearExistingPatches(html);
await fs.promises.writeFile(this.getBackupFilePath(uuidSession), html, 'utf-8');
} catch (e) {
vscode.window.showInformationMessage(msg.admin);
throw e;
}
}
async deleteBackupFiles() {
const htmlDir = path.dirname(this.htmlFile);
const htmlDirItems = await fs.promises.readdir(htmlDir);
for (const item of htmlDirItems) {
if (item.endsWith(`.${this.BackupPostFix}`)) {
await fs.promises.unlink(path.join(htmlDir, item));
}
}
}
async performPatch(uuidSession, options) {
const config = vscode.workspace.getConfiguration('custom_css_hot_reload');
let html = await fs.promises.readFile(this.htmlFile, 'utf-8');
html = this.clearExistingPatches(html);
const injectedHtml = await this.patchHtml(config);
html = html.replace(/<meta\s+http-equiv="Content-Security-Policy"[\s\S]*?\/>/, '');
html = html.replace(
/(<\/html>)/,
`<!-- !! CUSTOM-CSS-HOT-RELOAD-SESSION-ID ${uuidSession} !! -->\n` +
`<!-- !! CUSTOM-CSS-HOT-RELOAD-START !! -->\n${injectedHtml}<!-- !! CUSTOM-CSS-HOT-RELOAD-END !! -->\n</html>`,
);
try {
await fs.promises.writeFile(this.htmlFile, html, 'utf-8');
} catch (e) {
vscode.window.showInformationMessage(msg.admin);
this.disabledRestart();
return;
}
// if (options?.reload) {
// this.reloadWindow();
// }
if (options.reload) {
this.enabledRestart();
}
}
enabledRestart() {
vscode.window.showInformationMessage(msg.enabled, msg.restartIde).then((btn) => {
// if close button is clicked btn is undefined, so no reload window
if (btn === msg.restartIde) {
this.reloadWindow();
}
});
}
clearExistingPatches(html) {
html = html.replace(
/<!-- !! CUSTOM-CSS-HOT-RELOAD-START !! -->[\s\S]*?<!-- !! CUSTOM-CSS-HOT-RELOAD-END !! -->\n*/,
'',
);
html = html.replace(/<!-- !! CUSTOM-CSS-HOT-RELOAD-SESSION-ID [\w-]+ !! -->\n*/g, '');
return html;
}
async patchHtml(config) {
let res = `<script data-extension="custom-css-hot-reload">${getInjectionJs()}</script>`;
res += `<style data-extension="custom-css-hot-reload">
div#bartag\\.custom-css-hot-reload { display: none!important; }
</style>`;
for (const item of config.imports) {
const imp = await this.patchHtmlForItem(item);
if (imp) {
res += imp;
}
}
return res;
}
resolveVariable(key) {
const variables = {
cwd: () => {
return process.cwd();
},
userHome: () => {
return os.homedir();
},
execPath: () => {
return process.env.VSCODE_EXEC_PATH ?? process.execPath;
},
pathSeparator: () => {
return path.sep;
},
'/': () => {
return path.sep;
},
};
if (key in variables) {
return variables[key]();
}
if (key.startsWith('env:')) {
const [_, envKey, optionalDefault] = key.split(':');
return process.env[envKey] ?? optionalDefault ?? '';
}
}
parseUrl(url) {
if (/^file:/.test(url)) {
return url.replaceAll(/\$\{([^\{\}]+)\}/g, (substr, key) => {
return this.resolveVariable(key) ?? substr;
});
}
return url;
}
async getContent(url) {
if (/^file:/.test(url.toString())) {
const fp = Url.fileURLToPath(url);
return await fs.promises.readFile(fp);
}
const response = await fetch(url);
return response.buffer();
}
normalizePath(path) {
return path
.replace(/^file:\/\//i, '')
.replace(/\\/g, '/')
.replace(/^\//, '')
.toLowerCase();
}
async patchHtmlForItem(url) {
if (!url || typeof url !== 'string') {
return '';
}
let parsed = new Url.URL(url);
const ext = path.extname(parsed.pathname);
try {
parsed = this.parseUrl(url);
const fetched = await this.getContent(parsed);
const id = this.normalizePath(url);
if (ext === '.css') {
return `<style id="${id}" data-extension="custom-css-hot-reload">${fetched}</style>`;
} else if (ext === '.js') {
return `<script id="${id}" data-extension="custom-css-hot-reload">${fetched}</script>`;
}
throw new Error(`Unsupported extension type: ${ext}`);
} catch (e) {
vscode.window.showWarningMessage(msg.cannotLoad(parsed.toString()));
return '';
}
}
reloadWindow() {
vscode.commands.executeCommand('workbench.action.reloadWindow');
}
disabledRestart() {
vscode.window.showInformationMessage(msg.disabled, msg.restartIde).then((btn) => {
if (btn === msg.restartIde) {
this.reloadWindow();
}
});
}
registerCommands() {
this.context.subscriptions.push(
vscode.commands.registerCommand('extension.installCustomCSSHotReload', async () => {
await this.install({ reload: true, createBackup: true });
}),
vscode.commands.registerCommand('extension.uninstallCustomCSSHotReload', this.cmdUninstall.bind(this)),
vscode.commands.registerCommand('extension.updateCustomCSSHotReload', () => {
return this.cmdUpdate({ reload: true, createBackup: false });
}),
vscode.commands.registerCommand('extension.openImportedFiles', this.openImportedFiles.bind(this)),
);
}
/**
* Opens the imported files specified in the configuration in separate editor tabs.
*/
async openImportedFiles() {
const config = vscode.workspace.getConfiguration('custom_css_hot_reload');
for (const url of config.imports) {
const parsedUrl = this.parseUrl(url);
if (/^file:/.test(parsedUrl)) {
const filePath = Url.fileURLToPath(parsedUrl);
const document = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(document, { preview: false });
}
}
}
setupStatusBar() {
this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, Infinity);
this.statusBarItem.text = '$(paintcan) Custom CSS Hot Reload';
this.statusBarItem.show();
this.context.subscriptions.push(this.statusBarItem);
}
async getMatchingFileContents(parsedUrls) {
const openEditors = vscode.window.visibleTextEditors;
const fileContents = {};
for (const editor of openEditors) {
const filePath = editor.document.uri.fsPath;
if (parsedUrls.some((monitoredPath) => {
return this.compareFilePaths(monitoredPath, filePath);
})) {
const id = this.normalizePath(filePath);
fileContents[id] = editor.document.getText();
}
}
return fileContents;
}
compareFilePaths(path1, path2) {
return this.normalizePath(path1) === this.normalizePath(path2);
}
isMonitoredFile(filePath) {
const parsedUrls = this.config.imports.map((url) => {
return this.parseUrl(url);
});
const res = parsedUrls.some((monitoredPath) => {
return this.compareFilePaths(monitoredPath, filePath);
});
return res;
}
async updateFiles() {
const parsedUrls = this.config.imports.map((url) => {
return this.parseUrl(url);
});
const contents = await this.getMatchingFileContents(parsedUrls);
if (Object.keys(contents).length === 0) {
return;
}
this.sendCommandToDom({ command: 'updateContents', payload: { contents } });
}
disposeStylesWithoutRestart() {
this.sendCommandToDom({ command: 'dispose' });
}
setupEventListeners() {
this.context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(async () => {
if (this.config.hotReloadMode === 'onChange') {
this.debouncedUpdateFiles();
}
}),
vscode.workspace.onDidOpenTextDocument(async () => {
if (this.config.hotReloadMode === 'onChange') {
this.debouncedUpdateFiles();
}
}),
vscode.workspace.onDidCloseTextDocument(async () => {
if (this.config.hotReloadMode === 'onChange') {
this.debouncedUpdateFiles();
}
}),
vscode.workspace.onDidChangeTextDocument(async (event) => {
const filePath = event.document.uri.fsPath;
if (this.isMonitoredFile(filePath) && this.config.hotReloadMode === 'onChange') {
this.debouncedUpdateFiles();
}
}),
vscode.workspace.onDidSaveTextDocument(async (event) => {
const filePath = event.uri.fsPath;
if (this.isMonitoredFile(filePath)) {
try {
await this.install({ reload: false, createBackup: false });
} catch (error) {
vscode.window.showInformationMessage(msg.somethingWrong + error.message);
}
this.debouncedUpdateFiles();
}
}),
vscode.workspace.onDidChangeConfiguration(async (ex) => {
const hasChanged = ex.affectsConfiguration('custom_css_hot_reload.imports') || ex.affectsConfiguration('custom_css_hot_reload.hotReloadMode');
if (hasChanged) {
this.config = vscode.workspace.getConfiguration('custom_css_hot_reload');
await this.cmdUpdate({ reload: false });
this.debouncedUpdateFiles();
}
}),
);
}
sendCommandToDom(command) {
this.statusBarItem.tooltip = JSON.stringify(command);
}
async dispose() {
this.statusBarItem.dispose();
this.context.subscriptions.forEach((subscription) => {
return subscription.dispose();
});
}
async deactivate() {
await this.cmdUninstall();
}
}
let customCSSHotReloadInstance;
module.exports = {
activate: (context) => {
customCSSHotReloadInstance = new CustomCSSHotReload(context);
customCSSHotReloadInstance.init();
},
deactivate: async () => {
// const logFilePath = path.join(logDir, `test-to-run.log`);
// fs.writeFileSync(logFilePath, `Test ${Boolean(customCSSHotReloadInstance)}`);
if (customCSSHotReloadInstance) {
// await customCSSHotReloadInstance.deactivate()
}
},
};