Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,518 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
/**
* Provides automatic file renaming based on RSX naming conventions
*
* This provider watches for file saves in ./rsx directory and automatically
* renames files to match their class names, @rsx_id, or <Define:> tags
* according to RSX framework conventions.
*
* Only runs if auto_rename_files is set to true in config/rsx.php or rsx/resource/config/rsx.php
* (user config takes precedence over framework config).
* Files containing @FILENAME-CONVENTION-EXCEPTION are skipped.
*/
export class AutoRenameProvider {
private config_enabled: boolean = false;
private workspace_root: string = '';
private is_checking = false;
constructor() {
this.init();
}
private find_rspade_root(): string | undefined {
const workspace_folders = vscode.workspace.workspaceFolders;
if (!workspace_folders) {
return undefined;
}
// Check each workspace folder for app/RSpade/
for (const folder of workspace_folders) {
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
return folder.uri.fsPath;
}
}
return undefined;
}
private async init() {
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
return;
}
this.workspace_root = rspade_root;
await this.load_config();
}
private async load_config() {
const framework_config_path = path.join(this.workspace_root, 'config', 'rsx.php');
const user_config_path = path.join(this.workspace_root, 'rsx', 'resource', 'config', 'rsx.php');
let framework_enabled = false;
let user_enabled: boolean | null = null;
// Load framework config
if (fs.existsSync(framework_config_path)) {
try {
const content = fs.readFileSync(framework_config_path, 'utf8');
const value = this.extract_auto_rename_value(content);
if (value !== null) {
framework_enabled = value;
console.log('[AutoRename] Framework config - auto_rename_files:', framework_enabled);
}
} catch (error) {
console.error('[AutoRename] Failed to load framework config:', error);
}
} else {
console.log('[AutoRename] Framework config file not found:', framework_config_path);
}
// Load user config (takes precedence)
if (fs.existsSync(user_config_path)) {
try {
const content = fs.readFileSync(user_config_path, 'utf8');
const value = this.extract_auto_rename_value(content);
if (value !== null) {
user_enabled = value;
console.log('[AutoRename] User config - auto_rename_files:', user_enabled);
}
} catch (error) {
console.error('[AutoRename] Failed to load user config:', error);
}
} else {
console.log('[AutoRename] User config file not found:', user_config_path);
}
// User config takes precedence over framework config
this.config_enabled = user_enabled !== null ? user_enabled : framework_enabled;
console.log('[AutoRename] Final config - auto_rename_files:', this.config_enabled);
}
private extract_auto_rename_value(content: string): boolean | null {
// Look for development.auto_rename_files setting
// Match pattern: 'development' => [ ... 'auto_rename_files' => true/false ... ]
const development_section_match = content.match(/'development'\s*=>\s*\[([\s\S]*?)\],\s*\/\*/);
if (development_section_match) {
const development_content = development_section_match[1];
const auto_rename_match = development_content.match(/'auto_rename_files'\s*=>\s*(true|false)/);
if (auto_rename_match) {
return auto_rename_match[1] === 'true';
}
}
return null;
}
activate(context: vscode.ExtensionContext) {
console.log('[AutoRename] Provider activated');
// Watch for completed file saves (not before save)
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (this.is_checking) {
console.log('[AutoRename] Already checking, skipping');
return; // Prevent recursive calls
}
// Only process if this document is currently active in the editor
const active_editor = vscode.window.activeTextEditor;
if (!active_editor || active_editor.document !== document) {
console.log('[AutoRename] Document not active, skipping (likely bulk save/replace)');
return;
}
await this.load_config(); // Reload config on each save
if (!this.config_enabled) {
console.log('[AutoRename] Feature disabled in config');
return;
}
const file_path = document.uri.fsPath;
console.log('[AutoRename] File saved:', file_path);
// Only process files in ./rsx directory
const relative_path = path.relative(this.workspace_root, file_path);
console.log('[AutoRename] Relative path:', relative_path);
if (!relative_path.startsWith('rsx/') && !relative_path.startsWith('rsx\\')) {
console.log('[AutoRename] Not in rsx/ directory, skipping');
return;
}
// Check for exception marker
const content = document.getText();
if (content.includes('@FILENAME-CONVENTION-EXCEPTION')) {
console.log('[AutoRename] File contains @FILENAME-CONVENTION-EXCEPTION, skipping');
return;
}
this.is_checking = true;
try {
console.log('[AutoRename] Starting rename check...');
await this.check_and_rename(document);
} finally {
this.is_checking = false;
}
})
);
}
public async check_and_rename(document: vscode.TextDocument) {
const file_path = document.uri.fsPath;
const extension = this.get_extension(file_path);
const content = document.getText();
console.log('[AutoRename] Extension detected:', extension);
let identifier: string | null = null;
let suggested_filename: string | null = null;
// Determine identifier based on file type
if (extension === 'php') {
identifier = this.extract_php_class(content);
console.log('[AutoRename] PHP class extracted:', identifier);
if (identifier) {
suggested_filename = await this.get_suggested_php_filename(file_path, identifier);
}
} else if (extension === 'js') {
identifier = this.extract_js_class(content);
console.log('[AutoRename] JS class extracted:', identifier);
if (identifier) {
suggested_filename = await this.get_suggested_js_filename(file_path, identifier, content);
}
} else if (extension === 'blade.php') {
identifier = this.extract_rsx_id(content);
console.log('[AutoRename] Blade @rsx_id extracted:', identifier);
if (identifier) {
suggested_filename = await this.get_suggested_blade_filename(file_path, identifier);
}
} else if (extension === 'jqhtml') {
identifier = this.extract_jqhtml_component(content);
console.log('[AutoRename] Jqhtml component extracted:', identifier);
if (identifier) {
suggested_filename = await this.get_suggested_jqhtml_filename(file_path, identifier);
}
}
console.log('[AutoRename] Identifier:', identifier);
console.log('[AutoRename] Suggested filename:', suggested_filename);
if (!identifier || !suggested_filename) {
console.log('[AutoRename] No identifier or suggested filename, skipping');
return;
}
const current_filename = path.basename(file_path);
console.log('[AutoRename] Current filename:', current_filename);
if (current_filename.toLowerCase() === suggested_filename.toLowerCase()) {
console.log('[AutoRename] Filename already correct (case-insensitive match)');
return; // Already correct
}
// Check if suggested filename already exists
const dir = path.dirname(file_path);
const new_path = path.join(dir, suggested_filename);
if (fs.existsSync(new_path)) {
console.log('[AutoRename] Cannot rename: ${suggested_filename} already exists at', new_path);
return;
}
// Perform rename
console.log('[AutoRename] Performing rename to:', suggested_filename);
await this.rename_file(file_path, new_path);
}
private async rename_file(old_path: string, new_path: string) {
const old_uri = vscode.Uri.file(old_path);
const new_uri = vscode.Uri.file(new_path);
console.log('[AutoRename] Renaming from:', old_path);
console.log('[AutoRename] Renaming to:', new_path);
try {
// Capture cursor position and view column before closing
let cursor_position: vscode.Position | undefined;
let view_column: vscode.ViewColumn | undefined;
const old_doc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === old_path);
if (old_doc) {
// Find the editor for this document to capture cursor position
const old_editor = vscode.window.visibleTextEditors.find(
editor => editor.document.uri.fsPath === old_path
);
if (old_editor) {
cursor_position = old_editor.selection.active;
view_column = old_editor.viewColumn;
console.log('[AutoRename] Captured cursor position:', cursor_position.line, cursor_position.character);
console.log('[AutoRename] Captured view column:', view_column);
}
console.log('[AutoRename] Closing old document');
await vscode.window.showTextDocument(old_doc, { preview: false, preserveFocus: false });
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
}
// Use VS Code's rename API for proper integration
const edit = new vscode.WorkspaceEdit();
edit.renameFile(old_uri, new_uri, { overwrite: false });
const success = await vscode.workspace.applyEdit(edit);
console.log('[AutoRename] Rename result:', success);
if (success) {
console.log(`[AutoRename] ✅ Auto-renamed: ${path.basename(old_path)}${path.basename(new_path)}`);
// Wait a bit for the file system to settle
await new Promise(resolve => setTimeout(resolve, 100));
// Open the renamed file
const new_document = await vscode.workspace.openTextDocument(new_uri);
console.log('[AutoRename] Opened renamed document');
// Show the document in the editor, restoring view column if we had one
const show_options: vscode.TextDocumentShowOptions = {
preview: false,
viewColumn: view_column
};
const editor = await vscode.window.showTextDocument(new_document, show_options);
console.log('[AutoRename] Showing renamed document in editor');
// Restore cursor position if we captured one
if (cursor_position) {
editor.selection = new vscode.Selection(cursor_position, cursor_position);
editor.revealRange(new vscode.Range(cursor_position, cursor_position));
console.log('[AutoRename] Restored cursor position');
}
// Format the document
console.log('[AutoRename] Formatting document...');
await vscode.commands.executeCommand('editor.action.formatDocument');
console.log('[AutoRename] Format command executed');
// Save the formatted document
await new_document.save();
console.log('[AutoRename] Saved formatted document');
}
} catch (error) {
console.error('[AutoRename] ❌ Failed to rename file:', error);
}
}
private get_extension(file_path: string): string {
if (file_path.endsWith('.blade.php')) {
return 'blade.php';
}
if (file_path.endsWith('.jqhtml')) {
return 'jqhtml';
}
return path.extname(file_path).substring(1);
}
private extract_php_class(content: string): string | null {
// Match: class ClassName
const match = content.match(/^\s*class\s+([A-Za-z0-9_]+)/m);
return match ? match[1] : null;
}
private extract_js_class(content: string): string | null {
// Match: class ClassName
const match = content.match(/^\s*class\s+([A-Za-z0-9_]+)/m);
return match ? match[1] : null;
}
private extract_rsx_id(content: string): string | null {
// Match: @rsx_id('identifier')
const match = content.match(/@rsx_id\s*\(\s*['"]([^'"]+)['"]\s*\)/);
return match ? match[1] : null;
}
private extract_jqhtml_component(content: string): string | null {
// Match: <Define:ComponentName>
const match = content.match(/<Define:([A-Za-z0-9_]+)>/);
return match ? match[1] : null;
}
private async get_suggested_php_filename(file_path: string, class_name: string): Promise<string> {
// rsx/ files use lowercase convention
const dir = path.dirname(file_path);
const relative_dir = path.relative(this.workspace_root, dir);
const short_name = this.extract_short_name(class_name, relative_dir);
if (short_name) {
return short_name.toLowerCase() + '.php';
}
return class_name.toLowerCase() + '.php';
}
private async get_suggested_js_filename(file_path: string, class_name: string, content: string): Promise<string> {
// Check if this extends Jqhtml_Component
const is_jqhtml = content.includes('extends Jqhtml_Component') ||
content.match(/extends\s+[A-Za-z0-9_]+\s+extends Jqhtml_Component/);
console.log('[AutoRename] JS - Is Jqhtml_Component:', is_jqhtml);
const dir = path.dirname(file_path);
const relative_dir = path.relative(this.workspace_root, dir);
console.log('[AutoRename] JS - Relative directory:', relative_dir);
if (is_jqhtml) {
// For Jqhtml components, use snake_case convention
const snake_case = this.pascal_to_snake_case(class_name);
console.log('[AutoRename] JS - PascalCase to snake_case:', class_name, '→', snake_case);
const short_name = this.extract_short_name(class_name, relative_dir);
console.log('[AutoRename] JS - Short name extracted:', short_name);
if (short_name) {
const short_snake = this.pascal_to_snake_case(short_name);
console.log('[AutoRename] JS - Short name to snake_case:', short_name, '→', short_snake);
return short_snake.toLowerCase() + '.js';
}
return snake_case.toLowerCase() + '.js';
} else {
// Regular JS classes use lowercase
const short_name = this.extract_short_name(class_name, relative_dir);
console.log('[AutoRename] JS - Short name extracted:', short_name);
if (short_name) {
return short_name.toLowerCase() + '.js';
}
return class_name.toLowerCase() + '.js';
}
}
private async get_suggested_blade_filename(file_path: string, rsx_id: string): Promise<string> {
const dir = path.dirname(file_path);
const relative_dir = path.relative(this.workspace_root, dir);
const short_name = this.extract_short_name(rsx_id, relative_dir);
if (short_name) {
return short_name.toLowerCase() + '.blade.php';
}
return rsx_id.toLowerCase() + '.blade.php';
}
private async get_suggested_jqhtml_filename(file_path: string, component_name: string): Promise<string> {
// Jqhtml components use snake_case convention
const snake_case = this.pascal_to_snake_case(component_name);
console.log('[AutoRename] JQHTML - PascalCase to snake_case:', component_name, '→', snake_case);
const dir = path.dirname(file_path);
const relative_dir = path.relative(this.workspace_root, dir);
console.log('[AutoRename] JQHTML - Relative directory:', relative_dir);
const short_name = this.extract_short_name(component_name, relative_dir);
console.log('[AutoRename] JQHTML - Short name extracted:', short_name);
if (short_name) {
const short_snake = this.pascal_to_snake_case(short_name);
console.log('[AutoRename] JQHTML - Short name to snake_case:', short_name, '→', short_snake);
return short_snake.toLowerCase() + '.jqhtml';
}
return snake_case.toLowerCase() + '.jqhtml';
}
/**
* Convert PascalCase to snake_case
* Inserts underscores before uppercase letters and before first digit in number sequences
* Example: TestComponent1 -> Test_Component_1
*/
private pascal_to_snake_case(name: string): string {
// Insert underscore before uppercase letters (except first character)
let result = name.replace(/(?<!^)([A-Z])/g, '_$1');
// Insert underscore before first digit in a run of digits
result = result.replace(/(?<!^)(?<![0-9])([0-9])/g, '_$1');
// Replace multiple consecutive underscores with single underscore
result = result.replace(/_+/g, '_');
return result;
}
/**
* Extract short name from class/id if directory structure matches prefix
* Returns null if directory structure doesn't match or if short name rules aren't met
*
* Rules:
* - Short names only allowed in ./rsx directory (NOT in /app/RSpade)
* - Original name must have 3+ segments for short name to be allowed
* - Short name must have 2+ segments
*/
private extract_short_name(full_name: string, dir_path: string): string | null {
// Short names only allowed in ./rsx directory, not in framework code (/app/RSpade)
if (dir_path.includes('/app/RSpade') || dir_path.includes('\\app\\RSpade')) {
return null;
}
// Split the full name by underscores
const name_parts = full_name.split('_');
const original_segment_count = name_parts.length;
// If original name has exactly 2 segments, short name is NOT allowed
if (original_segment_count === 2) {
return null;
}
// If only 1 segment, no prefix to match
if (original_segment_count === 1) {
return null;
}
// Split directory path into parts (handle both / and \ separators)
const dir_parts = dir_path.split(/[/\\]/).filter(p => p.length > 0);
// Find the maximum number of consecutive matches between end of dir_parts and start of name_parts
let matched_parts = 0;
const max_possible = Math.min(dir_parts.length, name_parts.length - 1);
// Try to match last N dir parts with first N name parts
for (let num_to_check = max_possible; num_to_check > 0; num_to_check--) {
let all_match = true;
for (let i = 0; i < num_to_check; i++) {
const dir_idx = dir_parts.length - num_to_check + i;
if (dir_parts[dir_idx].toLowerCase() !== name_parts[i].toLowerCase()) {
all_match = false;
break;
}
}
if (all_match) {
matched_parts = num_to_check;
break;
}
}
if (matched_parts === 0) {
return null; // No match
}
// Calculate the short name
const short_parts = name_parts.slice(matched_parts);
const short_segment_count = short_parts.length;
// Validate short name segment count
// Short name must have 2+ segments
if (short_segment_count < 2) {
return null; // Short name would be too short
}
return short_parts.join('_');
}
dispose() {
// Cleanup if needed
}
}

View File

