Compare commits

...

1 Commits

Author SHA1 Message Date
Quinten Kock 637801b0d1 Add search pane 2026-05-24 21:37:57 +02:00
9 changed files with 444 additions and 2 deletions

194
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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<Displayable>,
@ -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<Editor[]>, 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();
}
}

130
src/app/searchPane.ts Normal file
View File

@ -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<SearchMatch[]>([]);
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}`),
)
)
)
)
);
}
}

View File

@ -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";
/// <reference types="./forge-vite-env.d.ts" />
// 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 () {

72
src/main/searchService.ts Normal file
View File

@ -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();

View File

@ -85,6 +85,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
closeTerminal: (id: string) =>
ipcRenderer.invoke("terminal:close", id) as Promise<boolean>,
// Search operations
startSearch: (args: string[]) =>
ipcRenderer.invoke("search:start", args) as Promise<void>,
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);

View File

@ -77,6 +77,9 @@ declare global {
language?: string;
root?: string;
}) => Promise<any>;
startSearch: (args: string[]) => Promise<any>;
onSearchResult: (callback: any) => any;
};
}
}