diff --git a/src/handlers/quickTools.js b/src/handlers/quickTools.js index 0b9909621..8b86cc887 100644 --- a/src/handlers/quickTools.js +++ b/src/handlers/quickTools.js @@ -1,5 +1,6 @@ import quickTools from "components/quickTools"; import actionStack from "lib/actionStack"; +import searchHistory from "lib/searchHistory"; import appSettings from "lib/settings"; import searchSettings from "settings/searchSettings"; import KeyboardEvent from "utils/keyboardEvent"; @@ -75,6 +76,67 @@ appSettings.on("update:quicktoolsItems:after", () => { }, 100); }); +// Initialize history navigation +function setupHistoryNavigation() { + const { $searchInput, $replaceInput } = quickTools; + + // Search input history navigation + if ($searchInput.el) { + $searchInput.el.addEventListener("keydown", (e) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + const newValue = searchHistory.navigateSearchUp($searchInput.el.value); + $searchInput.el.value = newValue; + // Trigger search + if (newValue) find(0, false); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + const newValue = searchHistory.navigateSearchDown( + $searchInput.el.value, + ); + $searchInput.el.value = newValue; + // Trigger search + if (newValue) find(0, false); + } else if (e.key === "Enter" || e.key === "Escape") { + // Reset navigation on enter or escape + searchHistory.resetSearchNavigation(); + } + }); + + // Reset navigation when user starts typing + $searchInput.el.addEventListener("input", () => { + searchHistory.resetSearchNavigation(); + }); + } + + // Replace input history navigation + if ($replaceInput.el) { + $replaceInput.el.addEventListener("keydown", (e) => { + if (e.key === "ArrowUp") { + e.preventDefault(); + const newValue = searchHistory.navigateReplaceUp( + $replaceInput.el.value, + ); + $replaceInput.el.value = newValue; + } else if (e.key === "ArrowDown") { + e.preventDefault(); + const newValue = searchHistory.navigateReplaceDown( + $replaceInput.el.value, + ); + $replaceInput.el.value = newValue; + } else if (e.key === "Enter" || e.key === "Escape") { + // Reset navigation on enter or escape + searchHistory.resetReplaceNavigation(); + } + }); + + // Reset navigation when user starts typing + $replaceInput.el.addEventListener("input", () => { + searchHistory.resetReplaceNavigation(); + }); + } +} + export const key = { get shift() { return state.shift; @@ -169,10 +231,16 @@ export default function actions(action, value) { return true; case "search-prev": + if (quickTools.$searchInput.el.value) { + searchHistory.addToHistory(quickTools.$searchInput.el.value); + } find(1, true); return true; case "search-next": + if (quickTools.$searchInput.el.value) { + searchHistory.addToHistory(quickTools.$searchInput.el.value); + } find(1, false); return true; @@ -181,10 +249,16 @@ export default function actions(action, value) { return true; case "search-replace": + if ($replaceInput.value) { + searchHistory.addToHistory($replaceInput.value); + } editor.replace($replaceInput.value || ""); return true; case "search-replace-all": + if ($replaceInput.value) { + searchHistory.addToHistory($replaceInput.value); + } editor.replaceAll($replaceInput.value || ""); return true; @@ -227,9 +301,15 @@ function toggleSearch() { }; $searchInput.onsearch = function () { - if (this.value) find(1, false); + if (this.value) { + searchHistory.addToHistory(this.value); + find(1, false); + } }; + // Setup history navigation for search inputs + setupHistoryNavigation(); + setFooterHeight(2); find(0, false); @@ -327,6 +407,10 @@ function removeSearch() { $footer.removeAttribute("data-searching"); $searchRow1.remove(); $searchRow2.remove(); + + // Reset history navigation when search is closed + searchHistory.resetAllNavigation(); + const { activeFile } = editorManager; // Check if current tab is a terminal diff --git a/src/lib/openFile.js b/src/lib/openFile.js index d4e9bc44a..66a51b7ff 100644 --- a/src/lib/openFile.js +++ b/src/lib/openFile.js @@ -4,7 +4,7 @@ import alert from "dialogs/alert"; import confirm from "dialogs/confirm"; import loader from "dialogs/loader"; import { reopenWithNewEncoding } from "palettes/changeEncoding"; -import { decode } from "utils/encodings"; +import { decode, detectEncoding } from "utils/encodings"; import helpers from "utils/helpers"; import EditorFile from "./editorFile"; import fileTypeHandler from "./fileTypeHandler"; @@ -84,7 +84,7 @@ export default async function openFile(file, options = {}) { const fileInfo = await fs.stat(); const name = fileInfo.name || file.filename || uri; const readOnly = fileInfo.canWrite ? false : true; - const createEditor = (isUnsaved, text) => { + const createEditor = (isUnsaved, text, detectedEncoding) => { new EditorFile(name, { uri, text, @@ -93,7 +93,7 @@ export default async function openFile(file, options = {}) { render, onsave, readOnly, - encoding, + encoding: detectedEncoding || encoding, SAFMode: mode, }); }; @@ -385,12 +385,21 @@ export default async function openFile(file, options = {}) { } const binData = await fs.readFile(); - const fileContent = await decode( - binData, - file.encoding || appSettings.value.defaultFileEncoding, - ); - createEditor(false, fileContent); + // Detect encoding if not explicitly provided + let detectedEncoding = file.encoding || encoding; + if (!detectedEncoding) { + try { + detectedEncoding = await detectEncoding(binData); + } catch (error) { + console.warn("Encoding detection failed, using default:", error); + detectedEncoding = appSettings.value.defaultFileEncoding; + } + } + + const fileContent = await decode(binData, detectedEncoding); + + createEditor(false, fileContent, detectedEncoding); if (mode !== "single") recents.addFile(uri); return; } catch (error) { diff --git a/src/lib/searchHistory.js b/src/lib/searchHistory.js new file mode 100644 index 000000000..5adb63431 --- /dev/null +++ b/src/lib/searchHistory.js @@ -0,0 +1,188 @@ +/** + * Search and Replace History Manager + * Manages search/replace history using localStorage + */ + +const HISTORY_KEY = "acode.searchreplace.history"; +const MAX_HISTORY_ITEMS = 20; + +class SearchHistory { + constructor() { + this.history = this.loadHistory(HISTORY_KEY); + this.searchIndex = -1; // Current position in history for search input + this.replaceIndex = -1; // Current position in history for replace input + this.tempSearchValue = ""; // Temporary storage for current search input + this.tempReplaceValue = ""; // Temporary storage for current replace input + } + + /** + * Load history from localStorage + * @param {string} key Storage key + * @returns {Array} History items + */ + loadHistory(key) { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : []; + } catch (error) { + console.warn("Failed to load search history:", error); + return []; + } + } + + /** + * Save history to localStorage + */ + saveHistory() { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(this.history)); + } catch (error) { + console.warn("Failed to save search history:", error); + } + } + + /** + * Add item to history + * @param {string} item Item to add + */ + addToHistory(item) { + if (!item || typeof item !== "string" || item.trim().length === 0) { + return; + } + + const trimmedItem = item.trim(); + + // Remove existing item if present + this.history = this.history.filter((h) => h !== trimmedItem); + + // Add to beginning + this.history.unshift(trimmedItem); + + // Limit history size + this.history = this.history.slice(0, MAX_HISTORY_ITEMS); + + this.saveHistory(); + } + + /** + * Get history + * @returns {Array} History items + */ + getHistory() { + return [...this.history]; + } + + /** + * Clear all history + */ + clearHistory() { + this.history = []; + this.saveHistory(); + } + + /** + * Navigate up in search history (terminal-like) + * @param {string} currentValue Current input value + * @returns {string} Previous history item or current value + */ + navigateSearchUp(currentValue) { + if (this.history.length === 0) return currentValue; + + // Store current value if we're at the beginning + if (this.searchIndex === -1) { + this.tempSearchValue = currentValue; + this.searchIndex = this.history.length - 1; + } else if (this.searchIndex > 0) { + this.searchIndex--; + } + + return this.history[this.searchIndex] || currentValue; + } + + /** + * Navigate down in search history (terminal-like) + * @param {string} currentValue Current input value + * @returns {string} Next history item or original value + */ + navigateSearchDown(currentValue) { + if (this.history.length === 0 || this.searchIndex === -1) { + return currentValue; + } + + this.searchIndex++; + + // If we've gone past the end, return to original value + if (this.searchIndex >= this.history.length) { + this.searchIndex = -1; + return this.tempSearchValue; + } + + return this.history[this.searchIndex]; + } + + /** + * Navigate up in replace history (terminal-like) + * @param {string} currentValue Current input value + * @returns {string} Previous history item or current value + */ + navigateReplaceUp(currentValue) { + if (this.history.length === 0) return currentValue; + + // Store current value if we're at the beginning + if (this.replaceIndex === -1) { + this.tempReplaceValue = currentValue; + this.replaceIndex = this.history.length - 1; + } else if (this.replaceIndex > 0) { + this.replaceIndex--; + } + + return this.history[this.replaceIndex] || currentValue; + } + + /** + * Navigate down in replace history (terminal-like) + * @param {string} currentValue Current input value + * @returns {string} Next history item or original value + */ + navigateReplaceDown(currentValue) { + if (this.history.length === 0 || this.replaceIndex === -1) { + return currentValue; + } + + this.replaceIndex++; + + // If we've gone past the end, return to original value + if (this.replaceIndex >= this.history.length) { + this.replaceIndex = -1; + return this.tempReplaceValue; + } + + return this.history[this.replaceIndex]; + } + + /** + * Reset search history navigation + */ + resetSearchNavigation() { + this.searchIndex = -1; + this.tempSearchValue = ""; + } + + /** + * Reset replace history navigation + */ + resetReplaceNavigation() { + this.replaceIndex = -1; + this.tempReplaceValue = ""; + } + + /** + * Reset all navigation state + */ + resetAllNavigation() { + this.resetSearchNavigation(); + this.resetReplaceNavigation(); + } +} + +export default new SearchHistory(); diff --git a/src/utils/encodings.js b/src/utils/encodings.js index fbe122df6..7aa298bd0 100644 --- a/src/utils/encodings.js +++ b/src/utils/encodings.js @@ -40,6 +40,78 @@ export function getEncoding(charset) { return encodings["UTF-8"]; } +function detectBOM(bytes) { + if ( + bytes.length >= 3 && + bytes[0] === 0xef && + bytes[1] === 0xbb && + bytes[2] === 0xbf + ) + return "UTF-8"; + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) + return "UTF-16LE"; + if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) + return "UTF-16BE"; + return null; +} + +export async function detectEncoding(buffer) { + if (!buffer || buffer.byteLength === 0) { + return settings.value.defaultFileEncoding || "UTF-8"; + } + + const bytes = new Uint8Array(buffer); + + const bomEncoding = detectBOM(bytes); + if (bomEncoding) return bomEncoding; + + const sample = bytes.subarray(0, Math.min(2048, bytes.length)); + let nulls = 0, + ascii = 0; + + for (const byte of sample) { + if (byte === 0) nulls++; + else if (byte < 0x80) ascii++; + } + + if (ascii / sample.length > 0.95) return "UTF-8"; + if (nulls > sample.length * 0.3) return "UTF-16LE"; + + const encodings = [ + ...new Set([ + "UTF-8", + settings.value.defaultFileEncoding || "UTF-8", + "windows-1252", + "ISO-8859-1", + ]), + ]; + + const testSample = sample.subarray(0, 512); + const testBuffer = testSample.buffer.slice( + testSample.byteOffset, + testSample.byteOffset + testSample.byteLength, + ); + + for (const encoding of encodings) { + try { + const encodingObj = getEncoding(encoding); + if (!encodingObj) continue; + + const text = await execDecode(testBuffer, encodingObj.name); + if ( + !text.includes("\uFFFD") && + !/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(text) + ) { + return encoding; + } + } catch (error) { + continue; + } + } + + return settings.value.defaultFileEncoding || "UTF-8"; +} + /** * Decodes arrayBuffer to String according given encoding type * @param {ArrayBuffer} buffer