@@ -0,0 +1,58 @@
import * as vscode from 'vscode';
export const init_blade_language_config = () => {
// HTML empty elements that don't require closing tags
const EMPTY_ELEMENTS: string[] = [
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'menuitem',
'meta',
'param',
'source',
'track',
'wbr',
];
// Configure Blade language indentation and auto-closing behavior
vscode.languages.setLanguageConfiguration('blade', {
indentationRules: {
increaseIndentPattern:
/<(?!\?|(?:area|base|br|col|frame|hr|html|img|input|link|meta|param)\b|[^>]*\/>)([-_\.A-Za-z0-9]+)(?=\s|>)\b[^>]*>(?!.*<\/\1>)|<!--(?!.*-->)|\{[^}"']*$/,
decreaseIndentPattern:
/^\s*(<\/(?!html)[-_\.A-Za-z0-9]+\b[^>]*>|-->|\})/,
},
wordPattern:
/(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
onEnterRules: [
{
// When pressing Enter between opening and closing tags, auto-indent
beforeText: new RegExp(
`<(?!(?:${EMPTY_ELEMENTS.join(
'|'
)}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`,
'i'
),
afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>$/i,
action: { indentAction: vscode.IndentAction.IndentOutdent },
},
{
// When pressing Enter after opening tag, auto-indent
beforeText: new RegExp(
`<(?!(?:${EMPTY_ELEMENTS.join(
'|'
)}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
'i'
),
action: { indentAction: vscode.IndentAction.Indent },
},
],
});
};

View File

@@ -0,0 +1,66 @@
import * as vscode from 'vscode';
/**
* Provides semantic tokens for uppercase component tags in Blade files
* Highlights component tag names in light green
* Highlights tag="" attribute in orange on jqhtml components
*/
export class BladeComponentSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
const tokens_builder = new vscode.SemanticTokensBuilder();
if (document.languageId !== 'blade') {
return tokens_builder.build();
}
const text = document.getText();
// Match opening tags that start with uppercase letter to find jqhtml components
// Matches: <ComponentName ...>, captures the entire tag up to >
const component_tag_regex = /<([A-Z][a-zA-Z0-9_]*)([^>]*?)>/g;
let component_match;
while ((component_match = component_tag_regex.exec(text)) !== null) {
const tag_name = component_match[1];
const tag_attributes = component_match[2];
const tag_start = component_match.index + component_match[0].indexOf(tag_name);
const tag_position = document.positionAt(tag_start);
// Push token for the component tag name
// Token type 0 maps to 'class' which VS Code themes style as entity.name.class (turquoise/cyan)
tokens_builder.push(tag_position.line, tag_position.character, tag_name.length, 0, 0);
// Now look for tag="" attribute within this component's attributes
// Matches: tag="..." or tag='...'
const tag_attr_regex = /\btag\s*=/g;
let attr_match;
while ((attr_match = tag_attr_regex.exec(tag_attributes)) !== null) {
// Calculate the position of 'tag' within the document
const attr_start = component_match.index + component_match[0].indexOf(tag_attributes) + attr_match.index;
const attr_position = document.positionAt(attr_start);
// Push token for 'tag' attribute name
// Token type 1 maps to 'jqhtmlTagAttribute' which we'll define to be orange
tokens_builder.push(attr_position.line, attr_position.character, 3, 1, 0);
}
}
// Also match closing tags that start with uppercase letter
// Matches: </ComponentName>
const closing_tag_regex = /<\/([A-Z][a-zA-Z0-9_]*)/g;
let closing_match;
while ((closing_match = closing_tag_regex.exec(text)) !== null) {
const tag_name = closing_match[1];
const tag_start = closing_match.index + closing_match[0].indexOf(tag_name);
const position = document.positionAt(tag_start);
// Push token for the tag name
// Token type 0 maps to 'class' which VS Code themes style as entity.name.class (turquoise/cyan)
tokens_builder.push(position.line, position.character, tag_name.length, 0, 0);
}
return tokens_builder.build();
}
}

View File

@@ -0,0 +1,122 @@
import * as vscode from 'vscode';
import { get_config } from './config';
const TAG_DOUBLE = 0;
const TAG_UNESCAPED = 1;
const TAG_COMMENT = 2;
const snippets: Record<number, string> = {
[TAG_DOUBLE]: '{{ ${1:${TM_SELECTED_TEXT/[{}]//g}} }}$0',
[TAG_UNESCAPED]: '{!! ${1:${TM_SELECTED_TEXT/[{} !]//g}} !!}$0',
[TAG_COMMENT]: '{{-- ${1:${TM_SELECTED_TEXT/(--)|[{} ]//g}} --}}$0',
};
const triggers = ['{}', '!', '-', '{'];
const regexes = [
/({{(?!\s|-))(.*?)(}})/,
/({!!(?!\s))(.*?)?(}?)/,
/({{[\s]?--)(.*?)?(}})/,
];
const translate = (position: vscode.Position, offset: number): vscode.Position => {
try {
return position.translate(0, offset);
} catch (error) {
// VS Code doesn't like negative numbers passed
// to translate (even though it works fine), so
// this block prevents debug console errors
}
return position;
};
const chars_for_change = (
doc: vscode.TextDocument,
change: vscode.TextDocumentContentChangeEvent
): number => {
if (change.text === '!') {
return 2;
}
if (change.text !== '-') {
return 1;
}
const start = translate(change.range.start, -2);
const end = translate(change.range.start, -1);
return doc.getText(new vscode.Range(start, end)) === ' ' ? 4 : 3;
};
export const blade_spacer = async (
e: vscode.TextDocumentChangeEvent,
editor?: vscode.TextEditor
) => {
const config = get_config();
if (
!config.get('enableBladeAutoSpacing', true) ||
!editor ||
editor.document.fileName.indexOf('.blade.php') === -1
) {
return;
}
let tag_type: number = -1;
let ranges: vscode.Range[] = [];
let offsets: number[] = [];
// Changes (per line) come in right-to-left when we need them left-to-right
const changes = e.contentChanges.slice().reverse();
changes.forEach((change) => {
if (triggers.indexOf(change.text) === -1) {
return;
}
if (!offsets[change.range.start.line]) {
offsets[change.range.start.line] = 0;
}
const start_offset =
offsets[change.range.start.line] -
chars_for_change(e.document, change);
const start = translate(change.range.start, start_offset);
const line_end = e.document.lineAt(start.line).range.end;
for (let i = 0; i < regexes.length; i++) {
// If we typed a - or a !, don't consider the "double" tag type
if (i === TAG_DOUBLE && ['-', '!'].indexOf(change.text) !== -1) {
continue;
}
// Only look at unescaped tags if we need to
if (i === TAG_UNESCAPED && change.text !== '!') {
continue;
}
// Only look at comment tags if we need to
if (i === TAG_COMMENT && change.text !== '-') {
continue;
}
const tag = regexes[i].exec(
e.document.getText(new vscode.Range(start, line_end))
);
if (tag) {
tag_type = i;
ranges.push(
new vscode.Range(start, start.translate(0, tag[0].length))
);
offsets[start.line] += tag[1].length;
}
}
});
if (ranges.length > 0 && snippets[tag_type]) {
editor.insertSnippet(new vscode.SnippetString(snippets[tag_type]), ranges);
}
};

View File

@@ -0,0 +1,59 @@
/**
* RSpade Class Refactor Code Actions Provider
*
* Provides refactoring actions that appear in the "Refactor..." menu
* when the cursor is on a class definition line.
*/
import * as vscode from 'vscode';
import { RspadeClassRefactorProvider } from './class_refactor_provider';
export class RspadeClassRefactorCodeActionsProvider implements vscode.CodeActionProvider {
private refactor_provider: RspadeClassRefactorProvider;
constructor(refactor_provider: RspadeClassRefactorProvider) {
this.refactor_provider = refactor_provider;
}
public provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
token: vscode.CancellationToken
): vscode.CodeAction[] | undefined {
// Only provide actions for PHP files in ./rsx or ./app/RSpade
if (document.languageId !== 'php') {
return undefined;
}
const file_path = document.uri.fsPath;
if (!file_path.includes('/rsx/') && !file_path.includes('\\rsx\\') &&
!file_path.includes('/app/RSpade') && !file_path.includes('\\app\\RSpade')) {
return undefined;
}
// Check if line contains a class definition at indent level 0
const position = range.start;
const line = document.lineAt(position.line).text;
// Must be class definition at start of line (indent level 0)
const class_definition_match = line.match(/^(?:abstract\s+|final\s+)?class\s+([A-Z][a-zA-Z0-9_]*)/);
if (class_definition_match) {
return this.create_refactor_actions();
}
return undefined;
}
private create_refactor_actions(): vscode.CodeAction[] {
const action = new vscode.CodeAction(
'Global Rename Class',
vscode.CodeActionKind.Refactor
);
action.command = {
command: 'rspade.refactorClass',
title: 'Global Rename Class'
};
return [action];
}
}

View File

@@ -0,0 +1,272 @@
/**
* RSpade Class Refactor Provider
*
* Provides context menu refactoring options for PHP class definitions.
* Communicates with the server-side refactor commands via the IDE service.
*/
import * as vscode from 'vscode';
import { RspadeFormattingProvider } from './formatting_provider';
import { AutoRenameProvider } from './auto_rename_provider';
export class RspadeClassRefactorProvider {
private formatting_provider: RspadeFormattingProvider;
private auto_rename_provider: AutoRenameProvider;
private output_channel: vscode.OutputChannel;
constructor(formatting_provider: RspadeFormattingProvider, auto_rename_provider: AutoRenameProvider) {
this.formatting_provider = formatting_provider;
this.auto_rename_provider = auto_rename_provider;
this.output_channel = vscode.window.createOutputChannel('RSpade Refactor');
}
/**
* Register the refactor command
*/
public register(context: vscode.ExtensionContext): void {
const command = vscode.commands.registerCommand(
'rspade.refactorClass',
async () => await this.refactor_class()
);
context.subscriptions.push(command);
}
/**
* Main refactor method
*/
private async refactor_class(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor');
return;
}
const document = editor.document;
const position = editor.selection.active;
this.output_channel.clear();
this.output_channel.show(true);
this.output_channel.appendLine('=== RSpade Class Refactor ===\n');
try {
// Extract class name from cursor position
const class_info = await this.extract_class_info(document, position);
if (!class_info) {
vscode.window.showErrorMessage('Could not identify class at cursor position');
return;
}
this.output_channel.appendLine(`Class: ${class_info.class_name}\n`);
// Show input dialog for new class name
const new_class_name = await vscode.window.showInputBox({
title: `Global Rename Class: ${class_info.class_name}`,
prompt: 'Enter new class name:',
placeHolder: 'New_Class_Name',
value: class_info.class_name,
ignoreFocusOut: true,
validateInput: (value: string) => {
if (!value) {
return 'Class name cannot be empty';
}
if (!/^[A-Z][a-zA-Z0-9_]*$/.test(value)) {
return 'Class name must be PascalCase (uppercase first letter)';
}
if (value === class_info.class_name) {
return 'New class name must be different from current name';
}
return null;
}
});
if (!new_class_name) {
this.output_channel.appendLine('Refactor cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Confirm refactoring
const confirmation = await vscode.window.showWarningMessage(
`Global Rename: ${class_info.class_name}${new_class_name}\n\n` +
'This will rename the class across all usages in all files.',
{ modal: true },
'Rename',
'Cancel'
);
if (confirmation !== 'Rename') {
this.output_channel.appendLine('Global rename cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Save all dirty files first
this.output_channel.appendLine('Checking for unsaved files...');
const dirty_documents = vscode.workspace.textDocuments.filter(doc => doc.isDirty);
if (dirty_documents.length > 0) {
this.output_channel.appendLine(`Found ${dirty_documents.length} unsaved file(s):`);
for (const doc of dirty_documents) {
this.output_channel.appendLine(` - ${doc.fileName}`);
}
this.output_channel.appendLine('\nSaving all files...');
const save_result = await vscode.workspace.saveAll(false);
if (!save_result) {
const error_msg = 'Failed to save all files. Refactor operation aborted.';
this.output_channel.appendLine(`\nERROR: ${error_msg}`);
vscode.window.showErrorMessage(error_msg);
return;
}
this.output_channel.appendLine('All files saved successfully\n');
} else {
this.output_channel.appendLine('No unsaved files\n');
}
// Show output channel
this.output_channel.show(true);
// Show terminal and execute refactoring
this.output_channel.appendLine(`Refactoring ${class_info.class_name} to ${new_class_name}...`);
this.output_channel.appendLine('');
const result = await this.execute_refactor(
class_info.class_name,
new_class_name
);
// Display result in terminal
this.output_channel.appendLine('\n=== Refactor Output ===\n');
this.output_channel.appendLine(result);
this.output_channel.appendLine('\n=== Refactor Complete ===');
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files and auto-rename
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
setTimeout(async () => {
await this.reload_all_open_files();
// Wait another 500ms then check if current file needs renaming
setTimeout(async () => {
const editor = vscode.window.activeTextEditor;
if (editor) {
const file_path = editor.document.uri.fsPath;
// Only auto-rename if file is in ./rsx
if (file_path.includes('/rsx/') || file_path.includes('\\rsx\\')) {
await this.auto_rename_provider.check_and_rename(editor.document);
}
}
}, 500);
}, 500);
}, 3500);
vscode.window.showInformationMessage(
`Successfully refactored ${class_info.class_name} to ${new_class_name}`
);
}
} catch (error: any) {
const error_message = error.message || String(error);
this.output_channel.appendLine(`\nERROR: ${error_message}`);
vscode.window.showErrorMessage(`Refactor failed: ${error_message}`);
}
}
/**
* Extract class name from cursor position
*/
private async extract_class_info(
document: vscode.TextDocument,
position: vscode.Position
): Promise<{ class_name: string } | null> {
const line = document.lineAt(position.line).text;
// Check for class definition at indent level 0: class ClassName or class ClassName extends Parent
// Must be at start of line (indent level 0)
const class_match = line.match(/^(?:abstract\s+|final\s+)?class\s+([A-Z][a-zA-Z0-9_]*)/);
if (class_match) {
const class_name = class_match[1];
return { class_name };
}
return null;
}
/**
* Reload all open text documents
*/
private async reload_all_open_files(): Promise<void> {
const text_documents = vscode.workspace.textDocuments;
for (const document of text_documents) {
// Skip untitled documents
if (document.uri.scheme === 'untitled') {
continue;
}
// Skip non-file schemes (git, output channels, etc)
if (document.uri.scheme !== 'file') {
continue;
}
// Get the text editor for this document
const editors = vscode.window.visibleTextEditors.filter(
editor => editor.document.uri.toString() === document.uri.toString()
);
if (editors.length > 0) {
// Document is currently visible, reload it
const position = editors[0].selection.active;
const view_column = editors[0].viewColumn;
// Close and reopen to force reload
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
const new_document = await vscode.workspace.openTextDocument(document.uri);
const editor = await vscode.window.showTextDocument(new_document, view_column);
// Restore cursor position
editor.selection = new vscode.Selection(position, position);
editor.revealRange(new vscode.Range(position, position));
}
}
}
/**
* Execute the refactor command via IDE service
*/
private async execute_refactor(
old_class: string,
new_class: string
): Promise<string> {
// Ensure authentication
await this.formatting_provider.ensure_auth();
// Prepare request data
const request_data = {
command: 'rsx:refactor:rename_php_class',
arguments: [old_class, new_class, '--skip-rename-file']
};
this.output_channel.appendLine('Sending refactor request to server...');
this.output_channel.appendLine(`Command: ${request_data.command}`);
this.output_channel.appendLine(`Arguments: ${JSON.stringify(request_data.arguments)}\n`);
// Make authenticated request
const response = await (this.formatting_provider as any).make_authenticated_request(
'/command',
request_data
);
if (!response.success) {
throw new Error(response.error || 'Refactor command failed');
}
return response.output || 'Refactor completed successfully (no output)';
}
}

View File

@@ -0,0 +1,15 @@
import * as vscode from 'vscode';
export function get_config() {
return vscode.workspace.getConfiguration('rspade');
}
export function get_python_command(): string {
const custom_path = get_config().get<string>('pythonPath');
if (custom_path && custom_path.trim() !== '') {
return custom_path;
}
// Default based on platform
return process.platform === 'win32' ? 'python' : 'python3';
}

View File

@@ -0,0 +1,257 @@
import * as vscode from 'vscode';
/**
* Convention methods that are called automatically by the RSX framework
* These methods are invoked by name at runtime, not through direct references
*/
const CONVENTION_METHODS = [
'_on_framework_core_define',
'_on_framework_modules_define',
'_on_framework_core_init',
'on_app_modules_define',
'on_app_define',
'_on_framework_modules_init',
'on_app_modules_init',
'on_app_init',
'on_app_ready',
'on_jqhtml_ready'
];
/**
* Check if a method is static by examining the line text
*/
function is_static_method(line_text: string): boolean {
return line_text.trim().startsWith('static ');
}
/**
* Check if position is inside a comment
*/
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
} else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
} else {
i++;
}
}
return in_block_comment;
}
/**
* Provides semantic tokens for convention methods (amber color)
*/
export class ConventionMethodSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
const tokens_builder = new vscode.SemanticTokensBuilder();
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return tokens_builder.build();
}
const text = document.getText();
// Find all static method definitions matching convention methods
for (const method_name of CONVENTION_METHODS) {
// Match: static method_name(...)
const regex = new RegExp(`\\bstatic\\s+(${method_name})\\s*\\(`, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
const method_start = match.index + 'static '.length;
const position = document.positionAt(method_start);
// Skip if this is inside a comment
if (is_in_comment(document, position)) {
continue;
}
tokens_builder.push(
position.line,
position.character,
method_name.length,
0, // token type index for 'conventionMethod'
0 // token modifiers
);
}
}
return tokens_builder.build();
}
}
/**
* Provides hover information for convention methods
*/
export class ConventionMethodHoverProvider implements vscode.HoverProvider {
provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined {
const word_range = document.getWordRangeAtPosition(position);
if (!word_range) {
return undefined;
}
const word = document.getText(word_range);
if (!CONVENTION_METHODS.includes(word)) {
return undefined;
}
// Check if this is a method definition
const line = document.lineAt(position.line).text;
if (!line.includes('(')) {
return undefined;
}
const markdown = new vscode.MarkdownString();
markdown.isTrusted = true;
if (is_static_method(line)) {
markdown.appendMarkdown(`**Convention Method**\n\n`);
markdown.appendMarkdown(`This method is automatically called by \`Rsx.js\` during initialization of the client-side RSpade runtime.\n\n`);
markdown.appendMarkdown(`Convention methods are invoked by name and do not appear as direct references in the codebase.`);
} else {
markdown.appendMarkdown(`**⚠️ Non-Static Convention Method**\n\n`);
markdown.appendMarkdown(`This method name is reserved for framework convention methods, but it is not declared as \`static\`.\n\n`);
markdown.appendMarkdown(`Convention methods must be \`static\` to be called by the RSX framework.`);
}
return new vscode.Hover(markdown, word_range);
}
}
/**
* Provides diagnostics for non-static convention methods
*/
export class ConventionMethodDiagnosticProvider {
private diagnostics_collection: vscode.DiagnosticCollection;
constructor() {
this.diagnostics_collection = vscode.languages.createDiagnosticCollection('rspade-convention');
}
activate(context: vscode.ExtensionContext) {
// Update diagnostics on document change
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((event) => {
this.update_diagnostics(event.document);
})
);
// Update diagnostics for all open documents
vscode.workspace.textDocuments.forEach(document => {
this.update_diagnostics(document);
});
// Update diagnostics when opening a document
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument(document => {
this.update_diagnostics(document);
})
);
}
private update_diagnostics(document: vscode.TextDocument) {
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return;
}
const diagnostics: vscode.Diagnostic[] = [];
const text = document.getText();
for (const method_name of CONVENTION_METHODS) {
// Match non-static methods with convention names: method_name(...) without 'static' before it
const regex = new RegExp(`^\\s*(?!static\\s)(${method_name})\\s*\\(`, 'gm');
let match;
while ((match = regex.exec(text)) !== null) {
const method_start = match.index + match[0].indexOf(method_name);
const start_pos = document.positionAt(method_start);
const end_pos = document.positionAt(method_start + method_name.length);
const range = new vscode.Range(start_pos, end_pos);
const diagnostic = new vscode.Diagnostic(
range,
`Convention method '${method_name}' must be declared as 'static' to be called by the RSX framework`,
vscode.DiagnosticSeverity.Error
);
diagnostic.source = 'RSpade';
diagnostics.push(diagnostic);
}
}
this.diagnostics_collection.set(document.uri, diagnostics);
}
dispose() {
this.diagnostics_collection.dispose();
}
}
/**
* Provides go-to-definition for convention methods
*/
export class ConventionMethodDefinitionProvider implements vscode.DefinitionProvider {
async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Location | undefined> {
const word_range = document.getWordRangeAtPosition(position);
if (!word_range) {
return undefined;
}
const word = document.getText(word_range);
if (!CONVENTION_METHODS.includes(word)) {
return undefined;
}
// Check if this is a method definition
const line = document.lineAt(position.line).text;
if (!line.includes('(') || !is_static_method(line)) {
return undefined;
}
// Find Rsx.js in the workspace
const files = await vscode.workspace.findFiles('**/Rsx.js', '**/node_modules/**');
if (files.length === 0) {
return undefined;
}
// Use the first match (should only be one Rsx.js)
const rsx_file = files[0];
const rsx_document = await vscode.workspace.openTextDocument(rsx_file);
const rsx_text = rsx_document.getText();
// Find _rsx_core_boot method
const boot_regex = /static\s+async\s+_rsx_core_boot\s*\(/;
const match = boot_regex.exec(rsx_text);
if (!match) {
return undefined;
}
const boot_position = rsx_document.positionAt(match.index);
return new vscode.Location(rsx_file, boot_position);
}
}

View File

@@ -0,0 +1,233 @@
import * as vscode from 'vscode';
import * as crypto from 'crypto';
import { RspadeFormattingProvider } from './formatting_provider';
// Use the global WebSocket available in VS Code extension host
declare const WebSocket: any;
interface WebSocketMessage {
type: string;
data?: any;
timestamp?: number;
}
export class DebugClient {
private ws: any = null; // WebSocket instance
private outputChannel: vscode.OutputChannel;
private formattingProvider: RspadeFormattingProvider;
private isConnecting: boolean = false;
private reconnectTimer: NodeJS.Timer | null = null;
private pingTimer: NodeJS.Timer | null = null;
private sessionId: string | null = null;
private serverKey: string | null = null;
constructor(formattingProvider: RspadeFormattingProvider) {
this.formattingProvider = formattingProvider;
this.outputChannel = vscode.window.createOutputChannel('RSPade Debug Proxy');
this.outputChannel.show();
this.log('Debug client initialized');
}
public async start(): Promise<void> {
this.log('Starting debug client...');
await this.connect();
}
private async connect(): Promise<void> {
if (this.isConnecting || this.ws?.readyState === WebSocket.OPEN) {
return;
}
this.isConnecting = true;
try {
// Get authentication from formatting provider
await this.ensureAuthenticated();
const serverUrl = await this.formattingProvider.get_server_url();
if (!serverUrl) {
throw new Error('No server URL configured');
}
// Parse URL and construct WebSocket URL
const url = new URL(serverUrl);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/_ide/debug/ws`;
this.log(`Connecting to WebSocket: ${wsUrl}`);
// Create WebSocket (standard API doesn't support headers in constructor)
// We'll send auth after connection
this.ws = new WebSocket(wsUrl);
this.setupEventHandlers();
} catch (error: any) {
this.log(`Connection failed: ${error.message}`);
this.isConnecting = false;
this.scheduleReconnect();
}
}
private setupEventHandlers(): void {
if (!this.ws) return;
this.ws.onopen = () => {
this.isConnecting = false;
this.log('WebSocket connected, sending authentication...');
// Send authentication as first message
const signature = crypto
.createHmac('sha256', this.serverKey!)
.update(this.sessionId!)
.digest('hex');
this.sendMessage({
type: 'auth',
data: {
sessionId: this.sessionId,
signature: signature
}
});
// Send initial hello message after auth
setTimeout(() => {
this.sendMessage({
type: 'hello',
data: { name: 'VS Code Debug Client' }
});
// Start ping timer
this.startPingTimer();
}, 100);
};
this.ws.onmessage = (event: any) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
this.handleMessage(message);
} catch (error) {
this.log(`Failed to parse message: ${error}`);
}
};
this.ws.onclose = () => {
this.log('WebSocket disconnected');
this.ws = null;
this.stopPingTimer();
this.scheduleReconnect();
};
this.ws.onerror = (error: any) => {
this.log(`WebSocket error: ${error}`);
};
}
private handleMessage(message: WebSocketMessage): void {
this.log(`Received: ${message.type}`, message.data);
switch (message.type) {
case 'welcome':
this.log('✅ Authentication successful! Connected to debug proxy');
this.log(`Session ID: ${message.data?.sessionId}`);
break;
case 'pong':
this.log(`PONG received! Server responded to ping`);
break;
case 'hello_response':
this.log(`Server says: ${message.data?.message}`);
break;
case 'error':
this.log(`❌ Error: ${message.data?.message}`);
break;
default:
this.log(`Unknown message type: ${message.type}`);
}
}
private sendMessage(message: WebSocketMessage): void {
if (this.ws?.readyState === 1) { // 1 = OPEN in standard WebSocket API
this.ws.send(JSON.stringify(message));
this.log(`Sent: ${message.type}`, message.data);
}
}
private startPingTimer(): void {
this.stopPingTimer();
// Send ping every 5 seconds
this.pingTimer = setInterval(() => {
this.sendMessage({
type: 'ping',
data: { timestamp: Date.now() }
});
this.log('PING sent to server');
}, 5000);
}
private stopPingTimer(): void {
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
this.log('Scheduling reconnection in 5 seconds...');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 5000);
}
private async ensureAuthenticated(): Promise<void> {
// Get auth data from formatting provider
const authData = await this.formattingProvider.ensure_auth();
if (!authData) {
throw new Error('Failed to authenticate');
}
// Extract session ID and server key
this.sessionId = authData.session_id;
this.serverKey = authData.server_key;
if (!this.sessionId || !this.serverKey) {
throw new Error('Invalid auth data');
}
}
private log(message: string, data?: any): void {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] ${message}`;
if (data) {
this.outputChannel.appendLine(`${logMessage}\n${JSON.stringify(data, null, 2)}`);
} else {
this.outputChannel.appendLine(logMessage);
}
}
public dispose(): void {
this.stopPingTimer();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.outputChannel.dispose();
}
}

View File

@@ -0,0 +1,134 @@
import * as vscode from 'vscode';
export class RspadeDecorationProvider {
// RSX markers are no longer used - keeping class for potential future use
private static readonly RSX_USE_START = '// [RSX:USE:START]'; // Deprecated
private static readonly RSX_USE_END = '// [RSX:USE:END]'; // Deprecated
private decoration_type: vscode.TextEditorDecorationType;
private decorations = new Map<string, vscode.DecorationOptions[]>();
constructor() {
// Create decoration type for read-only sections
this.decoration_type = vscode.window.createTextEditorDecorationType({
backgroundColor: 'rgba(100, 100, 100, 0.1)', // Subtle gray instead of yellow
borderWidth: '0px', // Remove border
isWholeLine: true,
overviewRulerColor: 'rgba(100, 100, 100, 0.3)',
overviewRulerLane: vscode.OverviewRulerLane.Left,
before: {
contentText: 'ⓘ ', // Info symbol instead of warning
color: 'rgba(100, 150, 200, 0.7)', // Soft blue
margin: '0 4px 0 0'
}
});
}
activate(context: vscode.ExtensionContext) {
// RSX markers are no longer used - this functionality is disabled
return;
/* Original implementation preserved for reference
// Update decorations for active editor
if (vscode.window.activeTextEditor) {
this.update_decorations(vscode.window.activeTextEditor);
}
// Update decorations when active editor changes
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
this.update_decorations(editor);
}
})
);
// Update decorations when document changes
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(event => {
const editor = vscode.window.activeTextEditor;
if (editor && event.document === editor.document) {
this.update_decorations(editor);
}
})
);
// Show warning when trying to edit protected region
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(event => {
if (event.contentChanges.length === 0) return;
const editor = vscode.window.activeTextEditor;
if (!editor || event.document !== editor.document) return;
for (const change of event.contentChanges) {
if (this.is_in_protected_region(event.document, change.range)) {
vscode.window.showWarningMessage(
'You are editing an auto-generated RSX:USE section. These changes may be overwritten.',
'Understood'
);
break;
}
}
})
);
*/
}
private update_decorations(editor: vscode.TextEditor) {
if (editor.document.languageId !== 'php') return;
const decorations: vscode.DecorationOptions[] = [];
const document = editor.document;
let in_protected_region = false;
let start_line: number | null = null;
for (let i = 0; i < document.lineCount; i++) {
const line = document.lineAt(i);
const text = line.text;
if (text.includes(RspadeDecorationProvider.RSX_USE_START)) {
in_protected_region = true;
start_line = i;
} else if (text.includes(RspadeDecorationProvider.RSX_USE_END)) {
if (in_protected_region && start_line !== null) {
// Create decoration for the entire region
const start_pos = new vscode.Position(start_line, 0);
const end_pos = new vscode.Position(i, line.text.length);
const decoration: vscode.DecorationOptions = {
range: new vscode.Range(start_pos, end_pos),
hoverMessage: new vscode.MarkdownString(
'ⓘ **Deprecated RSX:USE section**\n\n' +
'These markers are no longer used by the RSpade formatter.'
)
};
decorations.push(decoration);
}
in_protected_region = false;
start_line = null;
}
}
// Apply decorations
editor.setDecorations(this.decoration_type, decorations);
this.decorations.set(editor.document.uri.toString(), decorations);
}
private is_in_protected_region(document: vscode.TextDocument, range: vscode.Range): boolean {
const decorations = this.decorations.get(document.uri.toString()) || [];
for (const decoration of decorations) {
if (decoration.range.contains(range)) {
return true;
}
}
return false;
}
dispose() {
this.decoration_type.dispose();
this.decorations.clear();
}
}

View File

@@ -0,0 +1,651 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { IdeBridgeClient } from './ide_bridge_client';
interface JqhtmlExtensionAPI {
findComponent(name: string): {
uri: vscode.Uri;
position: vscode.Position;
name: string;
line: string;
} | undefined;
getAllComponentNames(): string[];
reindexWorkspace(): Promise<void>;
}
export class RspadeDefinitionProvider implements vscode.DefinitionProvider {
private readonly ide_bridge: IdeBridgeClient;
private status_bar_item: vscode.StatusBarItem | undefined;
private readonly jqhtml_api: JqhtmlExtensionAPI | undefined;
constructor(jqhtml_api: JqhtmlExtensionAPI | undefined) {
// Create output channel and IDE bridge client
const output_channel = vscode.window.createOutputChannel('RSpade Framework');
this.ide_bridge = new IdeBridgeClient(output_channel);
this.jqhtml_api = jqhtml_api;
}
/**
* Find the RSpade project root folder (contains app/RSpade/)
* Works in both single-folder and multi-root workspace modes
*/
private find_rspade_root(): string | undefined {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
// Check each workspace folder for app/RSpade/
for (const folder of vscode.workspace.workspaceFolders) {
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
return folder.uri.fsPath;
}
}
return undefined;
}
private show_error_status(message: string) {
// Create status bar item if it doesn't exist
if (!this.status_bar_item) {
this.status_bar_item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0);
this.status_bar_item.command = 'workbench.action.output.toggleOutput';
this.status_bar_item.tooltip = 'Click to view RSpade output';
}
// Set error message with icon
this.status_bar_item.text = `$(error) RSpade: ${message}`;
this.status_bar_item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.status_bar_item.show();
// Auto-hide after 5 seconds
setTimeout(() => {
this.clear_status_bar();
}, 5000);
}
public clear_status_bar() {
if (this.status_bar_item) {
this.status_bar_item.hide();
}
}
async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
): Promise<vscode.Definition | undefined> {
const languageId = document.languageId;
const fileName = document.fileName;
// Check for Route() pattern first - works in all file types
const routeResult = await this.handleRoutePattern(document, position);
if (routeResult) {
return routeResult;
}
// Handle "this.xxx" references in .jqhtml files (highest priority for jqhtml files)
if (fileName.endsWith('.jqhtml')) {
const thisResult = await this.handleThisReference(document, position);
if (thisResult) {
return thisResult;
}
}
// Handle jqhtml component tags in .blade.php and .jqhtml files
// TEMPORARILY DISABLED FOR .jqhtml FILES: jqhtml extension now provides this feature
// Re-enable by uncommenting: || fileName.endsWith('.jqhtml')
if (fileName.endsWith('.blade.php') /* || fileName.endsWith('.jqhtml') */) {
const componentResult = await this.handleJqhtmlComponent(document, position);
if (componentResult) {
return componentResult;
}
}
// Handle JavaScript/TypeScript files and .js/.jqhtml files
if (['javascript', 'typescript'].includes(languageId) ||
fileName.endsWith('.js') ||
fileName.endsWith('.jqhtml')) {
const result = await this.handleJavaScriptDefinition(document, position);
if (result) {
return result;
}
}
// Handle PHP and Blade files (RSX view references and class references)
if (['php', 'blade', 'html'].includes(languageId) ||
fileName.endsWith('.php') ||
fileName.endsWith('.blade.php')) {
const result = await this.handlePhpBladeDefinition(document, position);
if (result) {
return result;
}
}
// As a fallback, check if the cursor is in a string that is a valid file path
return this.handleFilePathInString(document, position);
}
/**
* Handle Route() pattern for both PHP and JavaScript
* Detects patterns like:
* - Rsx::Route('Controller') (PHP, defaults to 'index')
* - Rsx::Route('Controller', 'method') (PHP)
* - Rsx.Route('Controller') (JavaScript, defaults to 'index')
* - Rsx.Route('Controller', 'method') (JavaScript)
*/
private async handleRoutePattern(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
const line = document.lineAt(position.line).text;
// First try to match two-parameter version
// Matches: Rsx::Route('Controller', 'method') or Rsx.Route("Controller", "method")
const routePatternTwo = /(?:Rsx::Route|Rsx\.Route)\s*\(\s*['"]([A-Z][A-Za-z0-9_]*)['"],\s*['"]([a-z_][a-z0-9_]*)['"].*?\)/;
let match = line.match(routePatternTwo);
if (match) {
const [fullMatch, controller, method] = match;
const matchStart = line.indexOf(fullMatch);
const matchEnd = matchStart + fullMatch.length;
// Check if cursor is within the Route() call
if (position.character >= matchStart && position.character <= matchEnd) {
// Always go to the method when clicking anywhere in Route()
// This takes precedence over individual class name lookups
try {
const result = await this.queryIdeHelper(controller, method, 'class');
return this.createLocationFromResult(result);
} catch (error) {
// If method lookup fails, try just the controller
try {
const result = await this.queryIdeHelper(controller, undefined, 'class');
return this.createLocationFromResult(result);
} catch (error2) {
console.error('Error querying IDE helper for route:', error);
}
}
}
}
// Try single-parameter version (defaults to 'index')
// Matches: Rsx::Route('Controller') or Rsx.Route("Controller")
const routePatternOne = /(?:Rsx::Route|Rsx\.Route)\s*\(\s*['"]([A-Z][A-Za-z0-9_]*)['"].*?\)/;
match = line.match(routePatternOne);
if (match) {
const [fullMatch, controller] = match;
const matchStart = line.indexOf(fullMatch);
const matchEnd = matchStart + fullMatch.length;
// Check if cursor is within the Route() call
if (position.character >= matchStart && position.character <= matchEnd) {
// Check if this is actually a two-parameter call by looking for a comma
if (!fullMatch.includes(',')) {
// Single parameter - default to 'index'
const method = 'index';
try {
const result = await this.queryIdeHelper(controller, method, 'class');
return this.createLocationFromResult(result);
} catch (error) {
// If method lookup fails, try just the controller
try {
const result = await this.queryIdeHelper(controller, undefined, 'class');
return this.createLocationFromResult(result);
} catch (error2) {
console.error('Error querying IDE helper for route:', error);
}
}
}
}
}
return undefined;
}
/**
* Handle "this.xxx" references in .jqhtml files
* Only handles patterns where cursor is on a word after "this."
* Resolves to JavaScript class method if it exists
*/
private async handleThisReference(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
const line = document.lineAt(position.line).text;
const fileName = document.fileName;
// Check if cursor is on a word after "this."
// Get the word at cursor position
const wordRange = document.getWordRangeAtPosition(position);
if (!wordRange) {
return undefined;
}
const word = document.getText(wordRange);
// Check if "this." appears before this word
const beforeWord = line.substring(0, wordRange.start.character);
if (!beforeWord.endsWith('this.')) {
return undefined;
}
// Get the component name from the file
let componentName: string | undefined;
const fullText = document.getText();
const defineMatch = fullText.match(/<Define:([A-Z][A-Za-z0-9_]*)/);
if (defineMatch) {
componentName = defineMatch[1];
} else {
// If no Define tag, try to get component name from filename
// e.g., user_card.jqhtml -> User_Card
const baseName = path.basename(fileName, '.jqhtml');
if (baseName) {
// Convert snake_case to PascalCase with underscores
componentName = baseName.split('_').map(part =>
part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
).join('_');
}
}
if (!componentName) {
return undefined;
}
try {
// Try to find the JavaScript class and method
// First try jqhtml_class type to find the JS file
const result = await this.queryIdeHelper(componentName, word, 'jqhtml_class_method');
if (result && result.found) {
return this.createLocationFromResult(result);
}
} catch (error) {
// Method not found, try just the class
try {
const result = await this.queryIdeHelper(componentName, undefined, 'jqhtml_class');
return this.createLocationFromResult(result);
} catch (error2) {
console.error('Error querying IDE helper for this reference:', error2);
}
}
return undefined;
}
/**
* Handle jqhtml component tags in .blade.php and .jqhtml files
* Detects uppercase HTML-like tags such as:
* - <User_Card ... />
* - <User_Card ...>content</User_Card>
* - <Foo>content</Foo>
*/
private async handleJqhtmlComponent(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
console.log('[JQHTML Component] Entry point - checking component navigation');
// If JQHTML API not available, skip
if (!this.jqhtml_api) {
console.log('[JQHTML Component] JQHTML API not available - skipping');
return undefined;
}
console.log('[JQHTML Component] JQHTML API available');
// 1. Get the word at cursor position (component name pattern)
const word_range = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/);
if (!word_range) {
console.log('[JQHTML Component] No word range found at cursor position');
return undefined;
}
const component_name = document.getText(word_range);
console.log('[JQHTML Component] Found word at cursor:', component_name);
// 2. Verify it's a component reference (starts with uppercase)
if (!/^[A-Z]/.test(component_name)) {
console.log('[JQHTML Component] Word does not start with uppercase - not a component');
return undefined;
}
console.log('[JQHTML Component] Word starts with uppercase - valid component name pattern');
// 3. Check if cursor is in a tag context
const line = document.lineAt(position.line).text;
const before_word = line.substring(0, word_range.start.character);
console.log('[JQHTML Component] Line text:', line);
console.log('[JQHTML Component] Text before word:', before_word);
// Check for opening tags: <ComponentName or closing tags: </ComponentName
const is_in_tag_context =
before_word.match(/<\s*$/) !== null ||
before_word.match(/<\/\s*$/) !== null;
console.log('[JQHTML Component] Is in tag context:', is_in_tag_context);
if (!is_in_tag_context) {
console.log('[JQHTML Component] Not in tag context - skipping');
return undefined;
}
// 4. Look up component using JQHTML API
console.log('[JQHTML Component] Calling JQHTML API findComponent for:', component_name);
const component_def = this.jqhtml_api.findComponent(component_name);
console.log('[JQHTML Component] JQHTML API result:', component_def);
if (!component_def) {
console.log('[JQHTML Component] Component not found in JQHTML index');
return undefined;
}
// 5. Return the location
console.log('[JQHTML Component] Returning location:', component_def.uri.fsPath, 'at position', component_def.position);
return new vscode.Location(component_def.uri, component_def.position);
}
private async handleJavaScriptDefinition(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
// Get the word at the current position
const wordRange = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/);
if (!wordRange) {
return undefined;
}
const word = document.getText(wordRange);
// Check if this looks like an RSX class name (contains underscore and starts with capital)
if (!word.includes('_') || !/^[A-Z]/.test(word)) {
return undefined;
}
// Check if we're on a method call (look for a dot and method name after the class)
let method_name: string | undefined;
const line = document.lineAt(position.line).text;
const wordEnd = wordRange.end.character;
// Look for pattern like "ClassName.methodName"
const methodMatch = line.substring(wordEnd).match(/^\.([a-z_][a-z0-9_]*)/i);
if (methodMatch) {
method_name = methodMatch[1];
}
// Query the IDE helper endpoint
try {
const result = await this.queryIdeHelper(word, method_name, 'class');
return this.createLocationFromResult(result);
} catch (error) {
console.error('Error querying IDE helper:', error);
}
return undefined;
}
private async handlePhpBladeDefinition(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
const line = document.lineAt(position.line).text;
const charPosition = position.character;
// Pattern 1: @rsx_extends('View_Name') or @rsx_include('View_Name')
// Pattern 2: rsx_view('View_Name')
// Pattern 3: Class references like Demo_Controller
// Check if we're inside a string literal
let inString = false;
let stringStart = -1;
let stringEnd = -1;
let quoteChar = '';
// Find string boundaries around cursor position
for (let i = 0; i < line.length; i++) {
const char = line[i];
if ((char === '"' || char === "'") && (i === 0 || line[i - 1] !== '\\')) {
if (!inString) {
inString = true;
stringStart = i;
quoteChar = char;
} else if (char === quoteChar) {
stringEnd = i;
if (charPosition > stringStart && charPosition <= stringEnd) {
// Cursor is inside this string
break;
}
inString = false;
stringStart = -1;
stringEnd = -1;
}
}
}
// If we're inside a string, extract the identifier
if (stringStart >= 0) {
const stringContent = line.substring(stringStart + 1, stringEnd >= 0 ? stringEnd : line.length);
// Check what context this string is in
const beforeString = line.substring(0, stringStart);
// Check if we're in a bundle 'include' array
// Look for patterns like 'include' => [ or "include" => [
// We need to check previous lines too for context
let inBundleInclude = false;
// Check current line for include array
if (/['"]include['"]\s*=>\s*\[/.test(line)) {
inBundleInclude = true;
} else {
// Check previous lines for context (up to 10 lines back)
for (let i = Math.max(0, position.line - 10); i < position.line; i++) {
const prevLine = document.lineAt(i).text;
if (/['"]include['"]\s*=>\s*\[/.test(prevLine)) {
// Check if we haven't closed the array yet
let openBrackets = 0;
for (let j = i; j <= position.line; j++) {
const checkLine = document.lineAt(j).text;
openBrackets += (checkLine.match(/\[/g) || []).length;
openBrackets -= (checkLine.match(/\]/g) || []).length;
}
if (openBrackets > 0) {
inBundleInclude = true;
break;
}
}
}
}
// If we're in a bundle include array and the string looks like a bundle alias
if (inBundleInclude && /^[a-z0-9]+$/.test(stringContent)) {
try {
const result = await this.queryIdeHelper(stringContent, undefined, 'bundle_alias');
if (result && result.found) {
return this.createLocationFromResult(result);
}
} catch (error) {
console.error('Error querying IDE helper for bundle alias:', error);
}
}
// Check for RSX blade directives or function calls
const rsxPatterns = [
/@rsx_extends\s*\(\s*$/,
/@rsx_include\s*\(\s*$/,
/@rsx_layout\s*\(\s*$/,
/@rsx_component\s*\(\s*$/,
/rsx_view\s*\(\s*$/,
/rsx_include\s*\(\s*$/
];
let isRsxView = false;
for (const pattern of rsxPatterns) {
if (pattern.test(beforeString)) {
isRsxView = true;
break;
}
}
if (isRsxView && stringContent) {
// Query as a view
try {
const result = await this.queryIdeHelper(stringContent, undefined, 'view');
return this.createLocationFromResult(result);
} catch (error) {
console.error('Error querying IDE helper for view:', error);
}
}
}
// If not in a string, check for class references (like in PHP files)
// But skip this if we're inside a Route() call
const routePattern = /(?:Rsx::Route|Rsx\.Route)\s*\([^)]*\)/g;
let isInRoute = false;
let routeMatch;
while ((routeMatch = routePattern.exec(line)) !== null) {
const matchStart = routeMatch.index;
const matchEnd = matchStart + routeMatch[0].length;
if (position.character >= matchStart && position.character <= matchEnd) {
isInRoute = true;
break;
}
}
if (!isInRoute) {
const wordRange = document.getWordRangeAtPosition(position, /[A-Z][A-Za-z0-9_]*/);
if (wordRange) {
const word = document.getText(wordRange);
// Check if this looks like an RSX class name
if (word.includes('_') && /^[A-Z]/.test(word)) {
try {
const result = await this.queryIdeHelper(word, undefined, 'class');
return this.createLocationFromResult(result);
} catch (error) {
console.error('Error querying IDE helper for class:', error);
}
}
}
}
return undefined;
}
private createLocationFromResult(result: any): vscode.Location | undefined {
if (result && result.found) {
// Get the RSpade project root
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
return undefined;
}
// Construct the full file path
const filePath = path.join(rspade_root, result.file);
const fileUri = vscode.Uri.file(filePath);
// Create a position for the definition
const position = new vscode.Position(result.line - 1, 0); // VS Code uses 0-based line numbers
// Clear any error status on successful navigation
this.clear_status_bar();
return new vscode.Location(fileUri, position);
}
return undefined;
}
/**
* Handle file paths in strings - allows "Go to Definition" on file path strings
* This is a fallback handler that only runs if other definitions aren't found
*/
private async handleFilePathInString(
document: vscode.TextDocument,
position: vscode.Position
): Promise<vscode.Definition | undefined> {
const line = document.lineAt(position.line).text;
const charPosition = position.character;
// Check if we're inside a string literal
let inString = false;
let stringStart = -1;
let stringEnd = -1;
let quoteChar = '';
// Find string boundaries around cursor position
for (let i = 0; i < line.length; i++) {
const char = line[i];
if ((char === '"' || char === "'") && (i === 0 || line[i - 1] !== '\\')) {
if (!inString) {
inString = true;
stringStart = i;
quoteChar = char;
} else if (char === quoteChar) {
stringEnd = i;
if (charPosition > stringStart && charPosition <= stringEnd) {
// Cursor is inside this string
break;
}
inString = false;
stringStart = -1;
stringEnd = -1;
}
}
}
// If we're not inside a string, return undefined
if (stringStart < 0) {
return undefined;
}
// Extract the string content
const stringContent = line.substring(stringStart + 1, stringEnd >= 0 ? stringEnd : line.length);
// Check if the string looks like a file path (contains forward slashes or dots)
if (!stringContent.includes('/') && !stringContent.includes('.')) {
return undefined;
}
// Get the RSpade project root
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
return undefined;
}
// Try to resolve the path relative to the workspace
const possiblePath = path.join(rspade_root, stringContent);
// Check if the file exists
try {
const stat = fs.statSync(possiblePath);
if (stat.isFile()) {
// Create a location for the file
const fileUri = vscode.Uri.file(possiblePath);
const position = new vscode.Position(0, 0); // Go to start of file
return new vscode.Location(fileUri, position);
}
} catch (error) {
// File doesn't exist, that's ok - just return undefined
}
return undefined;
}
private async queryIdeHelper(identifier: string, methodName?: string, type?: string): Promise<any> {
const params: any = { identifier };
if (methodName) {
params.method = methodName;
}
if (type) {
params.type = type;
}
try {
const result = await this.ide_bridge.request('/_idehelper', params);
return result;
} catch (error: any) {
this.show_error_status('IDE helper request failed');
throw error;
}
}
}

View File

@@ -0,0 +1,569 @@
import * as vscode from 'vscode';
import { RspadeFoldingProvider } from './folding_provider';
import { RspadeDecorationProvider } from './decoration_provider';
import { RspadeFileWatcher } from './file_watcher';
import { RspadeFormattingProvider } from './formatting_provider';
import { RspadeDefinitionProvider } from './definition_provider';
import { DebugClient } from './debug_client';
import { get_config } from './config';
import { LaravelCompletionProvider } from './laravel_completion_provider';
import { blade_spacer } from './blade_spacer';
import { init_blade_language_config } from './blade_client';
import { ConventionMethodSemanticTokensProvider, ConventionMethodHoverProvider, ConventionMethodDiagnosticProvider, ConventionMethodDefinitionProvider } from './convention_method_provider';
import { JqhtmlLifecycleSemanticTokensProvider, JqhtmlLifecycleHoverProvider, JqhtmlLifecycleDiagnosticProvider } from './jqhtml_lifecycle_provider';
import { PhpAttributeSemanticTokensProvider } from './php_attribute_provider';
import { BladeComponentSemanticTokensProvider } from './blade_component_provider';
import { AutoRenameProvider } from './auto_rename_provider';
import { FolderColorProvider } from './folder_color_provider';
import { GitStatusProvider } from './git_status_provider';
import { GitDiffProvider } from './git_diff_provider';
import { RspadeRefactorProvider } from './refactor_provider';
import { RspadeRefactorCodeActionsProvider } from './refactor_code_actions';
import { RspadeClassRefactorProvider } from './class_refactor_provider';
import { RspadeClassRefactorCodeActionsProvider } from './class_refactor_code_actions';
import { RspadeSortClassMethodsProvider } from './sort_class_methods_provider';
import * as fs from 'fs';
import * as path from 'path';
let folding_provider: RspadeFoldingProvider;
let decoration_provider: RspadeDecorationProvider;
let file_watcher: RspadeFileWatcher;
let formatting_provider: RspadeFormattingProvider;
let definition_provider: RspadeDefinitionProvider;
let debug_client: DebugClient;
let laravel_completion_provider: LaravelCompletionProvider;
let auto_rename_provider: AutoRenameProvider;
/**
* Check for conflicting PHP extensions and prompt user to disable them
*/
async function check_conflicting_extensions() {
const intelephense = vscode.extensions.getExtension('bmewburn.vscode-intelephense-client');
const php_intellisense = vscode.extensions.getExtension('zobo.php-intellisense');
// Only warn if both Intelephense and PHP IntelliSense are installed
if (intelephense && php_intellisense) {
const action = await vscode.window.showWarningMessage(
`Both "Intelephense" and "PHP IntelliSense" are installed. ` +
`It is recommended to disable "PHP IntelliSense" to avoid conflicts.`,
'Disable PHP IntelliSense',
'Ignore'
);
if (action === 'Disable PHP IntelliSense') {
await vscode.commands.executeCommand('workbench.extensions.action.showExtensionsWithIds', [['zobo.php-intellisense']]);
}
}
}
/**
* Find the RSpade project root folder (contains rsx/ and system/app/RSpade/)
* Works in both single-folder and multi-root workspace modes
*/
function find_rspade_root(): string | undefined {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
// Check each workspace folder for rsx/ and system/app/RSpade/ (new structure)
// or app/RSpade/ (legacy structure)
for (const folder of vscode.workspace.workspaceFolders) {
const rsx_dir = path.join(folder.uri.fsPath, 'rsx');
const system_app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade');
// New structure: requires both rsx/ and system/app/RSpade/
if (fs.existsSync(rsx_dir) && fs.existsSync(system_app_rspade)) {
console.log(`[RSpade] Found project root (new structure): ${folder.uri.fsPath}`);
return folder.uri.fsPath;
}
// Legacy structure: just app/RSpade/
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
console.log(`[RSpade] Found project root (legacy structure): ${folder.uri.fsPath}`);
return folder.uri.fsPath;
}
}
return undefined;
}
export async function activate(context: vscode.ExtensionContext) {
console.log('RSpade Framework extension is now active');
// Find RSpade project root
const rspade_root = find_rspade_root();
if (!rspade_root) {
console.log('Not an RSpade project (no rsx/ and system/app/RSpade/ found), extension features disabled');
return;
}
console.log(`[RSpade] Project root: ${rspade_root}`);
// Get config scoped to RSpade root for multi-root workspace support
const config = get_config();
// Get JQHTML extension API for component navigation
// Try both possible extension IDs
let jqhtml_api = undefined;
const possible_jqhtml_ids = [
'jqhtml.jqhtml-vscode-extension',
'jqhtml.@jqhtml/vscode-extension',
'jqhtml.jqhtml-language'
];
console.log('[RSpade] Searching for JQHTML extension...');
console.log('[RSpade] All installed extensions:', vscode.extensions.all.map(e => e.id).filter(id => id.includes('jqhtml')));
let jqhtml_extension = null;
for (const ext_id of possible_jqhtml_ids) {
console.log(`[RSpade] Trying extension ID: ${ext_id}`);
jqhtml_extension = vscode.extensions.getExtension(ext_id);
if (jqhtml_extension) {
console.log(`[RSpade] JQHTML extension found with ID: ${ext_id}`);
console.log(`[RSpade] Extension isActive: ${jqhtml_extension.isActive}`);
break;
} else {
console.log(`[RSpade] Extension ID not found: ${ext_id}`);
}
}
if (!jqhtml_extension) {
console.warn('[RSpade] JQHTML extension not found - component navigation in Blade files will be unavailable');
} else {
try {
console.log('[RSpade] JQHTML extension isActive before activate():', jqhtml_extension.isActive);
console.log('[RSpade] Calling activate() on JQHTML extension...');
// Always call activate() - it returns the API or exports if already active
jqhtml_api = await jqhtml_extension.activate();
console.log('[RSpade] JQHTML extension isActive after activate():', jqhtml_extension.isActive);
console.log('[RSpade] JQHTML extension API loaded successfully');
console.log('[RSpade] API type:', typeof jqhtml_api);
console.log('[RSpade] API value:', jqhtml_api);
console.log('[RSpade] API methods:', Object.keys(jqhtml_api || {}));
console.log('[RSpade] findComponent exists:', typeof (jqhtml_api && jqhtml_api.findComponent));
console.log('[RSpade] getAllComponentNames exists:', typeof (jqhtml_api && jqhtml_api.getAllComponentNames));
console.log('[RSpade] reindexWorkspace exists:', typeof (jqhtml_api && jqhtml_api.reindexWorkspace));
} catch (error) {
console.warn('[RSpade] JQHTML extension found but API could not be loaded:', error);
}
}
// Initialize providers
folding_provider = new RspadeFoldingProvider();
decoration_provider = new RspadeDecorationProvider();
file_watcher = new RspadeFileWatcher();
formatting_provider = new RspadeFormattingProvider();
definition_provider = new RspadeDefinitionProvider(jqhtml_api);
laravel_completion_provider = new LaravelCompletionProvider();
// Register folder color provider
const folder_color_provider = new FolderColorProvider();
context.subscriptions.push(
vscode.window.registerFileDecorationProvider(folder_color_provider)
);
// Register git status provider
const git_status_provider = new GitStatusProvider(rspade_root);
context.subscriptions.push(
vscode.window.registerFileDecorationProvider(git_status_provider)
);
// Register git diff provider
const git_diff_provider = new GitDiffProvider(rspade_root);
git_diff_provider.activate(context);
// Register refactor provider
const refactor_provider = new RspadeRefactorProvider(formatting_provider);
refactor_provider.register(context);
// Register refactor code actions provider
const refactor_code_actions = new RspadeRefactorCodeActionsProvider(refactor_provider);
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
{ language: 'php' },
refactor_code_actions,
{
providedCodeActionKinds: [vscode.CodeActionKind.Refactor]
}
)
);
// Register auto-rename provider early (needed by class refactor provider)
auto_rename_provider = new AutoRenameProvider();
auto_rename_provider.activate(context);
console.log('Auto-rename provider registered for rsx/ files');
// Register class refactor provider
const class_refactor_provider = new RspadeClassRefactorProvider(formatting_provider, auto_rename_provider);
class_refactor_provider.register(context);
// Register class refactor code actions provider
const class_refactor_code_actions = new RspadeClassRefactorCodeActionsProvider(class_refactor_provider);
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
{ language: 'php' },
class_refactor_code_actions,
{
providedCodeActionKinds: [vscode.CodeActionKind.Refactor]
}
)
);
// Register sort class methods provider
const sort_methods_provider = new RspadeSortClassMethodsProvider(formatting_provider);
sort_methods_provider.register(context);
// Register folding provider
if (config.get<boolean>('enableCodeFolding', true)) {
context.subscriptions.push(
vscode.languages.registerFoldingRangeProvider(
{ language: 'php' },
folding_provider
)
);
}
// Activate decoration provider
if (get_config().get<boolean>('enableReadOnlyRegions', true)) {
decoration_provider.activate(context);
}
// Activate file watcher
if (get_config().get<boolean>('enableFormatOnMove', true)) {
file_watcher.activate(context);
}
// Register formatting provider
context.subscriptions.push(
vscode.languages.registerDocumentFormattingEditProvider(
{ language: 'php' },
formatting_provider
)
);
console.log('RSpade formatter registered for PHP files');
// Initialize Blade language configuration (indentation, auto-closing)
init_blade_language_config();
console.log('Blade language configuration initialized');
// Register Blade auto-spacing on text change
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((event) => {
blade_spacer(event, vscode.window.activeTextEditor);
})
);
console.log('Blade auto-spacing enabled');
// Register definition provider for JavaScript/TypeScript and PHP/Blade/jqhtml files
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
[
{ language: 'javascript' },
{ language: 'typescript' },
{ language: 'php' },
{ language: 'blade' },
{ language: 'html' },
{ pattern: '**/*.jqhtml' },
{ pattern: '**/*.blade.php' }
],
definition_provider
)
);
console.log('RSpade definition provider registered for JavaScript/TypeScript/PHP/Blade/jqhtml files');
// Register Laravel completion provider for PHP files
context.subscriptions.push(
vscode.languages.registerCompletionItemProvider(
{ language: 'php' },
laravel_completion_provider
)
);
console.log('Laravel completion provider registered for PHP files');
// Register convention method providers for JavaScript/TypeScript
// Note: Semantic tokens are handled by JqhtmlLifecycleSemanticTokensProvider to avoid duplicate registration
const convention_hover_provider = new ConventionMethodHoverProvider();
const convention_diagnostic_provider = new ConventionMethodDiagnosticProvider();
const convention_definition_provider = new ConventionMethodDefinitionProvider();
context.subscriptions.push(
vscode.languages.registerHoverProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
convention_hover_provider
)
);
context.subscriptions.push(
vscode.languages.registerDefinitionProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
convention_definition_provider
)
);
convention_diagnostic_provider.activate(context);
console.log('Convention method providers registered for JavaScript/TypeScript');
// Register JQHTML lifecycle method providers for JavaScript/TypeScript
const jqhtml_semantic_provider = new JqhtmlLifecycleSemanticTokensProvider();
const jqhtml_hover_provider = new JqhtmlLifecycleHoverProvider();
const jqhtml_diagnostic_provider = new JqhtmlLifecycleDiagnosticProvider();
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
jqhtml_semantic_provider,
new vscode.SemanticTokensLegend(['conventionMethod'])
)
);
context.subscriptions.push(
vscode.languages.registerHoverProvider(
[{ language: 'javascript' }, { language: 'typescript' }],
jqhtml_hover_provider
)
);
jqhtml_diagnostic_provider.activate(context);
console.log('JQHTML lifecycle providers registered for JavaScript/TypeScript');
// Register PHP attribute provider
const php_attribute_provider = new PhpAttributeSemanticTokensProvider();
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
[{ language: 'php' }],
php_attribute_provider,
new vscode.SemanticTokensLegend(['conventionMethod'])
)
);
console.log('PHP attribute provider registered for PHP files');
// Register Blade component provider for uppercase component tags
const blade_component_provider = new BladeComponentSemanticTokensProvider();
context.subscriptions.push(
vscode.languages.registerDocumentSemanticTokensProvider(
[{ language: 'blade' }, { pattern: '**/*.blade.php' }],
blade_component_provider,
new vscode.SemanticTokensLegend(['class', 'jqhtmlTagAttribute'])
)
);
console.log('Blade component provider registered for Blade files');
// Debug client disabled
// debug_client = new DebugClient(formatting_provider as any);
// debug_client.start().catch(error => {
// console.error('Failed to start debug client:', error);
// });
// console.log('RSpade debug client started (WebSocket test)');
// Clear status bar on document save
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument(() => {
definition_provider.clear_status_bar();
})
);
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('rspade.toggleFolding', () => {
const config = get_config();
const current = config.get<boolean>('enableCodeFolding', true);
config.update('enableCodeFolding', !current, vscode.ConfigurationTarget.Workspace);
vscode.window.showInformationMessage(`RSpade code folding ${!current ? 'enabled' : 'disabled'}`);
})
);
context.subscriptions.push(
vscode.commands.registerCommand('rspade.formatPhpFile', async () => {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === 'php') {
await vscode.commands.executeCommand('editor.action.formatDocument');
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('rspade.updateNamespace', async () => {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.languageId === 'php') {
await formatting_provider.update_namespace_only(editor.document);
}
})
);
// Override built-in copyRelativePath commands to use project root
const copy_relative_path_handler = async (uri?: vscode.Uri) => {
const rspade_root = find_rspade_root();
if (!rspade_root) {
vscode.window.showErrorMessage('Could not find RSpade project root');
return;
}
// Get URI from context menu click or active editor
const file_uri = uri || vscode.window.activeTextEditor?.document.uri;
if (!file_uri) {
return;
}
// Get path relative to project root
const relative_path = path.relative(rspade_root, file_uri.fsPath);
// Copy to clipboard
await vscode.env.clipboard.writeText(relative_path);
vscode.window.showInformationMessage(`Copied: ${relative_path}`);
};
// Register our custom command
context.subscriptions.push(
vscode.commands.registerCommand('rspade.copyRelativePathFromRoot', copy_relative_path_handler)
);
// Override built-in commands
context.subscriptions.push(
vscode.commands.registerCommand('copyRelativePath', copy_relative_path_handler)
);
context.subscriptions.push(
vscode.commands.registerCommand('copyRelativeFilePath', copy_relative_path_handler)
);
// Watch for configuration changes
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('rspade')) {
vscode.window.showInformationMessage('RSpade configuration changed. Restart VS Code for some changes to take effect.');
}
})
);
// Watch for extension update marker file
watch_for_self_update(context);
// Watch for terminal close marker file
watch_for_terminal_close(context);
}
function watch_for_self_update(context: vscode.ExtensionContext) {
// Check for update marker file every 2 seconds
const rspade_root = find_rspade_root();
if (!rspade_root) {
return;
}
const marker_file = path.join(rspade_root, '.vscode', '.rspade-extension-updated');
const check_interval = setInterval(() => {
if (fs.existsSync(marker_file)) {
console.log('[RSpade] Extension update marker detected, reloading window in 2 seconds...');
// Clear the interval immediately
clearInterval(check_interval);
// Wait 2 seconds before reloading to allow other VS Code instances to see the marker
setTimeout(async () => {
// Try to delete the marker file (may already be deleted by another instance)
try {
if (fs.existsSync(marker_file)) {
fs.unlinkSync(marker_file);
console.log('[RSpade] Deleted marker file');
}
} catch (error) {
console.error('[RSpade] Failed to delete marker file:', error);
}
// Close the terminal panel
console.log('[RSpade] Closing terminal panel');
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 200ms for panel to close
await new Promise(resolve => setTimeout(resolve, 200));
// Check for conflicting extensions after panel closes
await check_conflicting_extensions();
// Auto-reload VS Code
console.log('[RSpade] Reloading window now');
vscode.commands.executeCommand('workbench.action.reloadWindow');
}, 2000);
}
}, 2000); // Check every 2 seconds
// Clean up interval on deactivate
context.subscriptions.push({
dispose: () => clearInterval(check_interval)
});
}
function watch_for_terminal_close(context: vscode.ExtensionContext) {
// Check for terminal close marker file every second
const rspade_root = find_rspade_root();
if (!rspade_root) {
return;
}
const marker_file = path.join(rspade_root, '.vscode', '.rspade-close-terminal');
const check_interval = setInterval(() => {
if (fs.existsSync(marker_file)) {
console.log('[RSpade] Terminal close marker detected, hiding panel in 2 seconds...');
// Clear the interval immediately
clearInterval(check_interval);
// Wait 2 seconds to allow other VS Code instances to see the marker
setTimeout(async () => {
// Try to delete the marker file (may already be deleted by another instance)
try {
if (fs.existsSync(marker_file)) {
fs.unlinkSync(marker_file);
console.log('[RSpade] Deleted terminal close marker');
}
} catch (error) {
console.error('[RSpade] Failed to delete terminal close marker:', error);
}
// Close all terminals
console.log('[RSpade] Closing all terminals');
vscode.window.terminals.forEach(terminal => terminal.dispose());
// Close the terminal panel
console.log('[RSpade] Closing terminal panel');
await vscode.commands.executeCommand('workbench.action.closePanel');
}, 2000);
}
}, 1000); // Check every second
// Clean up interval on deactivate
context.subscriptions.push({
dispose: () => clearInterval(check_interval)
});
}
export function deactivate() {
// Cleanup
if (decoration_provider) {
decoration_provider.dispose();
}
if (file_watcher) {
file_watcher.dispose();
}
if (auto_rename_provider) {
auto_rename_provider.dispose();
}
// if (debug_client) {
// debug_client.dispose();
// }
}

