vercengen/components/ComponentScriptManager/core/project/FindAndReplace.js

/**
 * Internal sub-component of <span color = "yellow">{@link ve.ScriptManager}</span>.
 * 
 * ##### Constructor:
 * - `arg0_options`; {@link Object}
 *   - `.ignore_files=[]`: {@link Array}<{@link string}>
 *     
 * ##### Instance:
 * - `.cancel_search`: {@link boolean}
 * - `.is_searching=false`: {@link boolean}
 * - `.matches=[]`: {@link Array}<{@link string}>
 * - `.selected_index=-1`: {@link number}
 * 
 * @memberof ve.Component.ve.ScriptManager
 * @type {ve.ScriptManager.FindAndReplace}
 */
ve.ScriptManager.FindAndReplace = class {
	constructor (arg0_options) {
		//Convert from parameters
		let options = (arg0_options) ? arg0_options : {};
		
		//Initialise options
		this.options = options;
		
		//Declare local instance variables
		this.ignore_files = (options.ignore_files) ? options.ignore_files : [];
		this.matches = [];
		this.selected_index = -1;
		
		//State for async operations
		this.is_searching = false;
		this.cancel_search = false;
	}
	
	async _processFile (arg0_file_path, arg1_pattern, arg2_replace_string, arg3_callbacks) {
		let file_path = arg0_file_path;
		let pattern = arg1_pattern;
		let replace_string = arg2_replace_string;
		let callbacks = arg3_callbacks;
		
		try {
			//Async read
			let buffer = await fs.promises.readFile(file_path);
			if (buffer.indexOf(0) !== -1) return; //Binary guard
			
			let content = buffer.toString("utf8");
			let lines = content.split(/\r?\n/);
			let match_found_in_file = false;
			let file_matches = [];
			
			//Iterate lines
			lines.forEach((line_content, index) => {
				//Reset lastIndex to ensure we find the first match on the line
				pattern.lastIndex = 0;
				let local_match = pattern.exec(line_content);
				
				if (local_match) {
					match_found_in_file = true;
					
					file_matches.push({
						file: file_path,
						line: index + 1,
						match: line_content.trim(),
						start_column: local_match.index + 1,
						end_column: local_match.index + 1 + local_match[0].length,
					});
				}
			});
			
			//If matches found, notify callback immediately (Streaming)
			if (match_found_in_file) {
				if (replace_string !== undefined) {
					let new_content = content.replace(pattern, replace_string);
					await fs.promises.writeFile(file_path, new_content, "utf8");
				} else {
					if (callbacks.onmatch)
						callbacks.onmatch(file_matches);
				}
			}
		} catch (e) {
			console.error(loc("ve.registry.localisation.FindAndReplace_error_processing", file_path, e.message));
		}
	}
	
	_recalculateIgnoreFolders () {
		//Declare local instance variables
		let new_ignore_files = [];
		
		if (this.script_manager_instance) {
			let files_config = this.script_manager_instance?.config?.files;
			
			if (files_config)
				Object.iterate(files_config, (local_key, local_value) => {
					if (local_value.mode === "excluded")
						new_ignore_files.push(local_key);
				});
		}
		this.ignore_files = new_ignore_files;
	}
	
	_replaceInFile (arg0_file_path, arg1_pattern, arg2_line_number, arg3_replace_string) {
		//Convert from parameters
		let file_path = arg0_file_path;
		let pattern = arg1_pattern;
		let line_number = arg2_line_number;
		let replace_string = arg3_replace_string;
		
		try {
			let content = fs.readFileSync(file_path, "utf8");
			let lines = content.split(/\r?\n/);
			
			let target_line = lines[line_number - 1];
			pattern.lastIndex = 0;
			
			if (pattern.test(target_line)) {
				//Reset lastIndex again because .test() advanced it
				pattern.lastIndex = 0;
				
				lines[line_number - 1] = target_line.replace(pattern, replace_string);
				fs.writeFileSync(file_path, lines.join("\n"), "utf8");
				
				//Return statement
				return true;
			}
		} catch (e) { console.error(e); }
		
		//Return statement
		return false;
	}
	
	_restoreContent () {
		//Declare local instance variables
		let monaco_obj = this.script_manager_instance.scene_monaco.editor;
		
		//Restore selection after loading file
		if (this._saved_file_content) {
			this.script_manager_instance.v = this._saved_file_content;
			setTimeout(() => {
				if (this._saved_file_position)
					monaco_obj.setPosition(this._saved_file_position);
				if (!this._saved_file_selection.isEmpty())
					monaco_obj.setSelection(this._saved_file_selection);
				
				delete this._saved_file_content;
				delete this._saved_file_position;
				delete this._saved_file_selection;
			});
		}
	}
	
	async _traverse (arg0_current_path, arg1_pattern, arg2_replace_string, arg3_stats, arg4_callbacks) {
		//Check cancellation
		if (this.cancel_search) return;
		
		let current_path = arg0_current_path;
		let pattern = arg1_pattern;
		let replace_string = arg2_replace_string;
		let stats = arg3_stats;
		let callbacks = arg4_callbacks;
		
		try {
			//Use fs.promises for async file ops
			let file_stat = await fs.promises.stat(current_path);
			
			if (file_stat.isDirectory()) {
				let all_files = await fs.promises.readdir(current_path);
				
				for (let local_file of all_files) {
					let continue_search = false;
					let file_path = path.join(current_path, local_file);
					
					if (this.cancel_search) return;
					for (let i = 0; i < this.ignore_files.length; i++)
						if (file_path.includes(this.ignore_files[i])) {
							continue_search = true;
							break;
						}
					if (continue_search) continue;
					
					//Recursion
					await this._traverse(path.join(current_path, local_file), pattern, replace_string, stats, callbacks);
				}
			} else if (file_stat.isFile()) {
				stats.scanned++;
				
				//Throttle progress updates to UI (every 10 files) to save performance
				if (stats.scanned % 10 === 0 && callbacks.onprogress)
					callbacks.onprogress(stats.scanned);
				
				await this._processFile(current_path, pattern, replace_string, callbacks);
			}
		} catch (e) {}
	}
	
	/**
	 * Draws the container or appends results.
	 * - Method of: {@link ve.ScriptManager.FindAndReplace}
	 *
	 * @alias draw
	 * @memberof ve.Component.ve.ScriptManager.ve.ScriptManager.FindAndReplace
	 * 
	 * @param {HTMLElement} arg0_element - The container element.
	 * @param {Array} arg1_new_results - Array of new matches to append.
	 * @param {Boolean} arg2_reset - If true, clears the list and redraws the container structure.
	 */
	draw (arg0_element, arg1_new_results, arg2_reset) {
		//Convert from parameters
		let element = (arg0_element) ? arg0_element : document.createElement("div");
		let new_results = (arg1_new_results) ? arg1_new_results : [];
		let should_reset = (arg2_reset === true);
		
		//Declare local instance variables
		let files_obj = {};
		
		//1. Initialise container if resetting
		this._recalculateIgnoreFolders();
		if (should_reset) {
			let is_open = false;
			let old_details_el = element.querySelector(`#matches-details`);
			if (old_details_el && typeof old_details_el.getAttribute("open") === "string")
				is_open = true;
			
			element.innerHTML = `<details id = "matches-details"${(!is_open) ? is_open : " open"}>
				<summary id = "matches-label">${loc("ve.registry.localisation.FindAndReplace_summary_occurrences", 0)}</summary>
			</details>`;
		}
		
		let matches_container_el = element.querySelector(`#matches-details`);
		let matches_label_el = element.querySelector(`#matches-label`);
		
		//Update total count label
		if (matches_label_el)
			matches_label_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_summary_occurrences", String.formatNumber(this.matches.length));
		
		//2. Append new results only (Streaming UI)
		//Calculate the starting index for these new results within the main array
		let start_index = this.matches.length - new_results.length;
		
		for (let i = 0; i < new_results.length; i++) {
			let global_index = start_index + i;
			let is_new_file = false;
			let local_entry_el = document.createElement("span");
			let local_file_el;
			let local_file_name = path.resolve(new_results[i].file)
				.replace(path.resolve(this._folder), "");
			
			//1. Create file entry
			if (!files_obj[new_results[i].file]) {
				files_obj[new_results[i].file] = document.createElement("div");
					local_file_el = files_obj[new_results[i].file];
					local_file_el.className = "match-file";
					local_file_el.innerHTML = `<span id = "file-submatches"></span>`;
				is_new_file = true;
			}
			local_file_el = files_obj[new_results[i].file];
			
			//2. Create match entry
			local_entry_el.className = (this.selected_index === global_index) ? "match-entry selected" : "match-entry";
			local_entry_el.innerHTML = `${(is_new_file) ? loc("ve.registry.localisation.FindAndReplace_file_lines", local_file_name) : ""}<span class = "line">${new_results[i].line}</span>`;
				local_entry_el.title = new_results[i].match; //Tooltip for full line
			
			//3. Add onclick handler
			local_entry_el.onclick = () => {
				//Clear previous selection visually
				let prev_selected = matches_container_el.querySelector(".match-entry.selected");
					if (prev_selected) prev_selected.classList.remove("selected");
				
				this.selected_index = global_index;
				local_entry_el.classList.add("selected");
				
				//Save content if possible
				if (this.script_manager_instance) {
					let monaco_obj = this.script_manager_instance.scene_monaco.editor;
					
					if (!this._saved_file_content) {
						this._saved_file_content = this.script_manager_instance.v;
						this._saved_file_position = monaco_obj.getPosition();
						this._saved_file_selection = monaco_obj.getSelection();
					}
					
					//Scroll to position and move caret to it
					this.script_manager_instance.v = fs.readFileSync(new_results[i].file, "utf8");
					setTimeout(() => {
						monaco_obj.revealLine(new_results[i].line);
						monaco_obj.setSelection({
							startLineNumber: new_results[i].line,
							startColumn: new_results[i].start_column,
							endLineNumber: new_results[i].line,
							endColumn: new_results[i].end_column
						});
						monaco_obj.focus();
					}, 100);
				}
			};
			
			local_file_el.querySelector(`#file-submatches`).appendChild(local_entry_el);
		}
		
		//Push files_obj to element
		Object.iterate(files_obj, (local_key, local_value) => {
			let all_local_matches_els = local_value.querySelectorAll(`.match-entry`);
			let file_submatches_el = local_value.querySelector(`#file-submatches`);
			
			if (all_local_matches_els.length <= 8)
				file_submatches_el.classList.add("disable-nesting");
			
			matches_container_el.appendChild(local_value);
		});
	}
	
	/**
	 * Async execution of Find (and optionally Replace All). Streaming results are returned via callbacks.
	 * - Method of: {@link ve.ScriptManager.FindAndReplace}
	 * 
	 * @alias execute
	 * @memberof ve.Component.ve.ScriptManager.ve.ScriptManager.FindAndReplace
	 * 
	 * @param {string} arg0_root_path
	 * @param {string} arg1_search_string
	 * @param {string} arg2_replace_string
	 * @param {Object} [arg3_options]
	 * @param {{onmatch: function, onfinish: function, onprogress: function}} [arg4_callbacks] - Callback object/map.
	 */
	async execute (arg0_root_path, arg1_search_string, arg2_replace_string, arg3_options, arg4_callbacks) {
		//Convert from parameters
		let root_path = arg0_root_path;
		let search_string = arg1_search_string;
		let replace_string = arg2_replace_string;
		let options = (arg3_options) ? arg3_options : {};
		let callbacks = (arg4_callbacks) ? arg4_callbacks : {
			onmatch: () => {},
			onfinish: () => {},
			onprogress: () => {},
		};
		
		//Handle cancellation
		this._restoreContent();
		if (this.is_searching) {
			this.cancel_search = true;
			//Wait a tick for the previous loop to exit
			await new Promise(r => setTimeout(r, 100));
		}
		this.is_searching = true;
		this.cancel_search = false;
		
		//Initialise options
		options.flags = (options.flags) ? options.flags : ["g"];
		if (options.is_case_sensitive === undefined) options.is_case_sensitive = true;
		if (options.is_regex === undefined) options.is_regex = false;
		
		//Regex 'i' flag is for case-insensitivity; only add if is_case_sensitive is false
		if (!options.is_case_sensitive) options.flags.push("i");
		
		//Declare local instance variables
		let pattern;
		try {
			pattern = (options.is_regex) ?
				new RegExp(search_string, options.flags.join("")) :
				new RegExp(search_string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), options.flags.join(""));
		} catch (e) {
			console.error(loc("ve.registry.localisation.FindAndReplace_error_invalid_regex", e));
			this.is_searching = false;
			if (callbacks.onfinish) callbacks.onfinish();
			return;
		}
		
		let stats = { scanned: 0 };
		
		//Start Async Traversal
		this._folder = root_path;
		await this._traverse(root_path, pattern, replace_string, stats, callbacks);
		
		this.is_searching = false;
		if (callbacks.onfinish) callbacks.onfinish();
	}
	
	/**
	 * Returns all file paths which match the given patterns.
	 * - Method of: {@link ve.ScriptManager.FindAndReplace}
	 * 
	 * @alias getFiles
	 * @memberof ve.Component.ve.ScriptManager.ve.ScriptManager.FindAndReplace
	 * 
	 * @param {string[]} arg0_matches
	 * 
	 * @returns {string[]}
	 */
	getFiles (arg0_matches) {
		//Convert from parameters
		let matches = (arg0_matches) ? arg0_matches : [];
		
		//Declare local instance variables
		let all_files = [];
		
		//Iterate over all matches
		for (let i = 0; i < matches.length; i++)
			if (!all_files.includes(matches[i].file))
				all_files.push(matches[i].file);
		
		//Return statement
		return all_files;
	}
};

