extension: migrate vscode to editors/ (#316)
This commit is contained in:
224
editors/vscode/src/download.ts
Normal file
224
editors/vscode/src/download.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
// @ts-ignore
|
||||
import decompress = require('decompress');
|
||||
|
||||
interface GitHubRelease {
|
||||
tag_name: string;
|
||||
assets: {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
||||
export async function ensureServerBinary(
|
||||
context: vscode.ExtensionContext,
|
||||
channel: vscode.OutputChannel
|
||||
): Promise<string | undefined> {
|
||||
|
||||
const storagePath = context.globalStorageUri.fsPath;
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
channel.appendLine(`[Download] Initializing clice downloader...`);
|
||||
channel.appendLine(`[Download] Platform: ${platform}, Arch: ${arch}, Storage: ${storagePath}`);
|
||||
|
||||
let platformKeyword = '';
|
||||
let archKeyword = '';
|
||||
let binaryName = 'clice';
|
||||
|
||||
if (platform === 'win32') {
|
||||
platformKeyword = 'windows';
|
||||
archKeyword = 'x64';
|
||||
binaryName = 'clice.exe';
|
||||
} else if (platform === 'darwin') {
|
||||
platformKeyword = 'macos';
|
||||
archKeyword = arch;
|
||||
} else if (platform === 'linux') {
|
||||
platformKeyword = 'linux';
|
||||
archKeyword = arch === 'x64' ? 'x86_64' : arch;
|
||||
} else {
|
||||
const msg = `Unsupported platform: ${platform}`;
|
||||
channel.appendLine(`[Download] Error: ${msg}`);
|
||||
vscode.window.showErrorMessage(msg);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const executablePath = path.join(storagePath, "bin", binaryName);
|
||||
|
||||
if (fs.existsSync(executablePath)) {
|
||||
channel.appendLine(`[Download] Found existing binary at: ${executablePath}`);
|
||||
// TODO: check tag update
|
||||
return executablePath;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(storagePath)) {
|
||||
channel.appendLine(`[Download] Creating storage directory: ${storagePath}`);
|
||||
fs.mkdirSync(storagePath, { recursive: true });
|
||||
}
|
||||
|
||||
const statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
|
||||
|
||||
try {
|
||||
statusItem.text = "$(sync~spin) Checking clice updates...";
|
||||
statusItem.show();
|
||||
|
||||
channel.appendLine(`[Download] Fetching latest release from GitHub...`);
|
||||
const release = await fetchReleaseInfo(channel);
|
||||
channel.appendLine(`[Download] Latest tag: ${release.tag_name}`);
|
||||
|
||||
const asset = release.assets.find(a => {
|
||||
const name = a.name.toLowerCase();
|
||||
return name.includes(platformKeyword) &&
|
||||
name.includes(archKeyword) &&
|
||||
!name.includes('symbol');
|
||||
});
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`No compatible asset found for ${platform}-${archKeyword} in release ${release.tag_name}`);
|
||||
}
|
||||
|
||||
channel.appendLine(`[Download] Found asset: ${asset.name}`);
|
||||
channel.appendLine(`[Download] Download URL: ${asset.browser_download_url}`);
|
||||
|
||||
const tempArchiveName = asset.name;
|
||||
const tempArchivePath = path.join(storagePath, tempArchiveName);
|
||||
|
||||
statusItem.text = `$(cloud-download) Downloading clice...`;
|
||||
await downloadFile(asset.browser_download_url, tempArchivePath, channel);
|
||||
|
||||
statusItem.text = `$(file-zip) Extracting...`;
|
||||
channel.appendLine(`[Download] Extracting ${tempArchivePath} to ${storagePath}...`);
|
||||
|
||||
await decompress(tempArchivePath, storagePath);
|
||||
channel.appendLine(`[Download] Extraction complete.`);
|
||||
|
||||
fs.unlinkSync(tempArchivePath);
|
||||
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
throw new Error(`Executable not found at ${executablePath} after extraction.`);
|
||||
}
|
||||
|
||||
if (platform !== 'win32') {
|
||||
channel.appendLine(`[Download] Setting executable permissions (chmod 755)...`);
|
||||
fs.chmodSync(executablePath, '755');
|
||||
}
|
||||
|
||||
channel.appendLine(`[Download] Setup successful. Binary ready at: ${executablePath}`);
|
||||
vscode.window.showInformationMessage(`Clice language server updated to ${release.tag_name}`);
|
||||
return executablePath;
|
||||
|
||||
} catch (error) {
|
||||
channel.appendLine(`[Download] CRITICAL ERROR during setup:`);
|
||||
if (error instanceof Error) {
|
||||
channel.appendLine(`[Download] Message: ${error.message}`);
|
||||
if (error.stack) {
|
||||
channel.appendLine(`[Download] Stack: ${error.stack}`);
|
||||
}
|
||||
} else {
|
||||
channel.appendLine(`[Download] Unknown error: ${JSON.stringify(error)}`);
|
||||
}
|
||||
|
||||
vscode.window.showErrorMessage(`Failed to download clice server. Check "clice" output channel for details.`, "Open Output").then(selection => {
|
||||
if (selection === "Open Output") {
|
||||
channel.show();
|
||||
}
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} finally {
|
||||
statusItem.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destPath: string, channel: vscode.OutputChannel, maxRedirects = 5): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (maxRedirects <= 0) {
|
||||
reject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fs.createWriteStream(destPath);
|
||||
channel.appendLine(`[Download] Start downloading to ${destPath}`);
|
||||
|
||||
https.get(url, { headers: { 'User-Agent': 'VSCode-Extension' } }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
channel.appendLine(`[Download] Redirecting to ${response.headers.location}`);
|
||||
file.close();
|
||||
downloadFile(response.headers.location!, destPath, channel, maxRedirects - 1).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Download failed with status code ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
channel.appendLine(`[Download] Download finished.`);
|
||||
resolve();
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destPath, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchReleaseInfo(channel: vscode.OutputChannel): Promise<GitHubRelease> {
|
||||
try {
|
||||
channel.appendLine('[Download] Attempting to fetch latest stable release...');
|
||||
const release = await fetchJson<GitHubRelease>('/repos/clice-io/clice/releases/latest');
|
||||
channel.appendLine(`[Download] Found stable release: ${release.tag_name}`);
|
||||
return release;
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes('404')) {
|
||||
channel.appendLine('[Download] Latest stable release not found (404). Checking for pre-releases...');
|
||||
|
||||
const releases = await fetchJson<GitHubRelease[]>('/repos/clice-io/clice/releases?per_page=1');
|
||||
|
||||
if (Array.isArray(releases) && releases.length > 0) {
|
||||
const latestPre = releases[0];
|
||||
channel.appendLine(`[Download] Found pre-release: ${latestPre.tag_name}`);
|
||||
return latestPre;
|
||||
} else {
|
||||
throw new Error('No releases found in repository.');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson<T>(apiPath: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'api.github.com',
|
||||
path: apiPath,
|
||||
headers: { 'User-Agent': 'VSCode-Extension' }
|
||||
};
|
||||
|
||||
https.get(options, (res) => {
|
||||
let data = '';
|
||||
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
res.resume();
|
||||
reject(new Error(`GitHub API returned ${res.statusCode} for ${apiPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse GitHub API response: ${e}`));
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
93
editors/vscode/src/extension.ts
Normal file
93
editors/vscode/src/extension.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as net from 'net';
|
||||
import * as vscode from 'vscode';
|
||||
import { workspace, window, ExtensionContext } from 'vscode';
|
||||
import { LanguageClient, LanguageClientOptions, ServerOptions, StreamInfo } from 'vscode-languageclient/node';
|
||||
import { getSetting } from './setting';
|
||||
import { ensureServerBinary } from './download'
|
||||
|
||||
let client: LanguageClient;
|
||||
|
||||
export async function registerCommands(client: LanguageClient, context: ExtensionContext) {
|
||||
context.subscriptions.push(vscode.commands.registerCommand("clice.restart", async () => {
|
||||
await client.restart();
|
||||
}));
|
||||
}
|
||||
|
||||
export async function activate(context: ExtensionContext) {
|
||||
console.log('Congratulations, your extension "clice" is now active!');
|
||||
|
||||
const channel = window.createOutputChannel('clice');
|
||||
const verboseChannel = window.createOutputChannel('clice-verbose');
|
||||
|
||||
const setting = getSetting();
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
let executable = setting.executable
|
||||
let serverOptions: ServerOptions | (() => Promise<StreamInfo>);
|
||||
|
||||
if (setting.mode === "pipe") {
|
||||
if (!executable || executable === "") {
|
||||
const downloadedPath = await ensureServerBinary(context, channel);
|
||||
if (downloadedPath) {
|
||||
executable = downloadedPath;
|
||||
} else {
|
||||
window.showErrorMessage("Could not find or download clice executable.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let args = ["--mode=pipe"];
|
||||
serverOptions = {
|
||||
run: { command: executable, args: args },
|
||||
debug: { command: executable, args: args }
|
||||
};
|
||||
} else if (setting.mode === "socket") {
|
||||
serverOptions = (): Promise<StreamInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new net.Socket();
|
||||
client.connect(setting.port, setting.host, () => {
|
||||
resolve({
|
||||
reader: client,
|
||||
writer: client,
|
||||
});
|
||||
});
|
||||
client.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
} else {
|
||||
vscode.window.showErrorMessage("Invalid mode, please set the mode to 'pipe' or 'socket'.");
|
||||
return
|
||||
}
|
||||
|
||||
const clientOptions: LanguageClientOptions = {
|
||||
documentSelector: [{ scheme: 'file', language: 'cpp' }],
|
||||
outputChannel: channel,
|
||||
traceOutputChannel: verboseChannel,
|
||||
synchronize: {
|
||||
fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
|
||||
}
|
||||
};
|
||||
|
||||
client = new LanguageClient(
|
||||
'clice',
|
||||
'clice',
|
||||
serverOptions,
|
||||
clientOptions
|
||||
);
|
||||
|
||||
await registerCommands(client, context);
|
||||
|
||||
await client.start();
|
||||
}
|
||||
|
||||
export function deactivate(): Thenable<void> | undefined {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
let ret = client.stop();
|
||||
return ret;
|
||||
}
|
||||
70
editors/vscode/src/feature/header.ts
Normal file
70
editors/vscode/src/feature/header.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { DocumentUri } from 'vscode-languageclient/node';
|
||||
|
||||
let provider: HeaderContextProvider | undefined = undefined;
|
||||
|
||||
export type HeaderContext = {
|
||||
file: string,
|
||||
index: number,
|
||||
include: number
|
||||
};
|
||||
|
||||
export type HeaderContextSwitchParams = {
|
||||
header: DocumentUri,
|
||||
context: HeaderContext,
|
||||
};
|
||||
|
||||
export type IncludeLocation = {
|
||||
line: number,
|
||||
filename: string
|
||||
};
|
||||
|
||||
export class TreeItem extends vscode.TreeItem {
|
||||
children: Array<TreeItem> = []
|
||||
context: HeaderContext | undefined = undefined;
|
||||
};
|
||||
|
||||
export class HeaderContextProvider implements vscode.TreeDataProvider<TreeItem> {
|
||||
private _onDidChangeTreeData: vscode.EventEmitter<TreeItem | undefined | void> = new vscode.EventEmitter<TreeItem | undefined | void>();
|
||||
readonly onDidChangeTreeData: vscode.Event<TreeItem | undefined | void> = this._onDidChangeTreeData.event;
|
||||
|
||||
header: string = ""
|
||||
items: Array<TreeItem> = []
|
||||
|
||||
update(contexts: Array<Array<HeaderContext>>) {
|
||||
/// Create groups
|
||||
this.items = contexts.map((contexts) => {
|
||||
let item = new TreeItem("", vscode.TreeItemCollapsibleState.Expanded);
|
||||
item.children = contexts.map((context) => {
|
||||
const uri = vscode.Uri.file(context.file);
|
||||
let item = new TreeItem(uri, vscode.TreeItemCollapsibleState.None);
|
||||
item.context = context;
|
||||
item.iconPath = vscode.ThemeIcon.File;
|
||||
item.contextValue = "header-context";
|
||||
return item;
|
||||
});
|
||||
return item;
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this._onDidChangeTreeData.fire();
|
||||
}
|
||||
|
||||
getTreeItem(element: TreeItem) {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(element?: TreeItem) {
|
||||
return element ? element.children : this.items;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function registerHeaderContextView() {
|
||||
provider = new HeaderContextProvider();
|
||||
let treeView = vscode.window.createTreeView("header-contexts", { treeDataProvider: provider });
|
||||
}
|
||||
66
editors/vscode/src/feature/highlight.ts
Normal file
66
editors/vscode/src/feature/highlight.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const rainbowColors = [
|
||||
"#56B6C2",
|
||||
"#61AFEF",
|
||||
"#C678DD",
|
||||
"#E06C75",
|
||||
"#98C379",
|
||||
"#D19A66",
|
||||
"#E5C07B"
|
||||
];
|
||||
|
||||
const textEditorDecorationTypes = rainbowColors.map((color) => {
|
||||
return vscode.window.createTextEditorDecorationType({
|
||||
color: color
|
||||
});
|
||||
});
|
||||
|
||||
export function highlightDocument(document: vscode.TextDocument, legend: vscode.SemanticTokensLegend, semanticTokens: vscode.SemanticTokens) {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document !== document) { return; }
|
||||
const angleIndex = legend?.tokenTypes.indexOf('angle');
|
||||
const leftIndex = legend?.tokenModifiers.indexOf('left');
|
||||
const rightIndex = legend?.tokenModifiers.indexOf('right');
|
||||
if (leftIndex === undefined || rightIndex === undefined || angleIndex === undefined) { return; }
|
||||
|
||||
const decorations = new Map<number, vscode.Range[]>();
|
||||
let level = 0;
|
||||
|
||||
let lastLine = 0;
|
||||
let lastStart = 0;
|
||||
|
||||
// [line, startCharacter, length, tokenType, tokenModifiers]
|
||||
for (let i = 0; i < semanticTokens.data.length; i += 5) {
|
||||
const [lineDelta, startDelta, length, tokenType, tokenModifiers] = semanticTokens.data.slice(i, i + 5);
|
||||
|
||||
lastLine += lineDelta;
|
||||
lastStart = lineDelta === 0 ? lastStart + startDelta : startDelta;
|
||||
|
||||
const range = new vscode.Range(lastLine, lastStart, lastLine, lastStart + length);
|
||||
|
||||
if (tokenType === angleIndex) {
|
||||
if (tokenModifiers & (1 << rightIndex)) {
|
||||
level -= 1;
|
||||
}
|
||||
|
||||
if (decorations.has(level % rainbowColors.length)) {
|
||||
decorations.get(level % rainbowColors.length)?.push(range);
|
||||
} else {
|
||||
decorations.set(level % rainbowColors.length, [range]);
|
||||
}
|
||||
|
||||
if (tokenModifiers & (1 << leftIndex)) {
|
||||
level += 1;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [level, ranges] of decorations) {
|
||||
editor.setDecorations(
|
||||
textEditorDecorationTypes[level],
|
||||
ranges
|
||||
);
|
||||
}
|
||||
}
|
||||
31
editors/vscode/src/setting.ts
Normal file
31
editors/vscode/src/setting.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
interface Setting {
|
||||
executable: string | undefined,
|
||||
mode: string,
|
||||
host: string,
|
||||
port: number,
|
||||
}
|
||||
|
||||
export function getSetting(): Setting | undefined {
|
||||
const setting = vscode.workspace.getConfiguration('clice')
|
||||
const executable = setting.get<string>('executable');
|
||||
const mode = setting.get<string>('mode');
|
||||
|
||||
if (mode !== "pipe" && mode !== "socket") {
|
||||
vscode.window.showErrorMessage(`Unexpected mode: ${mode}`);
|
||||
return undefined
|
||||
}
|
||||
|
||||
const host = setting.get<string>('host')!;
|
||||
const port = setting.get<number>('port')!;
|
||||
|
||||
if (mode === "socket" && (!host || !port)) {
|
||||
vscode.window.showErrorMessage('Socket mode requires both host and port to be configured.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
executable, mode, host, port,
|
||||
}
|
||||
}
|
||||
15
editors/vscode/src/test/extension.test.ts
Normal file
15
editors/vscode/src/test/extension.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as assert from 'assert';
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from 'vscode';
|
||||
// import * as myExtension from '../../extension';
|
||||
|
||||
suite('Extension Test Suite', () => {
|
||||
vscode.window.showInformationMessage('Start all tests.');
|
||||
|
||||
test('Sample test', () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user