View File

@@ -0,0 +1,105 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { get_python_command } from './config';
const exec_async = promisify(exec);
export class RspadeFileWatcher {
private disposables: vscode.Disposable[] = [];
activate(context: vscode.ExtensionContext) {
// Watch for file rename/move events
const watcher = vscode.workspace.createFileSystemWatcher('**/*.php');
// Track file renames
const file_tracker = new Map<string, string>();
// Before delete, store the content
watcher.onDidDelete(uri => {
if (this.is_php_file_in_rsx(uri)) {
// Store file path for potential rename detection
file_tracker.set(uri.fsPath, uri.fsPath);
// Clean up old entries after 1 second
setTimeout(() => {
file_tracker.delete(uri.fsPath);
}, 1000);
}
});
// On create, check if it's a rename
watcher.onDidCreate(async uri => {
if (this.is_php_file_in_rsx(uri)) {
// Check if this might be a rename
let old_path: string | undefined;
// Look for recently deleted files
for (const [deleted_path, _] of file_tracker) {
// If the file was deleted within the last second, it might be a rename
if (path.basename(deleted_path) !== path.basename(uri.fsPath)) {
old_path = deleted_path;
break;
}
}
// Format the file to update namespace
await this.format_php_file(uri.fsPath);
if (old_path) {
// Silent update - no notification needed
console.log(`Updated namespace for moved file ${path.basename(uri.fsPath)}`);
file_tracker.delete(old_path);
}
}
});
this.disposables.push(watcher);
context.subscriptions.push(...this.disposables);
// Also handle explicit rename commands
context.subscriptions.push(
vscode.workspace.onDidRenameFiles(async e => {
for (const file of e.files) {
if (this.is_php_file_in_rsx(file.newUri)) {
await this.format_php_file(file.newUri.fsPath);
// Silent update - no notification needed
console.log(`Updated namespace for renamed file ${path.basename(file.newUri.fsPath)}`);
}
}
})
);
}
private is_php_file_in_rsx(uri: vscode.Uri): boolean {
const file_path = uri.fsPath;
return file_path.endsWith('.php') &&
(file_path.includes(path.sep + 'rsx' + path.sep) ||
file_path.includes('/rsx/'));
}
private async format_php_file(file_path: string): Promise<void> {
try {
const workspace_folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(file_path));
if (!workspace_folder) return;
const workspace_root = workspace_folder.uri.fsPath;
const orchestrator_path = path.join(workspace_root, '.vscode', 'formatters', 'orchestrator.py');
const python_cmd = get_python_command();
const command = `${python_cmd} "${orchestrator_path}" "${file_path}"`;
await exec_async(command, { cwd: workspace_root });
} catch (error) {
console.error('Failed to format PHP file:', error);
}
}
dispose() {
for (const disposable of this.disposables) {
disposable.dispose();
}
this.disposables = [];
}
}

