diff --git a/package-lock.json b/package-lock.json index c84de95..55bd7ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "miller", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "miller", - "version": "0.2.5", + "version": "0.2.6", "license": "GPL-3.0-or-later", "dependencies": { "@lydell/node-pty": "^1.1.0", @@ -34,6 +34,7 @@ "@types/electron-squirrel-startup": "^1.0.2", "@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/parser": "^8.46.3", + "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "codemirror": "^6.0.2", @@ -3860,6 +3861,195 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vscode/ripgrep": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", + "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@vscode/ripgrep-darwin-arm64": "1.18.0", + "@vscode/ripgrep-darwin-x64": "1.18.0", + "@vscode/ripgrep-linux-arm": "1.18.0", + "@vscode/ripgrep-linux-arm64": "1.18.0", + "@vscode/ripgrep-linux-ia32": "1.18.0", + "@vscode/ripgrep-linux-ppc64": "1.18.0", + "@vscode/ripgrep-linux-riscv64": "1.18.0", + "@vscode/ripgrep-linux-s390x": "1.18.0", + "@vscode/ripgrep-linux-x64": "1.18.0", + "@vscode/ripgrep-win32-arm64": "1.18.0", + "@vscode/ripgrep-win32-ia32": "1.18.0", + "@vscode/ripgrep-win32-x64": "1.18.0" + } + }, + "node_modules/@vscode/ripgrep-darwin-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz", + "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-darwin-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz", + "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz", + "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz", + "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz", + "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ppc64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz", + "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-riscv64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz", + "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-s390x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz", + "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz", + "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-win32-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz", + "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz", + "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", + "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vscode/sudo-prompt": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", diff --git a/package.json b/package.json index 6dd1d85..016d997 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/electron-squirrel-startup": "^1.0.2", "@typescript-eslint/eslint-plugin": "^8.46.3", "@typescript-eslint/parser": "^8.46.3", + "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "codemirror": "^6.0.2", diff --git a/src/app/editor.ts b/src/app/editor.ts index 9c40cb3..6e11c6a 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -209,6 +209,14 @@ export class Editor extends Displayable { return this.file.filePath.val + (this.file.isDirty() ? "*" : ""); } + jumpTo(line: number) { + const lineDoc = this.view.state.doc.line(line); + this.view.dispatch({ + selection: { anchor: lineDoc.from, head: lineDoc.from }, + }); + this.view.focus(); + } + close() { if (this.deleteFn) { this.file.removeEditor(this, this.deleteFn); diff --git a/src/app/editorgrid.ts b/src/app/editorgrid.ts index 61bdd56..a75d9e4 100644 --- a/src/app/editorgrid.ts +++ b/src/app/editorgrid.ts @@ -9,6 +9,7 @@ import { Terminal } from "./terminal"; import { Displayable } from "./displayable"; import { QuickOpen } from "./quickopen"; import { toggleSidebar } from "./renderer"; +import { SearchPane } from "./searchPane"; const EditorWrapper = ( editor: State, @@ -99,6 +100,12 @@ export function addTerminal() { }, 0); } +export function addSearchPane() { + const pane = new SearchPane(); + editors[currentTab.val].push(vanX.noreactive(pane)); + pane.focus(); +} + const TabHeader = (tab: State, del: () => void, k: number) => v.div( { @@ -145,7 +152,12 @@ function shortcutHandler(e: KeyboardEvent) { } else if (e.key === "b" && e.ctrlKey) { if (e.type === "keydown") { toggleSidebar(); + } + } else if (e.key === "f" && e.altKey) { + if (e.type === "keydown") { + addSearchPane(); } + e.preventDefault(); } } diff --git a/src/app/searchPane.ts b/src/app/searchPane.ts new file mode 100644 index 0000000..fae395c --- /dev/null +++ b/src/app/searchPane.ts @@ -0,0 +1,130 @@ +import van, { State } from "vanjs-core"; +const v = van.tags; + +import { Displayable } from "./displayable"; +import { addEditor } from "./editorgrid"; +import { OpenFile } from "./filestate"; +import * as u from "./utils"; + +interface SearchMatch { + path: string; + line: number; + column: number; + text: string; +} + +export class SearchPane extends Displayable { + query = van.state(""); + results = van.state([]); + isSearching = van.state(false); + private unsubscribeResult?: () => void; + + constructor() { + super(); + } + + async startSearch() { + const q = this.query.val; + if (!q) return; + + if (this.unsubscribeResult) { + this.unsubscribeResult(); + this.unsubscribeResult = undefined; + } + + this.isSearching.val = true; + this.results.val = []; + + const args = ["--json", q, "."]; + + try { + await window.electronAPI.startSearch(args); + + this.unsubscribeResult = window.electronAPI.onSearchResult((result: any) => { + console.log("Message from ripgrep: ", result); + if (result.type === "match") { + const match: SearchMatch = { + path: result.data.path.text, + line: result.data.line_number, + column: result.data.column_number, + text: result.data.lines.text, + }; + this.results.val = [...this.results.val, match]; + } + + if (result.type === "summary") { + this.isSearching.val = false; + } + }); + } catch (err) { + console.error("Search failed", err); + this.isSearching.val = false; + } + } + + focus() { + const input = document.getElementById("search-input") as HTMLInputElement; + input?.focus(); + } + + close(): boolean { + // TODO: stop ripgrep + if (this.unsubscribeResult) { + this.unsubscribeResult(); + } + return true; + } + + + title(): string { + return "Search"; + } + + get dom() { + return v.div( + { class: "flex flex-col h-full" }, + v.div( + { class: "p-2 border-b dark:border-gray-700 flex gap-2" }, + v.input( + { + id: "search-input", + class: "flex-1 p-1 border rounded dark:bg-gray-800 dark:text-white outline-none", + placeholder: "Search...", + value: this.query, + onkeydown: (e: KeyboardEvent) => { + if (e.key === "Enter") { + this.startSearch(); + } + }, + oninput: (e: any) => (this.query.val = e.target.value), + }, + ), + u.Button(() => this.startSearch(), "🔍"), + ), + v.div( + { class: "flex-1 overflow-y-auto p-2" }, + () => this.isSearching.val + ? v.p({ class: "text-center text-gray-500" }, "Searching...") + : v.div( + this.results.val.map((match) => + v.div( + { + class: "p-2 mb-2 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900 rounded border border-transparent hover:border-blue-300", + onclick: async () => { + // TODO: use absolute path! + const file = await OpenFile.openFile(match.path); + if (file) { + const editor = addEditor(file); + editor.jumpTo(match.line); + } + }, + }, + v.div({ class: "text-xs text-gray-500 dark:text-gray-400" }, match.path), + v.div({ class: "text-sm font-mono" }, `${match.line}:${match.column} ${match.text}`), + ) + ) + ) + ) + ); + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 8252e46..1b696fe 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,7 @@ import path from "node:path"; import started from "electron-squirrel-startup"; import { setupLangServer } from "./langserver"; import { setMenu } from "./menu"; +import { searchService } from "./searchService"; /// // Handle creating/removing shortcuts on Windows when installing/uninstalling. @@ -139,6 +140,19 @@ app.whenReady().then(() => { return terminalManager.closeTerminal(id); }); + ipcMain.handle("search:start", async (event, args: string[]) => { + const senderWindow = BrowserWindow.fromWebContents(event.sender); + if (!senderWindow) return; + + await searchService.startSearch( + senderWindow, + args, + (result) => { + senderWindow.webContents.send("search:result", result); + }, + ); + }); + createWindow(); if (process.platform === "darwin") { app.on("activate", function () { diff --git a/src/main/searchService.ts b/src/main/searchService.ts new file mode 100644 index 0000000..71fedd9 --- /dev/null +++ b/src/main/searchService.ts @@ -0,0 +1,72 @@ +import { ChildProcess, spawn } from "child_process"; +import { rgPath } from "@vscode/ripgrep"; +import { BrowserWindow } from "electron"; +import { getCurrentWorkspaceRoot } from "./fileOperations"; + +export interface SearchResult { + type: "match" | "error" | "line" | "path" | "context"; + data: any; +} + +export class SearchService { + private currentProcess: ChildProcess = null; + + async startSearch( + mainWindow: BrowserWindow, + args: string[], + onResult: (result: any) => void, + ) { + this.stopSearch(); + + const cwd = getCurrentWorkspaceRoot(); + console.log("Spawning ripgrep: ", rgPath, cwd, args); + + this.currentProcess = spawn(rgPath, args, {cwd}); + + this.currentProcess.stdin.end(); + + this.currentProcess.stdout.on("data", (data: Buffer) => { + console.log("ripgrep data: ", data.toString()); + const lines = data.toString().split("\n"); + for (const line of lines) { + if (line.trim()) { + try { + const json = JSON.parse(line); + onResult(json); + } catch (e) { + console.error("Failed to parse ripgrep JSON line:", line, e); + } + } + } + }); + + this.currentProcess.stderr.on("data", (data: Buffer) => { + const errorLine = data.toString().trim(); + if (errorLine) { + console.error("ripgrep stderr:", errorLine); + } + }); + + this.currentProcess.on("close", (code: number) => { + console.log(`ripgrep process exited with code ${code}`); + this.currentProcess = null; + }); + + this.currentProcess.on("error", (err: any) => { + console.log("Ripgrep failed to start: ", err); + }); + + this.currentProcess.on("exit", (code: number) => { + console.log("Ripgrep exited: ", code); + }) + } + + stopSearch() { + if (this.currentProcess) { + this.currentProcess.kill(); + this.currentProcess = null; + } + } +} + +export const searchService = new SearchService(); diff --git a/src/preload.ts b/src/preload.ts index 891211d..5baf5c0 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -85,6 +85,18 @@ contextBridge.exposeInMainWorld("electronAPI", { closeTerminal: (id: string) => ipcRenderer.invoke("terminal:close", id) as Promise, + // Search operations + startSearch: (args: string[]) => + ipcRenderer.invoke("search:start", args) as Promise, + + onSearchResult: (callback: (result: any) => void) => { + const listener = (_ev: any, result: any) => { + callback(result); + }; + ipcRenderer.on("search:result", listener); + return () => ipcRenderer.removeListener("search:result", listener); + }, + // Terminal events (subscribe/unsubscribe) onTerminalData: (id: string, callback: (data: string) => void) => { terminalDataCallbacks.set(id, callback); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index af0959d..809c305 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -77,6 +77,9 @@ declare global { language?: string; root?: string; }) => Promise; + + startSearch: (args: string[]) => Promise; + onSearchResult: (callback: any) => any; }; } }