//Initialise functions
{
	/**
	 * Opens a global find and replace prompt across the current set project folder.
	 * - Method of: {@link ve.ScriptManager}
	 *
	 * @private
	 */
	ve.ScriptManager.prototype._openFindAndReplace = function () {
		//Declare local instance variables
		let current_folder = (this._settings.project_folder !== "none") ?
			this._settings.project_folder : this.leftbar_file_explorer.v;
		
		if (!this._find_and_replace_obj) {
			this._find_and_replace_obj = new ve.ScriptManager.FindAndReplace();
			this._find_and_replace_obj.script_manager_instance = this;
		}
		
		let matches_el = document.createElement("div");
		matches_el.id = "ve-script-manager-find-and-replace";
		
		//Internal helper to get current RegExp pattern based on UI settings
		let local_get_pattern = () => {
			let local_flags = ["g"];
			if (!this._settings.far_is_case_sensitive) local_flags.push("i");
			
			return (this._settings.far_is_regex) ?
				new RegExp(this._settings.far_find_text, local_flags.join("")) :
				new RegExp(this._settings.far_find_text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), local_flags.join(""));
		};
		let local_get_folder = () => {
			current_folder = (this._settings.project_folder !== "none") ?
				this._settings.project_folder : this.leftbar_file_explorer.v;
		};
		
		//Open this.find_and_replace_window
		if (this.find_and_replace_window) this.find_and_replace_window.close();
		
		//Create Status Element
		let status_el = document.createElement("div");
		status_el.id = "status-label";
		status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_ready");
		
		this.find_and_replace_window = new ve.Window({
			find_text: new ve.Text((this._settings.far_find_text) ? this._settings.far_find_text : "", {
				name: loc("ve.registry.localisation.FindAndReplace_find"),
				onuserchange: (v) => {
					this._settings.far_find_text = v;
				}
			}),
			replace_text: new ve.Text((this._settings.far_replace_text) ? this._settings.far_replace_text : "", {
				name: loc("ve.registry.localisation.FindAndReplace_replace"),
				onuserchange: (v) => {
					this._settings.far_replace_text = v;
				}
			}),
			toggles: new ve.RawInterface({
				is_case_sensitive: new ve.Toggle(this._settings.far_is_case_sensitive, {
					name: loc("ve.registry.localisation.FindAndReplace_is_case_sensitive"),
					onuserchange: (v) => {
						this._settings.far_is_case_sensitive = v;
					}
				}),
				is_regex: new ve.Toggle(this._settings.far_is_regex, {
					name: loc("ve.registry.localisation.FindAndReplace_is_regex"),
					onuserchange: (v) => {
						this._settings.far_is_regex = v;
					}
				}),
			}, {
				style: { display: "flex" }
			}),
			status: new ve.HTML(status_el),
			matches: new ve.HTML(matches_el),
			
			actions_bar: new ve.RawInterface({
				find: new ve.Button(() => {
					if (this._settings.far_find_text) {
						let local_far = this._find_and_replace_obj;
						local_get_folder();
						
						//1. Clear previous results
						local_far.matches = [];
						local_far.selected_index = -1;
						local_far.draw(matches_el, [], true); //true = reset container
						status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_scanning");
						
						//2. Execute Async
						local_far.execute(current_folder, this._settings.far_find_text, undefined, {
							is_case_sensitive: this._settings.far_is_case_sensitive,
							is_regex: this._settings.far_is_regex
						}, {
							onmatch: (new_matches) => {
								//Add to main array
								local_far.matches.push(...new_matches);
								//Update UI incrementally
								local_far.draw(matches_el, new_matches, false);
							},
							onprogress: (count) => {
								status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_scanning_progress", String.formatNumber(count));
							},
							onfinish: () => {
								status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_search_complete", current_folder, String.formatNumber(local_far.matches.length), String.formatNumber(local_far.getFiles(local_far.matches).length));
								
								//Auto-select first match if exists
								if (local_far.matches.length > 0 && local_far.selected_index === -1) {
									local_far.selected_index = 0;
									//Force redraw just to highlight the selection
									local_far.draw(matches_el, [], false);
								}
							}
						});
					}
				}, {
					name: loc("ve.registry.localisation.FindAndReplace_find")
				}),
				replace: new ve.Button(() => {
					let local_far = this._find_and_replace_obj;
					local_get_folder();
					
					if (local_far.selected_index !== -1 && local_far.matches[local_far.selected_index]) {
						let local_match = local_far.matches[local_far.selected_index];
						let local_pattern = local_get_pattern();
						
						//Perform single line replacement
						local_far._replaceInFile(local_match.file, local_pattern, local_match.line, this._settings.far_replace_text);
						
						//Remove replaced entry
						local_far.matches.splice(local_far.selected_index, 1);
						
						//Adjust selection
						if (local_far.selected_index >= local_far.matches.length)
							local_far.selected_index = (local_far.matches.length > 0) ? 0 : -1;
						
						//Redraw list (Since we removed an item, we usually need to redraw the structure to fix indices/visuals)
						//For optimization, we could just remove the DOM element, but redraw is safer here.
						local_far.draw(matches_el, [], true);
						local_far.draw(matches_el, local_far.matches, false);
					}
				}, {
					name: loc("ve.registry.localisation.FindAndReplace_replace")
				}),
				replace_all: new ve.Button(() => {
					if (this._settings.far_find_text) {
						//Confirmation dialog could go here
						veConfirm(loc("ve.registry.localisation.FindAndReplace_confirm_replace_all", this._settings.far_find_text, current_folder), {
							special_function: () => {
								let local_far = this._find_and_replace_obj;
								local_get_folder();
								status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_replacing");
								
								local_far.execute(current_folder, this._settings.far_find_text, this._settings.far_replace_text, {
									is_case_sensitive: this._settings.far_is_case_sensitive,
									is_regex: this._settings.far_is_regex
								}, {
									onprogress: (count) => {
										status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_replacing_progress", String.formatNumber(count));
									},
									onfinish: () => {
										status_el.innerHTML = loc("ve.registry.localisation.FindAndReplace_status_replace_all_complete");
										local_far.matches = [];
										local_far.selected_index = -1;
										local_far.draw(matches_el, [], true);
									}
								});
							}
						});
					}
				}, {
					name: loc("ve.registry.localisation.FindAndReplace_replace_all")
				})
			}, {
				style: { display: "flex" }
			}),
		}, {
			name: loc("ve.registry.localisation.FindAndReplace_window_name"),
			width: "30rem",
			can_rename: false,
			do_not_wrap: true,
			
			onuserchange: (v) => {
				if (v.close)
					this._find_and_replace_obj._restoreContent();
			}
		});
	};
}