View File

@@ -0,0 +1,121 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
/**
* Provides folder coloring for the RSpade framework
*
* Colors:
* - rsx/ - Blue (highlight important directory)
* - system/ - Muted gray
* - app/ - Muted gray (legacy structure)
* - routes/ - Muted gray (legacy structure)
*/
export class FolderColorProvider implements vscode.FileDecorationProvider {
private readonly _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined> =
new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
public readonly onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[] | undefined> =
this._onDidChangeFileDecorations.event;
/**
* Find the RSpade project root folder (contains rsx/ and system/app/RSpade/)
* Works in both single-folder and multi-root workspace modes
*/
private find_rspade_root(): string | undefined {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
// Check each workspace folder for rsx/ and system/app/RSpade/ (new structure)
// or app/RSpade/ (legacy structure)
for (const folder of vscode.workspace.workspaceFolders) {
const rsx_dir = path.join(folder.uri.fsPath, 'rsx');
const system_app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade');
// New structure: requires both rsx/ and system/app/RSpade/
if (fs.existsSync(rsx_dir) && fs.existsSync(system_app_rspade)) {
return folder.uri.fsPath;
}
// Legacy structure: just app/RSpade/
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
return folder.uri.fsPath;
}
}
return undefined;
}
provideFileDecoration(
uri: vscode.Uri,
token: vscode.CancellationToken
): vscode.ProviderResult<vscode.FileDecoration> {
if (!vscode.workspace.workspaceFolders) {
console.log('[FolderColor] No workspace folders');
return undefined;
}
const uriPath = uri.fsPath.replace(/\\/g, '/');
// Check if this URI is a workspace folder root (for multi-root workspaces)
const workspaceFolder = vscode.workspace.workspaceFolders.find(
folder => folder.uri.fsPath.replace(/\\/g, '/') === uriPath
);
if (workspaceFolder) {
const folderName = workspaceFolder.name.toLowerCase();
console.log('[FolderColor] Workspace folder:', folderName);
// Color workspace folders based on name
if (folderName.includes('rsx')) {
return new vscode.FileDecoration(
undefined,
undefined,
new vscode.ThemeColor('charts.blue')
);
}
// docs, database, public - no coloring (default)
if (folderName.includes('system')) {
return new vscode.FileDecoration(
undefined,
undefined,
new vscode.ThemeColor('descriptionForeground')
);
}
}
// For single-folder workspaces, color top-level directories
const workspaceRoot = this.find_rspade_root();
if (!workspaceRoot) {
return undefined;
}
const relativePath = uriPath.replace(workspaceRoot.replace(/\\/g, '/') + '/', '');
// Only color top-level directories (no subdirectories)
if (relativePath.includes('/')) {
return undefined;
}
// Color blue for rsx
if (relativePath === 'rsx') {
return new vscode.FileDecoration(
undefined,
undefined,
new vscode.ThemeColor('charts.blue')
);
}
// Color muted gray for system, app, and routes
if (relativePath === 'system' || relativePath === 'app' || relativePath === 'routes') {
return new vscode.FileDecoration(
undefined,
undefined,
new vscode.ThemeColor('descriptionForeground')
);
}
return undefined;
}
}

View File

@@ -0,0 +1,41 @@
import * as vscode from 'vscode';
export class RspadeFoldingProvider implements vscode.FoldingRangeProvider {
// RSX markers are no longer used - keeping class for potential future use
private static readonly LLMDIRECTIVE_START = '// [RSX:LLMDIRECTIVE:START]'; // Deprecated
private static readonly LLMDIRECTIVE_END = '// [RSX:LLMDIRECTIVE:END]'; // Deprecated
provideFoldingRanges(
document: vscode.TextDocument,
_context: vscode.FoldingContext,
_token: vscode.CancellationToken
): vscode.ProviderResult<vscode.FoldingRange[]> {
// RSX markers are no longer used - returning empty array
return [];
/* Original implementation preserved for reference
const folding_ranges: vscode.FoldingRange[] = [];
let start_line: number | null = null;
for (let i = 0; i < document.lineCount; i++) {
const line = document.lineAt(i);
const text = line.text.trim();
if (text.includes(RspadeFoldingProvider.LLMDIRECTIVE_START)) {
start_line = i;
} else if (text.includes(RspadeFoldingProvider.LLMDIRECTIVE_END) && start_line !== null) {
// Create folding range from start to end
folding_ranges.push(new vscode.FoldingRange(
start_line,
i,
vscode.FoldingRangeKind.Region
));
start_line = null;
}
}
return folding_ranges;
*/
}
}

View File

