extension: migrate vscode to editors/ (#316)

This commit is contained in:
ykiko
2025-11-28 00:37:22 +08:00
committed by GitHub
parent 8aff090a08
commit caf9a172d6
22 changed files with 6348 additions and 21 deletions

View 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);
});
}

View 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;
}

View 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 });
}

View 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
);
}
}

View 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,
}
}

View 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));
});
});