@@ -0,0 +1,562 @@
/**
* RSpade Formatting Provider
*
* Handles code formatting via remote IDE service endpoints.
* All formatting is performed on the server - no local PHP execution.
*
* Authentication Flow:
* 1. Reads domain from storage/rsx-ide-bridge/domain.txt (auto-discovered)
* 2. Creates session with auth tokens on first use
* 3. Signs all requests with SHA1(body + client_key)
* 4. Validates server responses with SHA1(body + server_key)
*/
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import * as https from 'https';
import * as http from 'http';
import * as crypto from 'crypto';
import { promisify } from 'util';
const read_file = promisify(fs.readFile);
const write_file = promisify(fs.writeFile);
const exists = promisify(fs.exists);
interface AuthData {
session: string;
client_key: string;
server_key: string;
}
export class RspadeFormattingProvider implements vscode.DocumentFormattingEditProvider {
private auth_data: AuthData | null = null;
private server_url: string | null = null;
private output_channel: vscode.OutputChannel;
constructor() {
this.output_channel = vscode.window.createOutputChannel('RSpade Formatter');
console.log('[RSpade Formatter] Provider initialized');
this.output_channel.appendLine('=== RSpade Formatter Initialized ===');
this.output_channel.appendLine(`Time: ${new Date().toISOString()}`);
this.output_channel.appendLine('Ready to format PHP files via remote server');
}
private find_rspade_root(): string | undefined {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
// Check each workspace folder for system/app/RSpade/ (new structure) or app/RSpade/ (legacy)
for (const folder of vscode.workspace.workspaceFolders) {
// Try new structure first (both rsx/ and system/ must exist)
const rsx_dir = path.join(folder.uri.fsPath, 'rsx');
const system_app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade');
if (fs.existsSync(rsx_dir) && fs.existsSync(system_app_rspade)) {
// Return the project root (not system/)
return folder.uri.fsPath;
}
// Fall back to legacy structure
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
return folder.uri.fsPath;
}
}
return undefined;
}
async provideDocumentFormattingEdits(
document: vscode.TextDocument,
_options: vscode.FormattingOptions,
_token: vscode.CancellationToken
): Promise<vscode.TextEdit[]> {
console.log(`[RSpade Formatter] Format request: ${path.basename(document.fileName)}`);
this.output_channel.appendLine(`\n=== FORMAT REQUEST ===`);
this.output_channel.appendLine(`File: ${document.fileName}`);
this.output_channel.appendLine(`Language: ${document.languageId}`);
this.output_channel.appendLine(`Time: ${new Date().toISOString()}`);
if (document.languageId !== 'php') {
this.output_channel.appendLine('Result: Skipped (not a PHP file)');
return [];
}
try {
// Get the current document text
const original_text = document.getText();
this.output_channel.appendLine(`Original text length: ${original_text.length} chars`);
// Find RSpade project root
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
throw new Error('RSpade project root not found');
}
// Get relative path from project root
let relative_path = path.relative(rspade_root, document.uri.fsPath);
relative_path = relative_path.replace(/\\/g, '/'); // Convert Windows paths to Unix
this.output_channel.appendLine(`Relative path: ${relative_path}`);
this.output_channel.appendLine(`Project root: ${rspade_root}`);
// Format via server
this.output_channel.appendLine('Calling format_via_server...');
const formatted_text = await this.format_via_server(relative_path, original_text);
// If content changed, return a TextEdit to replace entire document
if (formatted_text !== original_text) {
const full_range = new vscode.Range(
document.positionAt(0),
document.positionAt(original_text.length)
);
this.output_channel.appendLine(`Result: SUCCESS - Content changed`);
this.output_channel.appendLine(`Formatted text length: ${formatted_text.length} chars`);
return [vscode.TextEdit.replace(full_range, formatted_text)];
}
this.output_channel.appendLine('Result: SUCCESS - Content unchanged');
return [];
} catch (error: any) {
console.error('[RSpade Formatter] Format failed:', error.message);
this.output_channel.appendLine(`Result: ERROR`);
this.output_channel.appendLine(`Error message: ${error.message}`);
this.output_channel.appendLine(`Error stack: ${error.stack}`);
vscode.window.showErrorMessage(`RSpade formatting failed: ${error.message || error}`);
return [];
}
}
async update_namespace_only(document: vscode.TextDocument): Promise<void> {
this.output_channel.appendLine(`\n=== NAMESPACE UPDATE ===`);
this.output_channel.appendLine(`File: ${document.fileName}`);
this.output_channel.appendLine(`Language: ${document.languageId}`);
if (document.languageId !== 'php') {
this.output_channel.appendLine('Skipped: Not a PHP file');
return;
}
try {
// Save the document first if it has unsaved changes
if (document.isDirty) {
this.output_channel.appendLine('Saving unsaved changes...');
await document.save();
this.output_channel.appendLine('Document saved');
}
// Get relative path
const workspace_folder = vscode.workspace.getWorkspaceFolder(document.uri);
if (!workspace_folder) {
this.output_channel.appendLine('ERROR: No workspace folder found');
throw new Error('No workspace folder found');
}
let relative_path = path.relative(workspace_folder.uri.fsPath, document.uri.fsPath);
relative_path = relative_path.replace(/\\/g, '/');
this.output_channel.appendLine(`Relative path: ${relative_path}`);
// Read current content
const content = await read_file(document.uri.fsPath, 'utf8');
this.output_channel.appendLine(`File content length: ${content.length} chars`);
// Format via server
this.output_channel.appendLine('Calling format_via_server for namespace update...');
await this.format_via_server(relative_path, content);
this.output_channel.appendLine('Namespace update completed successfully');
// Silent update - no notification needed
} catch (error: any) {
console.error('[RSpade Formatter] Namespace update failed:', error.message);
this.output_channel.appendLine(`ERROR: ${error.message}`);
this.output_channel.appendLine(`Stack: ${error.stack}`);
vscode.window.showErrorMessage(`RSpade namespace update failed: ${error.message || error}`);
}
}
private async format_via_server(relative_path: string, content: string): Promise<string> {
this.output_channel.appendLine('\n--- SERVER FORMATTING ---');
this.output_channel.appendLine(`File path: ${relative_path}`);
this.output_channel.appendLine(`Content length: ${content.length} chars`);
// Ensure we have authentication
await this.ensure_auth();
// Find RSpade project root for temp file creation
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
this.output_channel.appendLine('ERROR: RSpade project root not found');
throw new Error('RSpade project root not found');
}
const temp_file_path = relative_path + '.formatting.tmp';
const full_temp_path = path.join(rspade_root, temp_file_path);
this.output_channel.appendLine(`Creating temp file: ${temp_file_path}`);
// Write content to temp file
await write_file(full_temp_path, content, 'utf8');
this.output_channel.appendLine('Temp file written successfully');
try {
// Prepare request data
const request_data = {
file: temp_file_path,
return_content: true
};
this.output_channel.appendLine(`Request data: ${JSON.stringify(request_data)}`);
const response = await this.make_authenticated_request('/format', request_data);
if (!response.success) {
this.output_channel.appendLine(`ERROR: Server formatting failed - ${response.error}`);
throw new Error(response.error || 'Formatting failed');
}
// Clean up temp file
try {
fs.unlinkSync(full_temp_path);
this.output_channel.appendLine('Temp file cleaned up');
} catch (e: any) {
this.output_channel.appendLine(`Warning: Failed to clean up temp file - ${e.message}`);
}
const formatted_content = response.content || content;
this.output_channel.appendLine(`Formatted content length: ${formatted_content.length} chars`);
this.output_channel.appendLine(`Content changed: ${formatted_content !== content}`);
if (formatted_content !== content) {
this.output_channel.appendLine(`First 100 chars of original: ${content.substring(0, 100)}`);
this.output_channel.appendLine(`First 100 chars of formatted: ${formatted_content.substring(0, 100)}`);
}
return formatted_content;
} catch (error: any) {
this.output_channel.appendLine(`ERROR during formatting: ${error.message}`);
// Clean up temp file on error
try {
fs.unlinkSync(full_temp_path);
this.output_channel.appendLine('Temp file cleaned up after error');
} catch (e: any) {
this.output_channel.appendLine(`Warning: Failed to clean up temp file - ${e.message}`);
}
throw error;
}
}
public async ensure_auth(force_new: boolean = false): Promise<any> {
if (this.auth_data && !force_new) {
console.log('[RSpade Formatter] Reusing session:', this.auth_data.session.substring(0, 8) + '...');
this.output_channel.appendLine(`Using existing auth session: ${this.auth_data.session}`);
return {
session_id: this.auth_data.session,
server_key: this.auth_data.server_key
};
}
console.log('[RSpade Formatter] Creating new auth session');
this.output_channel.appendLine('\n--- AUTHENTICATION ---');
this.output_channel.appendLine('No existing auth session, creating new...');
// Get server URL (force refresh if this is a retry)
await this.get_server_url(force_new);
// Create new auth session
this.output_channel.appendLine(`Creating auth session at: ${this.server_url}/_ide/service/auth/create`);
const response = await this.make_request('/auth/create', {}, 'POST', false);
if (!response.success) {
throw new Error('Failed to create auth session');
}
this.auth_data = {
session: response.session,
client_key: response.client_key,
server_key: response.server_key
};
console.log('[RSpade Formatter] Auth created:', this.auth_data.session.substring(0, 8) + '...');
this.output_channel.appendLine(`Auth session created successfully!`);
this.output_channel.appendLine(` Session ID: ${this.auth_data.session}`);
this.output_channel.appendLine(` Client Key: ${this.auth_data.client_key.substring(0, 8)}...`);
this.output_channel.appendLine(` Server Key: ${this.auth_data.server_key.substring(0, 8)}...`);
return {
session_id: this.auth_data.session,
server_key: this.auth_data.server_key
};
}
public async get_server_url(force_refresh: boolean = false): Promise<string | null> {
// Only re-check if forced (due to connection failure) or no URL cached
if (this.server_url && !force_refresh) {
this.output_channel.appendLine(`Using cached server URL: ${this.server_url}`);
return this.server_url;
}
this.output_channel.appendLine('\n--- SERVER URL DISCOVERY ---');
if (force_refresh) {
this.output_channel.appendLine('Re-checking server URL due to connection failure...');
}
// First check VS Code settings
const config = vscode.workspace.getConfiguration('rspade');
const configured_url = config.get<string>('serverUrl');
this.output_channel.appendLine(`Checking VS Code settings: ${configured_url ? 'found' : 'not found'}`);
if (configured_url) {
this.server_url = configured_url;
console.log('[RSpade Formatter] Using configured URL:', this.server_url);
this.output_channel.appendLine(`Using configured server URL: ${this.server_url}`);
return this.server_url;
}
// Try to auto-discover from file
// Find RSpade project root (works in both single-folder and multi-root workspace)
let rspade_root = null;
if (vscode.workspace.workspaceFolders) {
for (const folder of vscode.workspace.workspaceFolders) {
// Try new structure first
const system_app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade');
if (fs.existsSync(system_app_rspade)) {
rspade_root = path.join(folder.uri.fsPath, 'system');
break;
}
// Fall back to legacy structure
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
rspade_root = folder.uri.fsPath;
break;
}
}
}
if (!rspade_root) {
this.output_channel.appendLine('ERROR: RSpade project root not found');
throw new Error('RSpade project root not found');
}
const domain_file = path.join(rspade_root, 'storage', 'rsx-ide-bridge', 'domain.txt');
this.output_channel.appendLine(`Checking for domain file: ${domain_file}`);
if (await exists(domain_file)) {
const domain = await read_file(domain_file, 'utf8');
this.server_url = domain.trim();
console.log('[RSpade Formatter] Auto-discovered:', this.server_url);
this.output_channel.appendLine(`Auto-discovered server URL: ${this.server_url}`);
return this.server_url;
}
// Provide helpful error message
this.output_channel.appendLine('\n════════════════════════════════════════════════════════');
this.output_channel.appendLine('ERROR: RSpade Extension cannot connect to server');
this.output_channel.appendLine('════════════════════════════════════════════════════════');
this.output_channel.appendLine('\nThe extension needs to know your development server URL.');
this.output_channel.appendLine('\nPlease do ONE of the following:\n');
this.output_channel.appendLine('1. Load your site in a web browser');
this.output_channel.appendLine(' This will auto-create: storage/rsx-ide-bridge/domain.txt\n');
this.output_channel.appendLine('2. Set VS Code setting: rspade.serverUrl');
this.output_channel.appendLine(' File → Preferences → Settings → Search "rspade"');
this.output_channel.appendLine(' Set to your development URL (e.g., https://myapp.test)\n');
this.output_channel.appendLine('3. Enable IDE integration in Laravel config');
this.output_channel.appendLine(' In config/rsx.php, set ide_integration.enabled = true');
this.output_channel.appendLine('\n════════════════════════════════════════════════════════');
throw new Error('RSpade: storage/rsx-ide-bridge/domain.txt not found. Please load site in browser or configure server URL.');
}
private async make_authenticated_request(
endpoint: string,
data: any,
retry_count: number = 0
): Promise<any> {
this.output_channel.appendLine(`\n--- AUTHENTICATED REQUEST ${retry_count > 0 ? '(RETRY)' : ''} ---`);
this.output_channel.appendLine(`Endpoint: ${endpoint}`);
// If no auth data or this is a retry, create new session
if (!this.auth_data || retry_count > 0) {
if (retry_count === 0) {
this.output_channel.appendLine('No auth session exists, creating new one...');
} else {
this.output_channel.appendLine('Session lost, creating new session...');
}
try {
await this.ensure_auth();
} catch (error: any) {
this.output_channel.appendLine(`ERROR: Failed to create auth session: ${error.message}`);
throw error;
}
}
const body = JSON.stringify(data);
const signature = crypto.createHash('sha1').update(body + this.auth_data!.client_key).digest('hex');
console.log('[RSpade Formatter] Request sig:', signature.substring(0, 8) + '...');
this.output_channel.appendLine(`Body length: ${body.length} bytes`);
this.output_channel.appendLine(`Session: ${this.auth_data!.session}`);
this.output_channel.appendLine(`Client key (first 8 chars): ${this.auth_data!.client_key.substring(0, 8)}...`);
this.output_channel.appendLine(`Generated signature: ${signature}`);
try {
const response = await this.make_request(endpoint, data, 'POST', true, {
'X-Session': this.auth_data!.session,
'X-Signature': signature
});
return response;
} catch (error: any) {
// Check if this is a recoverable error and we haven't retried yet
if (retry_count === 0) {
const error_msg = error.message || '';
// Session expired or not found
if (error_msg.includes('Session not found') || error_msg.includes('Invalid signature')) {
this.output_channel.appendLine('Session/signature error, attempting recovery...');
this.auth_data = null;
// Force URL refresh in case server changed
this.server_url = null;
return this.make_authenticated_request(endpoint, data, retry_count + 1);
}
// Connection failure - maybe server URL changed
if (error_msg.includes('ECONNREFUSED') || error_msg.includes('ENOTFOUND') ||
error_msg.includes('ETIMEDOUT') || error_msg.includes('getaddrinfo')) {
this.output_channel.appendLine('Connection failed, refreshing server URL...');
this.auth_data = null;
// Force URL refresh
this.server_url = null;
return this.make_authenticated_request(endpoint, data, retry_count + 1);
}
}
// Not a recoverable error or already retried
throw error;
}
}
private async make_request(
endpoint: string,
data: any,
method: string = 'POST',
validate_signature: boolean = false,
extra_headers: any = {}
): Promise<any> {
return new Promise((resolve, reject) => {
this.output_channel.appendLine('\n--- HTTP REQUEST ---');
this.output_channel.appendLine(`Method: ${method}`);
this.output_channel.appendLine(`Validate signature: ${validate_signature}`);
if (!this.server_url) {
this.output_channel.appendLine('ERROR: Server URL not configured');
reject(new Error('Server URL not configured'));
return;
}
const url = new URL(this.server_url);
const is_https = url.protocol === 'https:';
const http_module = is_https ? https : http;
const body = JSON.stringify(data);
const options: https.RequestOptions = {
hostname: url.hostname,
port: url.port || (is_https ? 443 : 80),
path: '/_ide/service' + endpoint,
method: method,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...extra_headers
},
timeout: 30000, // 30 second timeout
rejectUnauthorized: false // Allow self-signed certificates
};
this.output_channel.appendLine(`Full URL: ${is_https ? 'https' : 'http'}://${options.hostname}:${options.port}${options.path}`);
this.output_channel.appendLine(`Request body size: ${Buffer.byteLength(body)} bytes`);
const start_time = Date.now();
const req = http_module.request(options, (res) => {
let response_data = '';
let total_bytes = 0;
this.output_channel.appendLine(`\n--- HTTP RESPONSE ---`);
this.output_channel.appendLine(`Status: ${res.statusCode}`);
res.on('data', (chunk) => {
response_data += chunk;
total_bytes += chunk.length;
});
res.on('end', () => {
const elapsed = Date.now() - start_time;
console.log(`[RSpade Formatter] Response: ${res.statusCode} in ${elapsed}ms (${total_bytes} bytes)`);
this.output_channel.appendLine(`Response time: ${elapsed}ms`);
this.output_channel.appendLine(`Response body size: ${response_data.length} bytes`);
try {
// Validate signature if required
if (validate_signature && this.auth_data) {
const response_signature = res.headers['x-signature'] as string;
this.output_channel.appendLine(`\n--- SIGNATURE VALIDATION ---`);
this.output_channel.appendLine(`Response signature: ${response_signature || 'missing'}`);
if (response_signature) {
const expected_signature = crypto.createHash('sha1')
.update(response_data + this.auth_data.server_key)
.digest('hex');
console.log('[RSpade Formatter] Sig match:', response_signature.substring(0, 8) + '... == ' + expected_signature.substring(0, 8) + '...');
this.output_channel.appendLine(`Server key (first 8 chars): ${this.auth_data.server_key.substring(0, 8)}...`);
this.output_channel.appendLine(`Expected signature: ${expected_signature}`);
this.output_channel.appendLine(`Validation: ${response_signature === expected_signature ? 'PASSED' : 'FAILED'}`);
if (response_signature !== expected_signature) {
console.error('[RSpade Formatter] Signature mismatch!');
this.output_channel.appendLine('ERROR: Invalid server signature');
reject(new Error('Invalid server signature'));
return;
}
}
}
const response = JSON.parse(response_data);
if (res.statusCode !== 200) {
this.output_channel.appendLine(`ERROR: ${response.error || `HTTP ${res.statusCode}`}`);
reject(new Error(response.error || `HTTP ${res.statusCode}`));
} else {
this.output_channel.appendLine('Result: SUCCESS');
resolve(response);
}
} catch (e: any) {
this.output_channel.appendLine(`ERROR: Failed to parse response - ${e.message}`);
reject(new Error('Invalid response from server'));
}
});
});
req.on('error', (error) => {
const elapsed = Date.now() - start_time;
console.error(`[RSpade Formatter] Request failed after ${elapsed}ms:`, error.message);
this.output_channel.appendLine(`\nERROR after ${elapsed}ms: ${error.message}`);
reject(error);
});
req.on('timeout', () => {
this.output_channel.appendLine('\nERROR: Request timed out after 30 seconds');
req.destroy();
reject(new Error('Request timed out'));
});
req.write(body);
req.end();
});
}
}

View File

@@ -0,0 +1,543 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as crypto from 'crypto';
import { URL } from 'url';
interface AuthData {
session: string;
client_key: string;
server_key: string;
}
interface DiffResponse {
success: boolean;
added: number[][];
modified: number[][];
deleted: number[][];
}
/**
* Git diff provider - shows line-level change indicators in gutter
*/
export class GitDiffProvider {
private rspade_root: string | undefined;
private auth_data: AuthData | null = null;
private server_url: string | null = null;
private cached_protocol: 'https:' | 'http:' | null = null;
private retry_timer: NodeJS.Timeout | null = null;
private retry_interval: number = 60000; // 60 seconds
private domain_file_watcher: fs.FSWatcher | null = null;
private added_decoration: vscode.TextEditorDecorationType;
private modified_decoration: vscode.TextEditorDecorationType;
private deleted_decoration: vscode.TextEditorDecorationType;
// Track git diff state and local modifications per document
private git_state: Map<string, DiffResponse> = new Map();
private original_content: Map<string, string> = new Map();
constructor(rspade_root: string | undefined) {
this.rspade_root = rspade_root;
// Watch domain.txt for changes
this.setup_domain_file_watcher();
// Create decoration types for different change types
this.added_decoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
overviewRulerColor: 'rgba(0, 255, 0, 0.6)',
overviewRulerLane: vscode.OverviewRulerLane.Left,
gutterIconPath: this.create_colored_bar('#28a745'),
gutterIconSize: 'contain'
});
this.modified_decoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
overviewRulerColor: 'rgba(33, 150, 243, 0.6)',
overviewRulerLane: vscode.OverviewRulerLane.Left,
gutterIconPath: this.create_colored_bar('#2196f3'),
gutterIconSize: 'contain'
});
this.deleted_decoration = vscode.window.createTextEditorDecorationType({
isWholeLine: true,
overviewRulerColor: 'rgba(244, 67, 54, 0.6)',
overviewRulerLane: vscode.OverviewRulerLane.Left,
gutterIconPath: this.create_colored_bar('#f44336'),
gutterIconSize: 'contain'
});
}
activate(context: vscode.ExtensionContext) {
// Track document changes for real-time line marking
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(e => {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document === e.document) {
this.update_decorations_for_changes(editor);
}
})
);
// Refresh git diff on file save
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument(async document => {
const editor = vscode.window.activeTextEditor;
if (editor && editor.document === document) {
await this.fetch_git_diff(editor);
this.original_content.set(document.uri.toString(), document.getText());
this.update_decorations_for_changes(editor);
}
})
);
// Refresh on active editor change (only if clean)
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(async editor => {
if (editor) {
const doc_key = editor.document.uri.toString();
// Only fetch git diff if document is clean
if (!editor.document.isDirty) {
await this.fetch_git_diff(editor);
this.original_content.set(doc_key, editor.document.getText());
}
this.update_decorations_for_changes(editor);
}
})
);
// Initial refresh for current editor
const editor = vscode.window.activeTextEditor;
if (editor) {
this.fetch_git_diff(editor).then(() => {
this.original_content.set(editor.document.uri.toString(), editor.document.getText());
this.update_decorations_for_changes(editor);
});
}
}
private create_colored_bar(color: string): vscode.Uri {
// Create a simple SVG colored bar for the gutter
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="3" height="16"><rect width="3" height="16" fill="${color}"/></svg>`;
const data_uri = 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
return vscode.Uri.parse(data_uri);
}
private async fetch_git_diff(editor: vscode.TextEditor) {
if (!this.rspade_root) {
return;
}
try {
const relative_path = path.relative(this.rspade_root, editor.document.uri.fsPath).replace(/\\/g, '/');
const response = await this.make_authenticated_request('/git/diff', { file: relative_path });
if (response.success) {
this.git_state.set(editor.document.uri.toString(), response);
}
} catch (error) {
console.error('[GitDiff] Failed to fetch git diff:', error);
}
}
private update_decorations_for_changes(editor: vscode.TextEditor) {
const doc_key = editor.document.uri.toString();
const git_diff = this.git_state.get(doc_key);
const original_text = this.original_content.get(doc_key);
if (!git_diff) {
return;
}
// Start with git diff changes
const added_lines = new Set<number>();
const modified_lines = new Set<number>();
const deleted_lines = new Set<number>();
// Add git diff lines
for (const [start, end] of git_diff.added) {
for (let line = start; line <= end; line++) {
added_lines.add(line);
}
}
for (const [start, end] of git_diff.modified) {
for (let line = start; line <= end; line++) {
modified_lines.add(line);
}
}
for (const [start, end] of git_diff.deleted) {
for (let line = start; line <= end; line++) {
deleted_lines.add(line);
}
}
// If document is dirty and we have original content, compute proper diff
if (editor.document.isDirty && original_text) {
const local_changes = this.compute_diff(original_text, editor.document.getText());
// Overlay local changes on top of git changes
for (const line of local_changes.added) {
added_lines.add(line);
}
for (const line of local_changes.modified) {
modified_lines.add(line);
}
for (const line of local_changes.deleted) {
deleted_lines.add(line);
}
}
// Convert to ranges and apply decorations
const added_ranges = Array.from(added_lines).map(line => new vscode.Range(line - 1, 0, line - 1, 0));
const modified_ranges = Array.from(modified_lines).map(line => new vscode.Range(line - 1, 0, line - 1, 0));
const deleted_ranges = Array.from(deleted_lines).map(line => new vscode.Range(line - 1, 0, line - 1, 0));
editor.setDecorations(this.added_decoration, added_ranges);
editor.setDecorations(this.modified_decoration, modified_ranges);
editor.setDecorations(this.deleted_decoration, deleted_ranges);
}
private compute_diff(original: string, current: string): { added: number[], modified: number[], deleted: number[] } {
const original_lines = original.split('\n');
const current_lines = current.split('\n');
// Build LCS table for longest common subsequence
const m = original_lines.length;
const n = current_lines.length;
const lcs: number[][] = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (original_lines[i - 1] === current_lines[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
// Backtrack to find which lines changed
const added: number[] = [];
const modified: number[] = [];
const deleted: number[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && original_lines[i - 1] === current_lines[j - 1]) {
// Line unchanged
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
// Line added in current
added.push(j); // 1-indexed
j--;
} else if (i > 0) {
// Line deleted from original
deleted.push(j + 1); // Mark position where it was deleted (1-indexed)
i--;
}
}
return { added, modified, deleted };
}
private async get_server_url(): Promise<string> {
if (this.server_url) {
return this.server_url;
}
const config = vscode.workspace.getConfiguration('rspade');
const configured_url = config.get<string>('serverUrl');
if (configured_url) {
this.server_url = await this.negotiate_protocol(configured_url);
return this.server_url;
}
const domain_file = path.join(this.rspade_root!, 'storage', 'rsx-ide-bridge', 'domain.txt');
if (fs.existsSync(domain_file)) {
const domain = fs.readFileSync(domain_file, 'utf8').trim();
this.server_url = await this.negotiate_protocol(domain);
return this.server_url;
}
// domain.txt doesn't exist yet - schedule retry
this.schedule_retry();
throw new Error('Server URL not configured - waiting for domain.txt');
}
private async negotiate_protocol(url_or_hostname: string): Promise<string> {
// Parse the input to extract hostname
let hostname: string;
// If it looks like a URL, parse it
if (url_or_hostname.includes('://')) {
const parsed = new URL(url_or_hostname);
hostname = parsed.hostname;
// If we have a cached protocol from previous successful connection, use it
if (this.cached_protocol) {
console.log(`[GitDiff] Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
} else {
hostname = url_or_hostname;
}
// If we have a cached protocol, use it
if (this.cached_protocol) {
console.log(`[GitDiff] Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
// Try HTTPS first
console.log(`[GitDiff] Negotiating protocol for: ${hostname}`);
const https_url = `https://${hostname}`;
if (await this.test_connection(https_url)) {
console.log(`[GitDiff] HTTPS connection successful`);
this.cached_protocol = 'https:';
return https_url;
}
console.log(`[GitDiff] HTTPS failed, trying HTTP...`);
const http_url = `http://${hostname}`;
if (await this.test_connection(http_url)) {
console.log(`[GitDiff] HTTP connection successful`);
this.cached_protocol = 'http:';
return http_url;
}
throw new Error(`Unable to connect to ${hostname} via HTTPS or HTTP`);
}
private async test_connection(base_url: string): Promise<boolean> {
try {
const parsed_url = new URL(base_url);
const is_https = parsed_url.protocol === 'https:';
const port = parsed_url.port || (is_https ? 443 : 80);
const test_path = '/_ide/service/auth/create';
return new Promise<boolean>((resolve) => {
const options = {
hostname: parsed_url.hostname,
port: port,
path: test_path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '2'
},
timeout: 5000
};
const client = is_https ? https : http;
const req = client.request(options, (res) => {
// 200 OK or 301/302 redirect means server is responding
// We'll handle redirects by just noting protocol works
const success = res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302;
// If we got 301/302, it means we should probably use the other protocol
// But we'll return false to try the other one
if (res.statusCode === 301 || res.statusCode === 302) {
console.log(`[GitDiff] Got redirect ${res.statusCode} from ${base_url}`);
resolve(false);
} else {
resolve(success);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.write('{}');
req.end();
});
} catch (error) {
return false;
}
}
private setup_domain_file_watcher() {
if (!this.rspade_root) {
return;
}
const domain_file = path.join(this.rspade_root, 'storage', 'rsx-ide-bridge', 'domain.txt');
const domain_dir = path.dirname(domain_file);
// Watch the directory (file might not exist yet)
if (fs.existsSync(domain_dir)) {
try {
this.domain_file_watcher = fs.watch(domain_dir, (eventType, filename) => {
if (filename === 'domain.txt' && eventType === 'change') {
console.log('[GitDiff] domain.txt changed, clearing cached URL and protocol');
this.server_url = null;
this.cached_protocol = null;
this.auth_data = null;
// Cancel any pending retries
if (this.retry_timer) {
clearTimeout(this.retry_timer);
this.retry_timer = null;
}
// Refresh git diff for active editor
const editor = vscode.window.activeTextEditor;
if (editor) {
this.fetch_git_diff(editor);
}
}
});
} catch (error) {
console.error('[GitDiff] Failed to set up domain file watcher:', error);
}
}
}
private schedule_retry() {
// Don't schedule multiple retries
if (this.retry_timer) {
return;
}
console.log(`[GitDiff] Scheduling retry in ${this.retry_interval / 1000} seconds...`);
this.retry_timer = setTimeout(() => {
console.log('[GitDiff] Retrying git diff fetch...');
this.retry_timer = null;
const editor = vscode.window.activeTextEditor;
if (editor) {
this.fetch_git_diff(editor);
}
}, this.retry_interval);
}
private async ensure_auth(): Promise<void> {
if (this.auth_data) {
return;
}
await this.get_server_url();
const response = await this.make_request('/auth/create', {}, 'POST', false);
if (!response.success) {
throw new Error('Failed to create auth session');
}
this.auth_data = {
session: response.session,
client_key: response.client_key,
server_key: response.server_key
};
}
private async make_authenticated_request(endpoint: string, data: any): Promise<any> {
if (!this.auth_data) {
await this.ensure_auth();
}
const body = JSON.stringify(data);
const signature = crypto.createHash('sha1').update(body + this.auth_data!.client_key).digest('hex');
try {
const response = await this.make_request(endpoint, data, 'POST', true, {
'X-Session': this.auth_data!.session,
'X-Signature': signature
});
return response;
} catch (error: any) {
const error_msg = error.message || '';
if (error_msg.includes('Session not found') || error_msg.includes('Invalid signature')) {
this.auth_data = null;
await this.ensure_auth();
const body = JSON.stringify(data);
const signature = crypto.createHash('sha1').update(body + this.auth_data!.client_key).digest('hex');
return await this.make_request(endpoint, data, 'POST', true, {
'X-Session': this.auth_data!.session,
'X-Signature': signature
});
}
throw error;
}
}
private make_request(endpoint: string, data: any, method: string, signed: boolean, extra_headers: any = {}): Promise<any> {
return new Promise(async (resolve, reject) => {
const server_url = await this.get_server_url();
const parsed_url = new URL(server_url);
const is_https = parsed_url.protocol === 'https:';
const port = parsed_url.port || (is_https ? 443 : 80);
const full_path = `/_ide/service${endpoint}`;
const body_str = JSON.stringify(data);
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body_str),
...extra_headers
};
const options = {
hostname: parsed_url.hostname,
port: port,
path: full_path,
method: method,
headers: headers
};
console.log(`[GitDiff] Making request to: ${parsed_url.protocol}//${parsed_url.hostname}:${port}${full_path}`);
console.log(`[GitDiff] Server URL from config/file: ${server_url}`);
const client = is_https ? https : http;
const req = client.request(options, (res) => {
console.log(`[GitDiff] Response status: ${res.statusCode}`);
let response_data = '';
res.on('data', chunk => response_data += chunk);
res.on('end', () => {
console.log(`[GitDiff] Response body: ${response_data.substring(0, 200)}`);
try {
const parsed = JSON.parse(response_data);
resolve(parsed);
} catch (e) {
console.error(`[GitDiff] JSON parse error. Full response: ${response_data}`);
reject(new Error('Invalid JSON response'));
}
});
});
req.on('error', (err) => {
console.error(`[GitDiff] Request error:`, err);
reject(err);
});
req.write(body_str);
req.end();
});
}
dispose() {
this.added_decoration.dispose();
this.modified_decoration.dispose();
this.deleted_decoration.dispose();
}
}

View File

@@ -0,0 +1,431 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';
import * as crypto from 'crypto';
import { URL } from 'url';
interface AuthData {
session: string;
client_key: string;
server_key: string;
}
/**
* Git status provider that fetches status from IDE service
* Colors files based on git status without using local git
*/
export class GitStatusProvider implements vscode.FileDecorationProvider {
private _onDidChangeFileDecorations: vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined> =
new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
public readonly onDidChangeFileDecorations: vscode.Event<vscode.Uri | vscode.Uri[] | undefined> =
this._onDidChangeFileDecorations.event;
private git_status: Map<string, 'modified' | 'added' | 'conflict'> = new Map();
private rspade_root: string | undefined;
private auth_data: AuthData | null = null;
private server_url: string | null = null;
private cached_protocol: 'https:' | 'http:' | null = null;
private retry_timer: NodeJS.Timeout | null = null;
private retry_interval: number = 60000; // 60 seconds
private domain_file_watcher: fs.FSWatcher | null = null;
constructor(rspade_root: string | undefined) {
this.rspade_root = rspade_root;
// Initial fetch
this.refresh_git_status();
// Fetch on file save
vscode.workspace.onDidSaveTextDocument(() => {
this.refresh_git_status();
});
// Fetch on window focus
vscode.window.onDidChangeWindowState(e => {
if (e.focused) {
this.refresh_git_status();
}
});
// Watch domain.txt for changes
this.setup_domain_file_watcher();
}
async refresh_git_status() {
if (!this.rspade_root) {
return;
}
try {
const response = await this.make_authenticated_request('/git', {});
if (response.success) {
// Build new status map from response
const new_status = new Map<string, 'modified' | 'added' | 'conflict'>();
for (const file of response.modified || []) {
new_status.set(file, 'modified');
}
for (const file of response.added || []) {
new_status.set(file, 'added');
}
for (const file of response.conflicts || []) {
new_status.set(file, 'conflict');
}
// Collect URIs that need decoration updates
const changed_uris: vscode.Uri[] = [];
// First pass: update or remove existing tracked files
for (const [file_path, old_status] of this.git_status.entries()) {
const new_file_status = new_status.get(file_path);
// File changed status or was removed from git status
if (new_file_status !== old_status) {
const file_uri = vscode.Uri.file(path.join(this.rspade_root!, file_path));
changed_uris.push(file_uri);
if (new_file_status === undefined) {
// File no longer in git status - remove it
this.git_status.delete(file_path);
} else {
// File changed status - update it
this.git_status.set(file_path, new_file_status);
}
}
}
// Second pass: add newly tracked files
for (const [file_path, status] of new_status.entries()) {
if (!this.git_status.has(file_path)) {
this.git_status.set(file_path, status);
const file_uri = vscode.Uri.file(path.join(this.rspade_root!, file_path));
changed_uris.push(file_uri);
}
}
// Only fire decoration updates for files that actually changed
if (changed_uris.length > 0) {
this._onDidChangeFileDecorations.fire(changed_uris);
}
}
} catch (error) {
console.error('[GitStatus] Failed to fetch status:', error);
}
}
private async get_server_url(): Promise<string> {
if (this.server_url) {
return this.server_url;
}
// Check VS Code settings
const config = vscode.workspace.getConfiguration('rspade');
const configured_url = config.get<string>('serverUrl');
if (configured_url) {
this.server_url = await this.negotiate_protocol(configured_url);
return this.server_url;
}
// Try to auto-discover from domain.txt
const domain_file = path.join(this.rspade_root!, 'storage', 'rsx-ide-bridge', 'domain.txt');
if (fs.existsSync(domain_file)) {
const domain = fs.readFileSync(domain_file, 'utf8').trim();
this.server_url = await this.negotiate_protocol(domain);
return this.server_url;
}
// domain.txt doesn't exist yet - schedule retry
this.schedule_retry();
throw new Error('Server URL not configured - waiting for domain.txt');
}
private async negotiate_protocol(url_or_hostname: string): Promise<string> {
// Parse the input to extract hostname
let hostname: string;
// If it looks like a URL, parse it
if (url_or_hostname.includes('://')) {
const parsed = new URL(url_or_hostname);
hostname = parsed.hostname;
// If we have a cached protocol from previous successful connection, use it
if (this.cached_protocol) {
console.log(`[GitStatus] Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
} else {
hostname = url_or_hostname;
}
// If we have a cached protocol, use it
if (this.cached_protocol) {
console.log(`[GitStatus] Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
// Try HTTPS first
console.log(`[GitStatus] Negotiating protocol for: ${hostname}`);
const https_url = `https://${hostname}`;
if (await this.test_connection(https_url)) {
console.log(`[GitStatus] HTTPS connection successful`);
this.cached_protocol = 'https:';
return https_url;
}
console.log(`[GitStatus] HTTPS failed, trying HTTP...`);
const http_url = `http://${hostname}`;
if (await this.test_connection(http_url)) {
console.log(`[GitStatus] HTTP connection successful`);
this.cached_protocol = 'http:';
return http_url;
}
throw new Error(`Unable to connect to ${hostname} via HTTPS or HTTP`);
}
private async test_connection(base_url: string): Promise<boolean> {
try {
const parsed_url = new URL(base_url);
const is_https = parsed_url.protocol === 'https:';
const port = parsed_url.port || (is_https ? 443 : 80);
const test_path = '/_ide/service/auth/create';
return new Promise<boolean>((resolve) => {
const options = {
hostname: parsed_url.hostname,
port: port,
path: test_path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '2'
},
timeout: 5000
};
const client = is_https ? https : http;
const req = client.request(options, (res) => {
// 200 OK or 301/302 redirect means server is responding
// We'll handle redirects by just noting protocol works
const success = res.statusCode === 200 || res.statusCode === 301 || res.statusCode === 302;
// If we got 301/302, it means we should probably use the other protocol
// But we'll return false to try the other one
if (res.statusCode === 301 || res.statusCode === 302) {
console.log(`[GitStatus] Got redirect ${res.statusCode} from ${base_url}`);
resolve(false);
} else {
resolve(success);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.write('{}');
req.end();
});
} catch (error) {
return false;
}
}
private setup_domain_file_watcher() {
if (!this.rspade_root) {
return;
}
const domain_file = path.join(this.rspade_root, 'storage', 'rsx-ide-bridge', 'domain.txt');
const domain_dir = path.dirname(domain_file);
// Watch the directory (file might not exist yet)
if (fs.existsSync(domain_dir)) {
try {
this.domain_file_watcher = fs.watch(domain_dir, (eventType, filename) => {
if (filename === 'domain.txt' && eventType === 'change') {
console.log('[GitStatus] domain.txt changed, clearing cached URL and protocol');
this.server_url = null;
this.cached_protocol = null;
this.auth_data = null;
// Cancel any pending retries
if (this.retry_timer) {
clearTimeout(this.retry_timer);
this.retry_timer = null;
}
// Refresh git status with new URL
this.refresh_git_status();
}
});
} catch (error) {
console.error('[GitStatus] Failed to set up domain file watcher:', error);
}
}
}
private schedule_retry() {
// Don't schedule multiple retries
if (this.retry_timer) {
return;
}
console.log(`[GitStatus] Scheduling retry in ${this.retry_interval / 1000} seconds...`);
this.retry_timer = setTimeout(() => {
console.log('[GitStatus] Retrying git status fetch...');
this.retry_timer = null;
this.refresh_git_status();
}, this.retry_interval);
}
private async ensure_auth(): Promise<void> {
if (this.auth_data) {
return;
}
// Get server URL
await this.get_server_url();
// Create new auth session
const response = await this.make_request('/auth/create', {}, 'POST', false);
if (!response.success) {
throw new Error('Failed to create auth session');
}
this.auth_data = {
session: response.session,
client_key: response.client_key,
server_key: response.server_key
};
}
private async make_authenticated_request(endpoint: string, data: any): Promise<any> {
// Ensure we have auth
if (!this.auth_data) {
await this.ensure_auth();
}
const body = JSON.stringify(data);
const signature = crypto.createHash('sha1').update(body + this.auth_data!.client_key).digest('hex');
try {
const response = await this.make_request(endpoint, data, 'POST', true, {
'X-Session': this.auth_data!.session,
'X-Signature': signature
});
return response;
} catch (error: any) {
// If session lost, retry once with new auth
const error_msg = error.message || '';
if (error_msg.includes('Session not found') || error_msg.includes('Invalid signature')) {
this.auth_data = null;
await this.ensure_auth();
const body = JSON.stringify(data);
const signature = crypto.createHash('sha1').update(body + this.auth_data!.client_key).digest('hex');
return await this.make_request(endpoint, data, 'POST', true, {
'X-Session': this.auth_data!.session,
'X-Signature': signature
});
}
throw error;
}
}
private make_request(endpoint: string, data: any, method: string, signed: boolean, extra_headers: any = {}): Promise<any> {
return new Promise(async (resolve, reject) => {
const server_url = await this.get_server_url();
const parsed_url = new URL(server_url);
const is_https = parsed_url.protocol === 'https:';
const port = parsed_url.port || (is_https ? 443 : 80);
const full_path = `/_ide/service${endpoint}`;
const body_str = JSON.stringify(data);
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body_str),
...extra_headers
};
const options = {
hostname: parsed_url.hostname,
port: port,
path: full_path,
method: method,
headers: headers
};
console.log(`[GitStatus] Making request to: ${parsed_url.protocol}//${parsed_url.hostname}:${port}${full_path}`);
console.log(`[GitStatus] Server URL from config/file: ${server_url}`);
const client = is_https ? https : http;
const req = client.request(options, (res) => {
console.log(`[GitStatus] Response status: ${res.statusCode}`);
let response_data = '';
res.on('data', chunk => response_data += chunk);
res.on('end', () => {
console.log(`[GitStatus] Response body: ${response_data.substring(0, 200)}`);
try {
const parsed = JSON.parse(response_data);
resolve(parsed);
} catch (e) {
console.error(`[GitStatus] JSON parse error. Full response: ${response_data}`);
reject(new Error('Invalid JSON response'));
}
});
});
req.on('error', (err) => {
console.error(`[GitStatus] Request error:`, err);
reject(err);
});
req.write(body_str);
req.end();
});
}
provideFileDecoration(uri: vscode.Uri): vscode.ProviderResult<vscode.FileDecoration> {
if (!this.rspade_root) {
return undefined;
}
// Get path relative to project root
const relative_path = path.relative(this.rspade_root, uri.fsPath).replace(/\\/g, '/');
const status = this.git_status.get(relative_path);
if (!status) {
return undefined;
}
// Return decoration based on status
if (status === 'conflict') {
const decoration = new vscode.FileDecoration();
decoration.color = new vscode.ThemeColor('charts.red');
return decoration;
} else if (status === 'modified' || status === 'added') {
const decoration = new vscode.FileDecoration();
decoration.color = new vscode.ThemeColor('gitDecoration.modifiedResourceForeground');
return decoration;
}
return undefined;
}
}

View File

@@ -0,0 +1,577 @@
/**
* RSpade IDE Bridge Client
*
* Centralized client for communicating with RSpade framework IDE helper endpoints.
*
* AUTO-DISCOVERY SYSTEM:
* 1. Server creates storage/rsx-ide-bridge/domain.txt on first web request
* 2. Client reads domain.txt to discover server URL
* 3. Falls back to VS Code setting: rspade.serverUrl
* 4. Auto-retries with refreshed URL on connection failure
*
* AUTHENTICATION FLOW:
* 1. Client requests session from /_idehelper/auth/create
* 2. Server generates session ID, client_key, server_key
* 3. Client signs requests: SHA1(body + client_key)
* 4. Server validates signature and signs response: SHA1(body + server_key)
* 5. Client validates server response signature
*
* ERROR HANDLING:
* - ALWAYS fails loud with descriptive errors
* - NO silent fallbacks
* - Logs all activity to output channel
* - Shows status bar notifications for user visibility
*
* USAGE FOR NEW IDE HELPER INTEGRATIONS:
*
* ```typescript
* import { IdeBridgeClient } from './ide_bridge_client';
*
* // Create client instance (pass output channel for logging)
* const client = new IdeBridgeClient(output_channel);
*
* // Make request to IDE helper endpoint
* const response = await client.request('/_idehelper/your_endpoint', {
* param1: 'value1',
* param2: 'value2'
* });
*
* // Client handles:
* // - Auto-discovery of server URL
* // - Authentication session creation
* // - Request signing
* // - Response validation
* // - Auto-retry on connection/auth failure
* // - Comprehensive error reporting
* ```
*
* ADDING NEW IDE HELPER ENDPOINTS:
*
* Backend (PHP):
* 1. Add method to /app/RSpade/Ide/Helper/Ide_Helper_Controller.php
* 2. Register route in /routes/web.php:
* Route::get('/_idehelper/your_endpoint', [Ide_Helper_Controller::class, 'your_method']);
* 3. Return JsonResponse with data
*
* Frontend (TypeScript):
* 1. Use IdeBridgeClient.request() to call endpoint
* 2. NO hardcoded URLs
* 3. NO silent error handling
* 4. Let client handle all auth/retry/discovery logic
*/
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import * as https from 'https';
import * as http from 'http';
import * as crypto from 'crypto';
import { promisify } from 'util';
const read_file = promisify(fs.readFile);
const exists = promisify(fs.exists);
interface AuthData {
session: string;
client_key: string;
server_key: string;
}
export class IdeBridgeClient {
private auth_data: AuthData | null = null;
private server_url: string | null = null;
private cached_protocol: 'https:' | 'http:' | null = null;
private retry_timer: NodeJS.Timeout | null = null;
private retry_interval: number = 60000; // 60 seconds
private domain_file_watcher: fs.FSWatcher | null = null;
private output_channel: vscode.OutputChannel;
private status_bar_item: vscode.StatusBarItem | undefined;
constructor(output_channel?: vscode.OutputChannel) {
this.output_channel = output_channel || vscode.window.createOutputChannel('RSpade IDE Bridge');
this.output_channel.appendLine('=== RSpade IDE Bridge Client Initialized ===');
this.output_channel.appendLine(`Time: ${new Date().toISOString()}`);
// Watch domain.txt for changes
this.setup_domain_file_watcher();
}
/**
* Make authenticated request to IDE helper endpoint
* Handles auto-discovery, authentication, signing, and retry logic
*/
public async request(endpoint: string, data: any = {}, method: string = 'GET'): Promise<any> {
this.output_channel.appendLine(`\n=== IDE BRIDGE REQUEST ===`);
this.output_channel.appendLine(`Endpoint: ${endpoint}`);
this.output_channel.appendLine(`Method: ${method}`);
this.output_channel.appendLine(`Time: ${new Date().toISOString()}`);
return this.make_request_with_retry(endpoint, data, method, 0);
}
private async make_request_with_retry(
endpoint: string,
data: any,
method: string,
retry_count: number
): Promise<any> {
if (retry_count > 0) {
this.output_channel.appendLine(`\n--- RETRY ATTEMPT ${retry_count} ---`);
}
// Ensure we have server URL (force refresh on retry)
await this.discover_server_url(retry_count > 0);
// For authenticated endpoints, ensure we have auth session
const needs_auth = !endpoint.includes('/auth/create');
if (needs_auth) {
await this.ensure_auth(retry_count > 0);
}
try {
return await this.make_http_request(endpoint, data, method, needs_auth);
} catch (error: any) {
// Only retry once
if (retry_count === 0) {
const error_msg = error.message || '';
// Session expired or signature invalid - recreate session
if (error_msg.includes('Session not found') || error_msg.includes('Invalid signature')) {
this.output_channel.appendLine('Session/signature error, recreating session...');
this.auth_data = null;
return this.make_request_with_retry(endpoint, data, method, retry_count + 1);
}
// Connection failure - server URL might have changed
if (error_msg.includes('ECONNREFUSED') || error_msg.includes('ENOTFOUND') ||
error_msg.includes('ETIMEDOUT') || error_msg.includes('getaddrinfo')) {
this.output_channel.appendLine('Connection failed, refreshing server URL...');
this.auth_data = null;
this.server_url = null;
return this.make_request_with_retry(endpoint, data, method, retry_count + 1);
}
}
// Not recoverable or already retried - fail loud
this.show_error_status('IDE Bridge request failed');
throw error;
}
}
private async make_http_request(
endpoint: string,
data: any,
method: string,
needs_auth: boolean
): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.server_url) {
const error = new Error('Server URL not configured');
this.output_channel.appendLine(`ERROR: ${error.message}`);
reject(error);
return;
}
const url = new URL(this.server_url);
const is_https = url.protocol === 'https:';
const http_module = is_https ? https : http;
// For GET requests, encode data as query string
let body = '';
let full_path = endpoint;
if (method === 'GET' && Object.keys(data).length > 0) {
const params = new URLSearchParams(data);
full_path += (endpoint.includes('?') ? '&' : '?') + params.toString();
} else if (method === 'POST') {
body = JSON.stringify(data);
}
const headers: any = {
'Content-Type': 'application/json'
};
if (body) {
headers['Content-Length'] = Buffer.byteLength(body);
}
// Add authentication headers if needed
if (needs_auth && this.auth_data) {
const signature = crypto.createHash('sha1')
.update(body + this.auth_data.client_key)
.digest('hex');
headers['X-Session'] = this.auth_data.session;
headers['X-Signature'] = signature;
this.output_channel.appendLine(`Session: ${this.auth_data.session}`);
this.output_channel.appendLine(`Signature: ${signature}`);
}
const options: https.RequestOptions = {
hostname: url.hostname,
port: url.port || (is_https ? 443 : 80),
path: full_path,
method: method,
headers: headers,
timeout: 30000,
rejectUnauthorized: false
};
this.output_channel.appendLine(`Full URL: ${is_https ? 'https' : 'http'}://${options.hostname}:${options.port}${options.path}`);
const start_time = Date.now();
const req = http_module.request(options, (res) => {
let response_data = '';
this.output_channel.appendLine(`\n--- HTTP RESPONSE ---`);
this.output_channel.appendLine(`Status: ${res.statusCode}`);
res.on('data', (chunk) => {
response_data += chunk;
});
res.on('end', () => {
const elapsed = Date.now() - start_time;
this.output_channel.appendLine(`Response time: ${elapsed}ms`);
this.output_channel.appendLine(`Response size: ${response_data.length} bytes`);
try {
// Validate server signature if authenticated request
if (needs_auth && this.auth_data) {
const response_signature = res.headers['x-signature'] as string;
this.output_channel.appendLine(`\n--- SIGNATURE VALIDATION ---`);
this.output_channel.appendLine(`Response signature: ${response_signature || 'MISSING'}`);
if (response_signature) {
const expected_signature = crypto.createHash('sha1')
.update(response_data + this.auth_data.server_key)
.digest('hex');
this.output_channel.appendLine(`Expected signature: ${expected_signature}`);
this.output_channel.appendLine(`Validation: ${response_signature === expected_signature ? 'PASSED' : 'FAILED'}`);
if (response_signature !== expected_signature) {
const error = new Error('Invalid server signature');
this.output_channel.appendLine(`ERROR: ${error.message}`);
reject(error);
return;
}
}
}
const response = JSON.parse(response_data);
if (res.statusCode !== 200) {
const error = new Error(response.error || `HTTP ${res.statusCode}`);
this.output_channel.appendLine(`ERROR: ${error.message}`);
reject(error);
} else {
this.output_channel.appendLine('Result: SUCCESS');
this.clear_status_bar();
resolve(response);
}
} catch (e: any) {
const error = new Error(`Failed to parse response: ${e.message}`);
this.output_channel.appendLine(`ERROR: ${error.message}`);
reject(error);
}
});
});
req.on('error', (error) => {
const elapsed = Date.now() - start_time;
this.output_channel.appendLine(`\nERROR after ${elapsed}ms: ${error.message}`);
reject(error);
});
req.on('timeout', () => {
this.output_channel.appendLine('\nERROR: Request timed out after 30 seconds');
req.destroy();
reject(new Error('Request timed out'));
});
if (body) {
req.write(body);
}
req.end();
});
}
private async ensure_auth(force_new: boolean = false): Promise<void> {
if (this.auth_data && !force_new) {
this.output_channel.appendLine(`Using existing auth session: ${this.auth_data.session}`);
return;
}
this.output_channel.appendLine('\n--- AUTHENTICATION ---');
this.output_channel.appendLine('Creating new auth session...');
// Request new session (this endpoint doesn't require auth)
const response = await this.make_http_request('/auth/create', {}, 'POST', false);
if (!response.success) {
throw new Error('Failed to create auth session');
}
this.auth_data = {
session: response.session,
client_key: response.client_key,
server_key: response.server_key
};
this.output_channel.appendLine(`✅ Auth session created successfully!`);
this.output_channel.appendLine(` Server: ${this.server_url}`);
this.output_channel.appendLine(` Session ID: ${this.auth_data.session}`);
this.output_channel.appendLine(` Client Key: ${this.auth_data.client_key.substring(0, 8)}...`);
this.output_channel.appendLine(` Server Key: ${this.auth_data.server_key.substring(0, 8)}...`);
}
private async discover_server_url(force_refresh: boolean = false): Promise<void> {
if (this.server_url && !force_refresh) {
this.output_channel.appendLine(`Using cached server URL: ${this.server_url}`);
return;
}
this.output_channel.appendLine('\n--- SERVER URL DISCOVERY ---');
if (force_refresh) {
this.output_channel.appendLine('Re-checking server URL due to connection failure...');
}
// Check VS Code settings first
const config = vscode.workspace.getConfiguration('rspade');
const configured_url = config.get<string>('serverUrl');
this.output_channel.appendLine(`VS Code setting: ${configured_url ? 'found' : 'not found'}`);
if (configured_url) {
this.server_url = await this.negotiate_protocol(configured_url);
this.output_channel.appendLine(`✅ Using configured server URL: ${this.server_url}`);
return;
}
// Auto-discover from domain file
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
this.show_detailed_error();
throw new Error('RSpade project root not found');
}
const domain_file = path.join(rspade_root, 'storage', 'rsx-ide-bridge', 'domain.txt');
this.output_channel.appendLine(`Checking for domain file: ${domain_file}`);
if (await exists(domain_file)) {
const domain = (await read_file(domain_file, 'utf8')).trim();
this.server_url = await this.negotiate_protocol(domain);
this.output_channel.appendLine(`✅ Auto-discovered server URL from domain.txt: ${this.server_url}`);
return;
}
// domain.txt doesn't exist yet - schedule retry
this.schedule_retry();
this.show_detailed_error();
throw new Error('RSpade: storage/rsx-ide-bridge/domain.txt not found. Please load site in browser or configure server URL.');
}
private async negotiate_protocol(url_or_hostname: string): Promise<string> {
// Parse the input to extract hostname
let hostname: string;
// If it looks like a URL, parse it
if (url_or_hostname.includes('://')) {
const parsed = new URL(url_or_hostname);
hostname = parsed.hostname;
// If we have a cached protocol from previous successful connection, use it
if (this.cached_protocol) {
this.output_channel.appendLine(`Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
} else {
hostname = url_or_hostname;
}
// If we have a cached protocol, use it
if (this.cached_protocol) {
this.output_channel.appendLine(`Using cached protocol: ${this.cached_protocol}`);
return `${this.cached_protocol}//${hostname}`;
}
// Try HTTPS first
this.output_channel.appendLine(`Negotiating protocol for: ${hostname}`);
const https_url = `https://${hostname}`;
if (await this.test_connection(https_url)) {
this.output_channel.appendLine(`✅ HTTPS connection successful`);
this.cached_protocol = 'https:';
return https_url;
}
this.output_channel.appendLine(`HTTPS failed, trying HTTP...`);
const http_url = `http://${hostname}`;
if (await this.test_connection(http_url)) {
this.output_channel.appendLine(`✅ HTTP connection successful`);
this.cached_protocol = 'http:';
return http_url;
}
throw new Error(`Unable to connect to ${hostname} via HTTPS or HTTP`);
}
private async test_connection(base_url: string): Promise<boolean> {
try {
const parsed_url = new URL(base_url);
const is_https = parsed_url.protocol === 'https:';
const port = parsed_url.port || (is_https ? 443 : 80);
const test_path = '/_ide/service/auth/create';
return new Promise<boolean>((resolve) => {
const options = {
hostname: parsed_url.hostname,
port: port,
path: test_path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '2'
},
timeout: 5000,
rejectUnauthorized: false
};
const client = is_https ? https : http;
const req = client.request(options, (res) => {
// 200 OK means success
// 301/302 redirect means we should try the other protocol
if (res.statusCode === 301 || res.statusCode === 302) {
this.output_channel.appendLine(`Got redirect ${res.statusCode} from ${base_url}`);
resolve(false);
} else {
resolve(res.statusCode === 200);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.write('{}');
req.end();
});
} catch (error) {
return false;
}
}
private setup_domain_file_watcher() {
const rspade_root = this.find_rspade_root();
if (!rspade_root) {
return;
}
const domain_file = path.join(rspade_root, 'storage', 'rsx-ide-bridge', 'domain.txt');
const domain_dir = path.dirname(domain_file);
// Watch the directory (file might not exist yet)
if (fs.existsSync(domain_dir)) {
try {
this.domain_file_watcher = fs.watch(domain_dir, (eventType, filename) => {
if (filename === 'domain.txt' && eventType === 'change') {
this.output_channel.appendLine('[IdeBridge] domain.txt changed, clearing cached URL and protocol');
this.server_url = null;
this.cached_protocol = null;
this.auth_data = null;
// Cancel any pending retries
if (this.retry_timer) {
clearTimeout(this.retry_timer);
this.retry_timer = null;
}
}
});
} catch (error) {
this.output_channel.appendLine(`Failed to set up domain file watcher: ${error}`);
}
}
}
private schedule_retry() {
// Don't schedule multiple retries
if (this.retry_timer) {
return;
}
this.output_channel.appendLine(`Scheduling retry in ${this.retry_interval / 1000} seconds...`);
this.retry_timer = setTimeout(() => {
this.output_channel.appendLine('Retrying server URL discovery...');
this.retry_timer = null;
this.server_url = null;
// Next request will trigger discovery
}, this.retry_interval);
}
private find_rspade_root(): string | undefined {
if (!vscode.workspace.workspaceFolders) {
return undefined;
}
for (const folder of vscode.workspace.workspaceFolders) {
// Try new structure first
const system_app_rspade = path.join(folder.uri.fsPath, 'system', 'app', 'RSpade');
if (fs.existsSync(system_app_rspade)) {
return path.join(folder.uri.fsPath, 'system');
}
// Fall back to legacy structure
const app_rspade = path.join(folder.uri.fsPath, 'app', 'RSpade');
if (fs.existsSync(app_rspade)) {
return folder.uri.fsPath;
}
}
return undefined;
}
private show_error_status(message: string) {
if (!this.status_bar_item) {
this.status_bar_item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0);
this.status_bar_item.command = 'workbench.action.output.toggleOutput';
this.status_bar_item.tooltip = 'Click to view RSpade output';
}
this.status_bar_item.text = `$(error) RSpade: ${message}`;
this.status_bar_item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
this.status_bar_item.show();
setTimeout(() => {
this.clear_status_bar();
}, 5000);
}
private clear_status_bar() {
if (this.status_bar_item) {
this.status_bar_item.hide();
}
}
private show_detailed_error() {
this.output_channel.appendLine('\n════════════════════════════════════════════════════════');
this.output_channel.appendLine('ERROR: RSpade Extension cannot connect to server');
this.output_channel.appendLine('════════════════════════════════════════════════════════');
this.output_channel.appendLine('\nThe extension needs to know your development server URL.');
this.output_channel.appendLine('\nPlease do ONE of the following:\n');
this.output_channel.appendLine('1. Load your site in a web browser');
this.output_channel.appendLine(' This will auto-create: storage/rsx-ide-bridge/domain.txt\n');
this.output_channel.appendLine('2. Set VS Code setting: rspade.serverUrl');
this.output_channel.appendLine(' File → Preferences → Settings → Search "rspade"');
this.output_channel.appendLine(' Set to your development URL (e.g., https://myapp.test)\n');
this.output_channel.appendLine('3. Enable IDE integration in Laravel config');
this.output_channel.appendLine(' In config/rsx.php, set ide_integration.enabled = true');
this.output_channel.appendLine('\n════════════════════════════════════════════════════════');
}
}

View File

@@ -0,0 +1,527 @@
import * as vscode from 'vscode';
import { IdeBridgeClient } from './ide_bridge_client';
/**
* JQHTML lifecycle methods that are called automatically by the framework
*/
const JQHTML_LIFECYCLE_METHODS = ['on_render', 'on_create', 'on_load', 'on_ready', 'on_destroy'];
/**
* Convention methods that are called automatically by the RSX framework
*/
const CONVENTION_METHODS = [
'_on_framework_core_define',
'_on_framework_modules_define',
'_on_framework_core_init',
'on_app_modules_define',
'on_app_define',
'_on_framework_modules_init',
'on_app_modules_init',
'on_app_init',
'on_app_ready',
'on_jqhtml_ready'
];
/**
* Lifecycle method documentation
*/
const LIFECYCLE_DOCS: { [key: string]: string } = {
on_render: 'Initial render phase - template DOM created. Parent completes before children. DOM manipulation allowed. MUST be synchronous.',
on_create: 'Quick setup after DOM exists. Children complete before parent. DOM manipulation allowed. MUST be synchronous.',
on_load: 'Data loading phase - fetch async data. NO DOM manipulation allowed, only update `this.data`. Template re-renders after load. MUST be async.',
on_ready: 'Final setup phase - all data loaded. Children complete before parent. DOM manipulation allowed. Can be sync or async.',
on_destroy: 'Component destruction phase - cleanup resources. Called before component is removed. MUST be synchronous.',
};
/**
* Cache for lineage lookups
*/
const lineage_cache = new Map<string, string[]>();
/**
* IDE Bridge client instance (shared across all providers)
*/
let ide_bridge_client: IdeBridgeClient | null = null;
/**
* Get JavaScript class lineage from backend via IDE bridge
*/
async function get_js_lineage(class_name: string): Promise<string[]> {
// Check cache first
if (lineage_cache.has(class_name)) {
return lineage_cache.get(class_name)!;
}
// Initialize IDE bridge client if needed
if (!ide_bridge_client) {
const output_channel = vscode.window.createOutputChannel('RSpade JQHTML Lifecycle');
ide_bridge_client = new IdeBridgeClient(output_channel);
}
try {
const response = await ide_bridge_client.request('/_idehelper/js_lineage', { class: class_name });
const lineage = response.lineage || [];
// Cache the result
lineage_cache.set(class_name, lineage);
return lineage;
} catch (error: any) {
// Re-throw error to fail loud - no silent fallbacks
throw new Error(`Failed to get JS lineage for ${class_name}: ${error.message}`);
}
}
/**
* Extract class name from document text
*/
function extract_class_name(text: string): string | null {
const regex = /class\s+([A-Za-z0-9_]+)\s+extends/;
const match = regex.exec(text);
return match ? match[1] : null;
}
/**
* Check if class extends Jqhtml_Component
*/
function directly_extends_jqhtml(text: string): boolean {
const regex = /class\s+[A-Za-z0-9_]+\s+extends\s+Jqhtml_Component/;
return regex.test(text);
}
/**
* Check if class has extends clause
*/
function has_extends_clause(text: string): boolean {
const regex = /class\s+[A-Za-z0-9_]+\s+extends\s+[A-Za-z0-9_]+/;
return regex.test(text);
}
/**
* Check if a method is async
*/
function is_async_method(line_text: string): boolean {
return line_text.trim().startsWith('async ');
}
/**
* Check if position is inside a comment
*/
function is_in_comment(document: vscode.TextDocument, position: vscode.Position): boolean {
const line_text = document.lineAt(position.line).text;
const char_pos = position.character;
// Check for single-line comment
const single_comment_idx = line_text.indexOf('//');
if (single_comment_idx !== -1 && single_comment_idx < char_pos) {
return true;
}
// Check for multi-line comment by looking at text before position
const text_before = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
let in_block_comment = false;
let i = 0;
while (i < text_before.length) {
if (text_before.substring(i, i + 2) === '/*') {
in_block_comment = true;
i += 2;
} else if (text_before.substring(i, i + 2) === '*/') {
in_block_comment = false;
i += 2;
} else {
i++;
}
}
return in_block_comment;
}
/**
* Provides semantic tokens for JQHTML lifecycle methods (amber color)
*/
export class JqhtmlLifecycleSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
console.log(`[JQHTML] provideDocumentSemanticTokens called for: ${document.fileName}`);
const tokens_builder = new vscode.SemanticTokensBuilder();
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
console.log(`[JQHTML] Skipping - not JS/TS, language is: ${document.languageId}`);
return tokens_builder.build();
}
const text = document.getText();
// Quick check: does this file have an extends clause?
if (!has_extends_clause(text)) {
console.log(`[JQHTML] No extends clause found, checking for convention methods only`);
// Still process convention methods even without extends
} else {
console.log(`[JQHTML] Found extends clause`);
}
// Check if directly extends Jqhtml_Component
const is_jqhtml = directly_extends_jqhtml(text);
console.log(`[JQHTML] Directly extends Jqhtml_Component: ${is_jqhtml}`);
// If not directly extending, check lineage
let extends_jqhtml = is_jqhtml;
if (!is_jqhtml && has_extends_clause(text)) {
const class_name = extract_class_name(text);
console.log(`[JQHTML] Checking lineage for class: ${class_name}`);
if (class_name) {
const lineage = await get_js_lineage(class_name);
console.log(`[JQHTML] Lineage: ${JSON.stringify(lineage)}`);
extends_jqhtml = lineage.includes('Jqhtml_Component');
console.log(`[JQHTML] Extends Jqhtml_Component via lineage: ${extends_jqhtml}`);
}
}
// Highlight lifecycle methods (only if extends Jqhtml_Component)
if (extends_jqhtml) {
console.log(`[JQHTML] Scanning for JQHTML lifecycle methods...`);
let lifecycle_count = 0;
for (const method_name of JQHTML_LIFECYCLE_METHODS) {
const regex = new RegExp(`\\b(async\\s+)?(${method_name})\\s*\\(`, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
const method_start = match.index + (match[1] ? match[1].length : 0);
const position = document.positionAt(method_start);
// Skip if this is inside a comment
if (is_in_comment(document, position)) {
console.log(`[JQHTML] Skipping ${method_name} - inside comment`);
continue;
}
console.log(`[JQHTML] Found lifecycle method: ${method_name} at line ${position.line}`);
tokens_builder.push(position.line, position.character, method_name.length, 0, 0);
lifecycle_count++;
}
}
console.log(`[JQHTML] Total lifecycle methods highlighted: ${lifecycle_count}`);
}
// Highlight convention methods (for all classes)
console.log(`[JQHTML] Scanning for convention methods...`);
let convention_count = 0;
for (const method_name of CONVENTION_METHODS) {
const regex = new RegExp(`\\b(static\\s+)?(async\\s+)?(${method_name})\\s*\\(`, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
const prefix_length = (match[1] ? match[1].length : 0) + (match[2] ? match[2].length : 0);
const method_start = match.index + prefix_length;
const position = document.positionAt(method_start);
// Skip if this is inside a comment
if (is_in_comment(document, position)) {
console.log(`[JQHTML] Skipping ${method_name} - inside comment`);
continue;
}
console.log(`[JQHTML] Found convention method: ${method_name} at line ${position.line}`);
tokens_builder.push(position.line, position.character, method_name.length, 0, 0);
convention_count++;
}
}
console.log(`[JQHTML] Total convention methods highlighted: ${convention_count}`);
// Highlight @Instantiatable in JSDoc comments
console.log(`[JQHTML] Scanning for @Instantiatable annotations...`);
let instantiatable_count = 0;
const instantiatable_regex = /@(Instantiatable)\b/g;
let instantiatable_match;
while ((instantiatable_match = instantiatable_regex.exec(text)) !== null) {
const annotation_start = instantiatable_match.index + 1; // Skip the @ symbol
const position = document.positionAt(annotation_start);
console.log(`[JQHTML] Found @Instantiatable at line ${position.line}`);
tokens_builder.push(position.line, position.character, 'Instantiatable'.length, 0, 0);
instantiatable_count++;
}
console.log(`[JQHTML] Total @Instantiatable annotations highlighted: ${instantiatable_count}`);
const result = tokens_builder.build();
console.log(`[JQHTML] Returning ${result.data.length} semantic tokens`);
return result;
}
}
/**
* Provides hover information for JQHTML lifecycle methods
*/
export class JqhtmlLifecycleHoverProvider implements vscode.HoverProvider {
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
const word_range = document.getWordRangeAtPosition(position);
if (!word_range) {
return undefined;
}
const word = document.getText(word_range);
if (!JQHTML_LIFECYCLE_METHODS.includes(word)) {
return undefined;
}
// Check if this is a method definition
const line = document.lineAt(position.line).text;
if (!line.includes('(')) {
return undefined;
}
// Check if class extends Jqhtml_Component
const text = document.getText();
if (!has_extends_clause(text)) {
return undefined;
}
const is_jqhtml = directly_extends_jqhtml(text);
let extends_jqhtml = is_jqhtml;
if (!is_jqhtml) {
const class_name = extract_class_name(text);
if (class_name) {
const lineage = await get_js_lineage(class_name);
extends_jqhtml = lineage.includes('Jqhtml_Component');
}
}
if (!extends_jqhtml) {
return undefined;
}
const markdown = new vscode.MarkdownString();
markdown.isTrusted = true;
const is_async = is_async_method(line);
// Determine if async is required, forbidden, or optional
const must_be_sync = ['on_create', 'on_render', 'on_destroy'].includes(word);
const must_be_async = word === 'on_load';
const can_be_either = word === 'on_ready';
let has_error = false;
if (must_be_sync && is_async) {
markdown.appendMarkdown(`**⚠️ Incorrect Async Declaration**\n\n`);
markdown.appendMarkdown(`\`${word}\` must be synchronous - remove 'async' keyword.\n\n`);
has_error = true;
} else if (must_be_async && !is_async) {
markdown.appendMarkdown(`**⚠️ Missing Async Declaration**\n\n`);
markdown.appendMarkdown(`\`${word}\` must be async - add 'async' keyword.\n\n`);
has_error = true;
}
if (!has_error) {
markdown.appendMarkdown(`**JQHTML Lifecycle Method**\n\n`);
}
markdown.appendMarkdown(`${LIFECYCLE_DOCS[word]}\n\n`);
markdown.appendMarkdown(`Run \`php artisan rsx:man jqhtml\` for more information.`);
return new vscode.Hover(markdown, word_range);
}
}
/**
* Diagnostic provider for non-async lifecycle methods
*/
export class JqhtmlLifecycleDiagnosticProvider {
private diagnostics_collection: vscode.DiagnosticCollection;
private document_cache = new Map<string, boolean>();
constructor() {
this.diagnostics_collection = vscode.languages.createDiagnosticCollection('rspade-jqhtml');
}
activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((event) => {
this.update_diagnostics(event.document);
})
);
vscode.workspace.textDocuments.forEach((document) => {
this.update_diagnostics(document);
});
context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((document) => {
this.update_diagnostics(document);
})
);
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((document) => {
// Clear cache on save to force lineage re-check
this.document_cache.delete(document.uri.toString());
this.update_diagnostics(document);
})
);
}
private async update_diagnostics(document: vscode.TextDocument) {
if (document.languageId !== 'javascript' && document.languageId !== 'typescript') {
return;
}
const text = document.getText();
if (!has_extends_clause(text)) {
return;
}
// Check cache
const cache_key = document.uri.toString();
let extends_jqhtml = this.document_cache.get(cache_key);
if (extends_jqhtml === undefined) {
const is_jqhtml = directly_extends_jqhtml(text);
extends_jqhtml = is_jqhtml;
if (!is_jqhtml) {
const class_name = extract_class_name(text);
if (class_name) {
const lineage = await get_js_lineage(class_name);
extends_jqhtml = lineage.includes('Jqhtml_Component');
}
}
this.document_cache.set(cache_key, extends_jqhtml);
}
if (!extends_jqhtml) {
return;
}
const diagnostics: vscode.Diagnostic[] = [];
const lines = text.split('\n');
// Find each lifecycle method and validate
for (const method_name of JQHTML_LIFECYCLE_METHODS) {
// Find method definition and extract its body
const method_regex = new RegExp(`^\\s*(static\\s+)?(async\\s+)?(${method_name})\\s*\\([^)]*\\)\\s*\\{`, 'gm');
let method_match;
while ((method_match = method_regex.exec(text)) !== null) {
const is_static = !!method_match[1];
const is_async = !!method_match[2];
const method_start_index = method_match.index + method_match[0].indexOf(method_name);
const method_start_pos = document.positionAt(method_start_index);
const method_end_pos = document.positionAt(method_start_index + method_name.length);
const method_name_range = new vscode.Range(method_start_pos, method_end_pos);
// Check if method is static (should not be)
if (is_static) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`JQHTML lifecycle method '${method_name}' should not be static`,
vscode.DiagnosticSeverity.Warning
)
);
}
// Check async requirements
if (method_name === 'on_create' && is_async) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`'on_create' must be synchronous - remove 'async' keyword`,
vscode.DiagnosticSeverity.Error
)
);
} else if (method_name === 'on_render' && is_async) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`'on_render' must be synchronous - remove 'async' keyword`,
vscode.DiagnosticSeverity.Error
)
);
} else if (method_name === 'on_destroy' && is_async) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`'on_destroy' must be synchronous - remove 'async' keyword`,
vscode.DiagnosticSeverity.Error
)
);
} else if (method_name === 'on_load' && !is_async) {
diagnostics.push(
new vscode.Diagnostic(
method_name_range,
`'on_load' must be async - add 'async' keyword`,
vscode.DiagnosticSeverity.Error
)
);
}
// on_ready can be either async or non-async - no requirement
// Find method body to check for violations
const method_body_start = method_match.index + method_match[0].length;
let brace_count = 1;
let body_end = method_body_start;
for (let i = method_body_start; i < text.length && brace_count > 0; i++) {
if (text[i] === '{') brace_count++;
if (text[i] === '}') brace_count--;
if (brace_count === 0) {
body_end = i;
break;
}
}
const method_body = text.substring(method_body_start, body_end);
// Check for violations in method body
if (method_name === 'on_create') {
// Check for this.data or that.data access
const data_access_regex = /(this|that)\.data/g;
let data_match;
while ((data_match = data_access_regex.exec(method_body)) !== null) {
const violation_pos = document.positionAt(method_body_start + data_match.index);
const violation_end = document.positionAt(method_body_start + data_match.index + data_match[0].length);
diagnostics.push(
new vscode.Diagnostic(
new vscode.Range(violation_pos, violation_end),
`'${data_match[0]}' is populated during on_load, which happens after on_create. Did you mean ${data_match[1]}.args?`,
vscode.DiagnosticSeverity.Warning
)
);
}
}
if (method_name === 'on_load') {
// Check for DOM access: this.$, this.$id, that.$, that.$id
const dom_access_regex = /(this|that)\.\$(?:id)?/g;
let dom_match;
while ((dom_match = dom_access_regex.exec(method_body)) !== null) {
const violation_pos = document.positionAt(method_body_start + dom_match.index);
const violation_end = document.positionAt(method_body_start + dom_match.index + dom_match[0].length);
diagnostics.push(
new vscode.Diagnostic(
new vscode.Range(violation_pos, violation_end),
`'on_load' should not access DOM elements. It should be headless, using only ${dom_match[1]}.args for inputs and setting ${dom_match[1]}.data for outputs`,
vscode.DiagnosticSeverity.Warning
)
);
}
}
}
}
this.diagnostics_collection.set(document.uri, diagnostics);
}
dispose() {
this.diagnostics_collection.dispose();
}
}

View File

@@ -0,0 +1,289 @@
import * as vscode from 'vscode';
export class LaravelCompletionProvider implements vscode.CompletionItemProvider {
private laravel_functions: Map<string, CompletionItemData> = new Map();
constructor() {
this.initialize_laravel_functions();
}
private initialize_laravel_functions() {
// Path Helpers
this.add_function('base_path', 'base_path(string $path = \'\'): string',
'Get the base path of the Laravel installation. Optionally append a path.');
this.add_function('app_path', 'app_path(string $path = \'\'): string',
'Get the path to the app folder. Optionally append a path.');
this.add_function('config_path', 'config_path(string $path = \'\'): string',
'Get the configuration path. Optionally append a path.');
this.add_function('database_path', 'database_path(string $path = \'\'): string',
'Get the database path. Optionally append a path.');
this.add_function('public_path', 'public_path(string $path = \'\'): string',
'Get the public path. Optionally append a path.');
this.add_function('resource_path', 'resource_path(string $path = \'\'): string',
'Get the path to the resources folder. Optionally append a path.');
this.add_function('storage_path', 'storage_path(string $path = \'\'): string',
'Get the path to the storage folder. Optionally append a path.');
// Environment & Config
this.add_function('env', 'env(string $key, mixed $default = null): mixed',
'Get the value of an environment variable. Supports a default value.');
this.add_function('config', 'config(string|array|null $key = null, mixed $default = null): mixed',
'Get/set a configuration value. Pass an array to set multiple values.');
this.add_function('app', 'app(string|null $abstract = null, array $parameters = []): mixed',
'Get the available container instance or resolve a type from the container.');
// URL & Asset Helpers
this.add_function('url', 'url(string|null $path = null, mixed $parameters = [], bool|null $secure = null): string',
'Generate a URL for the application.');
this.add_function('asset', 'asset(string $path, bool|null $secure = null): string',
'Generate an asset URL.');
this.add_function('secure_asset', 'secure_asset(string $path): string',
'Generate an asset URL using HTTPS.');
this.add_function('route', 'route(string $name, mixed $parameters = [], bool $absolute = true): string',
'Generate the URL to a named route.');
this.add_function('mix', 'mix(string $path, string $manifestDirectory = \'\'): string',
'Get the path to a versioned Mix file.');
// Request & Response
this.add_function('request', 'request(array|string|null $key = null, mixed $default = null): mixed',
'Get an instance of the current request or an input item from the request.');
this.add_function('response', 'response(mixed $content = \'\', int $status = 200, array $headers = []): mixed',
'Create a new response instance.');
this.add_function('redirect', 'redirect(string|null $to = null, int $status = 302, array $headers = [], bool|null $secure = null): mixed',
'Get an instance of the redirector or create a new redirect response.');
this.add_function('back', 'back(int $status = 302, array $headers = [], mixed $fallback = false): mixed',
'Create a new redirect response to the previous location.');
this.add_function('abort', 'abort(int $code, string $message = \'\', array $headers = []): never',
'Throw an HttpException with the given data.');
this.add_function('abort_if', 'abort_if(bool $boolean, int $code, string $message = \'\', array $headers = []): void',
'Throw an HttpException with the given data if the given condition is true.');
this.add_function('abort_unless', 'abort_unless(bool $boolean, int $code, string $message = \'\', array $headers = []): void',
'Throw an HttpException with the given data unless the given condition is true.');
// Authentication & Session
this.add_function('auth', 'auth(string|null $guard = null): mixed',
'Get the available auth instance or a specific guard.');
this.add_function('session', 'session(array|string|null $key = null, mixed $default = null): mixed',
'Get/set the specified session value.');
this.add_function('old', 'old(string|null $key = null, mixed $default = null): mixed',
'Retrieve an old input item.');
this.add_function('cookie', 'cookie(string|null $name = null, string|null $value = null, int $minutes = 0, ...): mixed',
'Create a new cookie instance.');
this.add_function('csrf_token', 'csrf_token(): string',
'Get the CSRF token value.');
this.add_function('csrf_field', 'csrf_field(): string',
'Generate a CSRF token form field.');
// Caching
this.add_function('cache', 'cache(mixed ...$arguments): mixed',
'Get/set the specified cache value.');
// Collections & Arrays
this.add_function('collect', 'collect(mixed $value = null): mixed',
'Create a collection from the given value.');
this.add_function('data_get', 'data_get(mixed $target, string|array|null $key, mixed $default = null): mixed',
'Get an item from an array or object using "dot" notation.');
this.add_function('data_set', 'data_set(mixed &$target, string|array $key, mixed $value, bool $overwrite = true): mixed',
'Set an item on an array or object using dot notation.');
this.add_function('data_forget', 'data_forget(mixed &$target, string|array $key): mixed',
'Remove an item from an array or object using "dot" notation.');
// Debugging
this.add_function('dd', 'dd(mixed ...$vars): never',
'Dump the given variables and end the script.');
this.add_function('dump', 'dump(mixed ...$vars): void',
'Dump the given variables.');
this.add_function('info', 'info(string $message, array $context = []): void',
'Write some information to the log.');
this.add_function('logger', 'logger(string|null $message = null, array $context = []): mixed',
'Log a debug message to the logs or get a logger instance.');
// Events & Jobs
this.add_function('event', 'event(mixed ...$args): mixed',
'Dispatch an event and call the listeners.');
this.add_function('dispatch', 'dispatch(mixed $job): mixed',
'Dispatch a job to its appropriate handler.');
this.add_function('dispatch_now', 'dispatch_now(mixed $job): mixed',
'Dispatch a job immediately (synchronously).');
this.add_function('dispatch_sync', 'dispatch_sync(mixed $job): mixed',
'Dispatch a job synchronously.');
// Encryption & Hashing
this.add_function('bcrypt', 'bcrypt(string $value, array $options = []): string',
'Hash the given value using Bcrypt.');
this.add_function('encrypt', 'encrypt(mixed $value, bool $serialize = true): string',
'Encrypt the given value.');
this.add_function('decrypt', 'decrypt(string $payload, bool $unserialize = true): mixed',
'Decrypt the given value.');
// Date & Time
this.add_function('now', 'now(mixed $tz = null): mixed',
'Create a new Carbon instance for the current time.');
this.add_function('today', 'today(mixed $tz = null): mixed',
'Create a new Carbon instance for the current date.');
// Translation
this.add_function('trans', 'trans(string|null $key = null, array $replace = [], string|null $locale = null): string',
'Translate the given message.');
this.add_function('trans_choice', 'trans_choice(string $key, int|array|\\Countable $number, array $replace = [], string|null $locale = null): string',
'Translate the given message with a count.');
this.add_function('__', '__(string|null $key = null, array $replace = [], string|null $locale = null): string',
'Translate the given message (alias for trans).');
// Validation
this.add_function('validator', 'validator(array $data = [], array $rules = [], array $messages = [], array $customAttributes = []): mixed',
'Create a new Validator instance.');
// Other Laravel Helpers
this.add_function('class_basename', 'class_basename(string|object $class): string',
'Get the class "basename" of the given object/class.');
this.add_function('class_uses_recursive', 'class_uses_recursive(string|object $class): array',
'Returns all traits used by a class, its parent classes and trait of their traits.');
this.add_function('trait_uses_recursive', 'trait_uses_recursive(string|object $trait): array',
'Returns all traits used by a trait and its traits.');
this.add_function('value', 'value(mixed $value, mixed ...$args): mixed',
'Return the default value of the given value.');
this.add_function('with', 'with(mixed $value, callable|null $callback = null): mixed',
'Return the given value, optionally passed through the given callback.');
this.add_function('tap', 'tap(mixed $value, callable|null $callback = null): mixed',
'Call the given Closure with the given value then return the value.');
this.add_function('blank', 'blank(mixed $value): bool',
'Determine if the given value is "blank".');
this.add_function('filled', 'filled(mixed $value): bool',
'Determine if a value is "filled".');
this.add_function('optional', 'optional(mixed $value, callable|null $callback = null): mixed',
'Provide access to optional objects.');
this.add_function('rescue', 'rescue(callable $callback, mixed $rescue = null, bool|callable $report = true): mixed',
'Catch a potential exception and return a default value.');
this.add_function('retry', 'retry(int $times, callable $callback, int|\\Closure $sleepMilliseconds = 0, callable|null $when = null): mixed',
'Retry an operation a given number of times.');
this.add_function('throw_if', 'throw_if(mixed $condition, \\Throwable|string $exception = \'RuntimeException\', mixed ...$parameters): mixed',
'Throw the given exception if the given condition is true.');
this.add_function('throw_unless', 'throw_unless(mixed $condition, \\Throwable|string $exception = \'RuntimeException\', mixed ...$parameters): mixed',
'Throw the given exception unless the given condition is true.');
this.add_function('windows_os', 'windows_os(): bool',
'Determine whether the current environment is Windows based.');
}
private add_function(name: string, signature: string, documentation: string) {
this.laravel_functions.set(name, {
name,
signature,
documentation
});
}
public provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
context: vscode.CompletionContext
): vscode.CompletionItem[] {
const line_text = document.lineAt(position).text;
const before_cursor = line_text.substring(0, position.character);
// Check if we're in a position where a function name might be typed
const function_pattern = /([a-z_][a-z0-9_]*)?$/i;
const match = before_cursor.match(function_pattern);
if (!match) {
return [];
}
const prefix = match[1] || '';
const completion_items: vscode.CompletionItem[] = [];
this.laravel_functions.forEach((func_data, func_name) => {
if (func_name.toLowerCase().startsWith(prefix.toLowerCase())) {
const item = new vscode.CompletionItem(
func_name,
vscode.CompletionItemKind.Function
);
item.detail = func_data.signature;
item.documentation = new vscode.MarkdownString(func_data.documentation);
// Create snippet for function with parentheses
const params = this.extract_parameters(func_data.signature);
if (params.length > 0) {
// Create snippet with placeholders for required parameters
const snippet_params = params
.filter(p => !p.optional)
.map((p, i) => `\${${i + 1}:${p.name}}`)
.join(', ');
item.insertText = new vscode.SnippetString(`${func_name}(${snippet_params})`);
} else {
item.insertText = new vscode.SnippetString(`${func_name}()`);
}
item.sortText = '0' + func_name; // Prioritize Laravel functions
completion_items.push(item);
}
});
return completion_items;
}
private extract_parameters(signature: string): Array<{name: string, optional: boolean}> {
const params: Array<{name: string, optional: boolean}> = [];
// Extract everything between parentheses
const match = signature.match(/\((.*?)\)/);
if (!match) {
return params;
}
const params_str = match[1];
if (!params_str.trim()) {
return params;
}
// Split by comma but handle nested parentheses/brackets
const param_parts = this.smart_split(params_str, ',');
for (const param of param_parts) {
const param_match = param.match(/\$([a-z_][a-z0-9_]*)/i);
if (param_match) {
const param_name = param_match[1];
const is_optional = param.includes('=');
params.push({ name: param_name, optional: is_optional });
}
}
return params;
}
private smart_split(str: string, delimiter: string): string[] {
const result: string[] = [];
let current = '';
let depth = 0;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '(' || char === '[' || char === '{') {
depth++;
} else if (char === ')' || char === ']' || char === '}') {
depth--;
} else if (char === delimiter && depth === 0) {
result.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim()) {
result.push(current.trim());
}
return result;
}
}
interface CompletionItemData {
name: string;
signature: string;
documentation: string;
}

View File

@@ -0,0 +1,52 @@
import * as vscode from 'vscode';
/**
* Framework PHP attributes that should be highlighted
*/
const FRAMEWORK_ATTRIBUTES = [
'Ajax_Endpoint',
'Route',
'Auth',
'Relationship',
'Monoprogenic',
'Instantiatable'
];
/**
* Provides semantic tokens for PHP attributes (amber color)
*/
export class PhpAttributeSemanticTokensProvider implements vscode.DocumentSemanticTokensProvider {
async provideDocumentSemanticTokens(document: vscode.TextDocument): Promise<vscode.SemanticTokens> {
const tokens_builder = new vscode.SemanticTokensBuilder();
if (document.languageId !== 'php') {
return tokens_builder.build();
}
const text = document.getText();
// Find all PHP attributes: #[AttributeName] or #[\AttributeName]
for (const attribute_name of FRAMEWORK_ATTRIBUTES) {
// Match: #[AttributeName or #[\AttributeName with optional namespace prefix
// Captures the attribute name only (not brackets or backslash)
const regex = new RegExp(`#\\[\\\\?(${attribute_name})(?:\\s|\\(|\\])`, 'g');
let match;
while ((match = regex.exec(text)) !== null) {
// match[1] contains the attribute name without namespace prefix
const attr_start = match.index + match[0].indexOf(match[1]);
const position = document.positionAt(attr_start);
tokens_builder.push(
position.line,
position.character,
attribute_name.length,
0, // token type index for 'conventionMethod'
0 // token modifiers
);
}
}
return tokens_builder.build();
}
}

View File

@@ -0,0 +1,65 @@
/**
* RSpade Refactor Code Actions Provider
*
* Provides refactoring actions that appear in the "Refactor..." menu
* when the cursor is on a static method definition or call.
*/
import * as vscode from 'vscode';
import { RspadeRefactorProvider } from './refactor_provider';
export class RspadeRefactorCodeActionsProvider implements vscode.CodeActionProvider {
private refactor_provider: RspadeRefactorProvider;
constructor(refactor_provider: RspadeRefactorProvider) {
this.refactor_provider = refactor_provider;
}
public provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
token: vscode.CancellationToken
): vscode.CodeAction[] | undefined {
// Only provide actions for PHP files in ./rsx or ./app/RSpade
if (document.languageId !== 'php') {
return undefined;
}
const file_path = document.uri.fsPath;
if (!file_path.includes('/rsx/') && !file_path.includes('\\rsx\\') &&
!file_path.includes('/app/RSpade') && !file_path.includes('\\app\\RSpade')) {
return undefined;
}
// Check if line contains a static method (cursor can be anywhere on the line)
const position = range.start;
const line = document.lineAt(position.line).text;
// Check for static method definition: public/protected/private static function method_name
const static_definition_match = line.match(/\b(?:public|protected|private)\s+static\s+function\s+(\w+)/);
if (static_definition_match) {
return this.create_refactor_actions();
}
// Check for static method call: ClassName::method_name
const static_call_match = line.match(/(\w+)::(\w+)/);
if (static_call_match) {
return this.create_refactor_actions();
}
return undefined;
}
private create_refactor_actions(): vscode.CodeAction[] {
const action = new vscode.CodeAction(
'Global Rename Method',
vscode.CodeActionKind.Refactor
);
action.command = {
command: 'rspade.refactorStaticMethod',
title: 'Global Rename Method'
};
return [action];
}
}

View File

@@ -0,0 +1,336 @@
/**
* RSpade Refactor Provider
*
* Provides context menu refactoring options for PHP static methods.
* Communicates with the server-side refactor commands via the IDE service.
*/
import * as vscode from 'vscode';
import { RspadeFormattingProvider } from './formatting_provider';
export class RspadeRefactorProvider {
private formatting_provider: RspadeFormattingProvider;
private output_channel: vscode.OutputChannel;
constructor(formatting_provider: RspadeFormattingProvider) {
this.formatting_provider = formatting_provider;
this.output_channel = vscode.window.createOutputChannel('RSpade Refactor');
}
/**
* Register the refactor command
*/
public register(context: vscode.ExtensionContext): void {
const command = vscode.commands.registerCommand(
'rspade.refactorStaticMethod',
async () => await this.refactor_static_method()
);
context.subscriptions.push(command);
}
/**
* Check if the refactor command should be available
*/
public should_show_refactor_menu(): boolean {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return false;
}
const document = editor.document;
// Only PHP files
if (document.languageId !== 'php') {
return false;
}
// Must be in ./rsx or ./app/RSpade directory
const file_path = document.uri.fsPath;
if (!file_path.includes('/rsx/') && !file_path.includes('\\rsx\\') &&
!file_path.includes('/app/RSpade') && !file_path.includes('\\app\\RSpade')) {
return false;
}
// Check if cursor is on a static method definition or call
const position = editor.selection.active;
const word_range = document.getWordRangeAtPosition(position);
if (!word_range) {
return false;
}
const word = document.getText(word_range);
const line = document.lineAt(position.line).text;
// Check for static method definition: public/protected/private static function method_name
const static_definition_match = line.match(/\b(?:public|protected|private)\s+static\s+function\s+(\w+)/);
if (static_definition_match) {
const method_name = static_definition_match[1];
// Cursor must be on the method name itself
return word === method_name;
}
// Check for static method call: ClassName::method_name
const static_call_match = line.match(/(\w+)::(\w+)/);
if (static_call_match) {
const method_name = static_call_match[2];
// Cursor must be on the method name itself
return word === method_name;
}
return false;
}
/**
* Main refactor method
*/
private async refactor_static_method(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor');
return;
}
const document = editor.document;
const position = editor.selection.active;
this.output_channel.clear();
this.output_channel.show(true);
this.output_channel.appendLine('=== RSpade Method Refactor ===\n');
try {
// Extract method information from cursor position
const method_info = await this.extract_method_info(document, position);
if (!method_info) {
vscode.window.showErrorMessage('Could not identify static method at cursor position');
return;
}
this.output_channel.appendLine(`Class: ${method_info.class_name}`);
this.output_channel.appendLine(`Method: ${method_info.method_name}\n`);
// Show input dialog for new method name
const new_method_name = await vscode.window.showInputBox({
title: `Global Rename Method: ${method_info.class_name}::${method_info.method_name}`,
prompt: 'Enter new method name:',
placeHolder: 'new_method_name',
value: method_info.method_name,
ignoreFocusOut: true,
validateInput: (value: string) => {
if (!value) {
return 'Method name cannot be empty';
}
if (!/^[a-z_][a-z0-9_]*$/.test(value)) {
return 'Method name must be snake_case (lowercase with underscores)';
}
if (value === method_info.method_name) {
return 'New method name must be different from current name';
}
return null;
}
});
if (!new_method_name) {
this.output_channel.appendLine('Refactor cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Confirm refactoring
const confirmation = await vscode.window.showWarningMessage(
`Global Rename: ${method_info.class_name}::${method_info.method_name}${method_info.class_name}::${new_method_name}\n\n` +
'This will rename the method across all usages in all files.',
{ modal: true },
'Rename',
'Cancel'
);
if (confirmation !== 'Rename') {
this.output_channel.appendLine('Global rename cancelled by user');
return;
}
// Save all dirty files first
this.output_channel.appendLine('Checking for unsaved files...');
const dirty_documents = vscode.workspace.textDocuments.filter(doc => doc.isDirty);
if (dirty_documents.length > 0) {
this.output_channel.appendLine(`Found ${dirty_documents.length} unsaved file(s):`);
for (const doc of dirty_documents) {
this.output_channel.appendLine(` - ${doc.fileName}`);
}
this.output_channel.appendLine('\nSaving all files...');
const save_result = await vscode.workspace.saveAll(false);
if (!save_result) {
const error_msg = 'Failed to save all files. Refactor operation aborted.';
this.output_channel.appendLine(`\nERROR: ${error_msg}`);
vscode.window.showErrorMessage(error_msg);
return;
}
this.output_channel.appendLine('All files saved successfully\n');
} else {
this.output_channel.appendLine('No unsaved files\n');
}
// Show output channel
this.output_channel.show(true);
// Show terminal and execute refactoring
this.output_channel.appendLine(`Refactoring ${method_info.class_name}::${method_info.method_name} to ${method_info.class_name}::${new_method_name}...`);
this.output_channel.appendLine('');
const result = await this.execute_refactor(
method_info.class_name,
method_info.method_name,
new_method_name
);
// Display result in terminal
this.output_channel.appendLine('\n=== Refactor Output ===\n');
this.output_channel.appendLine(result);
this.output_channel.appendLine('\n=== Refactor Complete ===');
// Check if refactor was successful
if (result.includes('=== Refactor Complete ===') || result.trim().length > 0) {
// Wait 3.5 seconds total (2s + 1.5s), hide panel, then reload files
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes to propagate (Samba/network issues)
setTimeout(async () => {
await this.reload_all_open_files();
}, 500);
}, 3500);
vscode.window.showInformationMessage(
`Successfully refactored ${method_info.class_name}::${method_info.method_name} to ${new_method_name}`
);
}
} catch (error: any) {
const error_message = error.message || String(error);
this.output_channel.appendLine(`\nERROR: ${error_message}`);
vscode.window.showErrorMessage(`Refactor failed: ${error_message}`);
}
}
/**
* Extract method information from cursor position
*/
private async extract_method_info(
document: vscode.TextDocument,
position: vscode.Position
): Promise<{ class_name: string; method_name: string } | null> {
const line = document.lineAt(position.line).text;
// Check for static method definition: public/protected/private static function method_name
const definition_match = line.match(/\b(?:public|protected|private)\s+static\s+function\s+(\w+)/);
if (definition_match) {
const method_name = definition_match[1];
// Extract class name from the file
const class_name = await this.extract_class_name(document);
if (class_name) {
return { class_name, method_name };
}
}
// Check for static method call: ClassName::method_name
const static_call_match = line.match(/(\w+)::(\w+)/);
if (static_call_match) {
const class_name = static_call_match[1];
const method_name = static_call_match[2];
return { class_name, method_name };
}
return null;
}
/**
* Extract class name from PHP file
*/
private async extract_class_name(document: vscode.TextDocument): Promise<string | null> {
const text = document.getText();
// Match actual class declaration, not @class in comments
// Look for: class ClassName or abstract class ClassName or final class ClassName
const class_match = text.match(/^\s*(?:abstract\s+|final\s+)?class\s+(\w+)/m);
if (class_match) {
return class_match[1];
}
return null;
}
/**
* Reload all open text documents
*/
private async reload_all_open_files(): Promise<void> {
const text_documents = vscode.workspace.textDocuments;
for (const document of text_documents) {
// Skip untitled documents
if (document.uri.scheme === 'untitled') {
continue;
}
// Skip non-file schemes (git, output channels, etc)
if (document.uri.scheme !== 'file') {
continue;
}
// Get the text editor for this document
const editors = vscode.window.visibleTextEditors.filter(
editor => editor.document.uri.toString() === document.uri.toString()
);
if (editors.length > 0) {
// Document is currently visible, reload it
const position = editors[0].selection.active;
const view_column = editors[0].viewColumn;
// Close and reopen to force reload
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
const new_document = await vscode.workspace.openTextDocument(document.uri);
const editor = await vscode.window.showTextDocument(new_document, view_column);
// Restore cursor position
editor.selection = new vscode.Selection(position, position);
editor.revealRange(new vscode.Range(position, position));
}
}
}
/**
* Execute the refactor command via IDE service
*/
private async execute_refactor(
class_name: string,
old_method: string,
new_method: string
): Promise<string> {
// Ensure authentication
await this.formatting_provider.ensure_auth();
// Prepare request data
const request_data = {
command: 'rsx:refactor:rename_php_class_function',
arguments: [class_name, old_method, new_method]
};
this.output_channel.appendLine('Sending refactor request to server...');
this.output_channel.appendLine(`Command: ${request_data.command}`);
this.output_channel.appendLine(`Arguments: ${JSON.stringify(request_data.arguments)}\n`);
// Make authenticated request
const response = await (this.formatting_provider as any).make_authenticated_request(
'/command',
request_data
);
if (!response.success) {
throw new Error(response.error || 'Refactor command failed');
}
return response.output || 'Refactor completed successfully (no output)';
}
}

View File

@@ -0,0 +1,168 @@
/**
* RSpade Sort Class Methods Provider
*
* Reorganizes methods in PHP class files according to RSpade conventions
*/
import * as vscode from 'vscode';
import * as path from 'path';
import { RspadeFormattingProvider } from './formatting_provider';
export class RspadeSortClassMethodsProvider {
private formatting_provider: RspadeFormattingProvider;
private output_channel: vscode.OutputChannel;
constructor(formatting_provider: RspadeFormattingProvider) {
this.formatting_provider = formatting_provider;
this.output_channel = vscode.window.createOutputChannel('RSpade Sort Methods');
}
/**
* Register the sort command
*/
public register(context: vscode.ExtensionContext): void {
const command = vscode.commands.registerCommand(
'rspade.sortClassMethods',
async (uri?: vscode.Uri) => await this.sort_class_methods(uri)
);
context.subscriptions.push(command);
}
/**
* Main sort method
*/
private async sort_class_methods(uri?: vscode.Uri): Promise<void> {
// Determine file path
let file_path: string;
if (uri) {
// Called from explorer context menu
file_path = uri.fsPath;
} else {
// Called from command palette - use active editor
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active file to sort');
return;
}
file_path = editor.document.uri.fsPath;
}
// Validate it's a PHP file
if (!file_path.endsWith('.php')) {
vscode.window.showErrorMessage('Can only sort PHP class files');
return;
}
this.output_channel.clear();
this.output_channel.show(true);
this.output_channel.appendLine('=== RSpade Sort Class Methods ===\n');
this.output_channel.appendLine(`File: ${file_path}\n`);
try {
// Get workspace root to make path relative
const workspace_folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(file_path));
if (!workspace_folder) {
throw new Error('File is not in workspace');
}
const relative_path = path.relative(workspace_folder.uri.fsPath, file_path);
this.output_channel.appendLine(`Relative path: ${relative_path}\n`);
// Confirm sorting
const confirmation = await vscode.window.showWarningMessage(
`Sort methods in ${path.basename(file_path)}?\n\n` +
'This will reorganize all methods according to RSpade conventions.',
{ modal: true },
'Sort',
'Cancel'
);
if (confirmation !== 'Sort') {
this.output_channel.appendLine('Sort cancelled by user');
await vscode.commands.executeCommand('workbench.action.closePanel');
return;
}
// Execute sort
this.output_channel.appendLine('Sorting methods...\n');
const result = await this.execute_sort(relative_path);
// Display result
this.output_channel.appendLine('\n=== Sort Output ===\n');
this.output_channel.appendLine(result);
this.output_channel.appendLine('\n=== Sort Complete ===');
// Reload the file if it's open
const document = await vscode.workspace.openTextDocument(file_path);
const editors = vscode.window.visibleTextEditors.filter(
editor => editor.document.uri.fsPath === file_path
);
if (editors.length > 0) {
// Wait 3.5 seconds, close panel, then reload
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
// Wait 500ms for filesystem changes
setTimeout(async () => {
for (const editor of editors) {
const position = editor.selection.active;
const view_column = editor.viewColumn;
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
const new_document = await vscode.workspace.openTextDocument(file_path);
const new_editor = await vscode.window.showTextDocument(new_document, view_column);
// Restore cursor position
new_editor.selection = new vscode.Selection(position, position);
new_editor.revealRange(new vscode.Range(position, position));
}
}, 500);
}, 3500);
} else {
// File not open, just close panel
setTimeout(async () => {
await vscode.commands.executeCommand('workbench.action.closePanel');
}, 3500);
}
vscode.window.showInformationMessage(`Successfully sorted methods in ${path.basename(file_path)}`);
} catch (error: any) {
const error_message = error.message || String(error);
this.output_channel.appendLine(`\nERROR: ${error_message}`);
vscode.window.showErrorMessage(`Sort failed: ${error_message}`);
}
}
/**
* Execute the sort command via IDE service
*/
private async execute_sort(file_path: string): Promise<string> {
// Ensure authentication
await this.formatting_provider.ensure_auth();
// Prepare request data
const request_data = {
command: 'rsx:refactor:sort_php_class_functions',
arguments: [file_path]
};
this.output_channel.appendLine('Sending sort request to server...');
this.output_channel.appendLine(`Command: ${request_data.command}`);
this.output_channel.appendLine(`Arguments: ${JSON.stringify(request_data.arguments)}\n`);
// Make authenticated request
const response = await (this.formatting_provider as any).make_authenticated_request(
'/command',
request_data
);
if (!response.success) {
throw new Error(response.error || 'Sort command failed');
}
return response.output || 'Sort completed successfully (no output)';
}
}