diff --git a/README.md b/README.md index b149fbd..0b6e8fb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin | [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | | [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | | [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | +| [`@wterm/search`](packages/@wterm/search) | Grid and scrollback search | ## Features diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 0058866..2ab414c 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -405,3 +405,68 @@ interface CursorState { visible: boolean; } ``` + +## @wterm/search + +Search across both terminal grid rows and scrollback rows. + +### `Search` + +```ts +import { Search } from "@wterm/search"; + +const search = new Search(term); +const match = search.findNext("error"); +``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodDescription
new Search(term)Create a search instance for an initialized WTerm
findNext(query, opts?)Find the next match after the internal cursor
findPrevious(query, opts?)Find the previous match before the internal cursor
findAll(query, opts?)Return all matches in oldest-first order (scrollback then grid)
reset()Clear the internal cursor so the next findNext starts from the top
+ +### `SearchOptions` + +```ts +interface SearchOptions { + caseSensitive?: boolean; + regex?: boolean; + wholeWord?: boolean; +} +``` + +### `SearchMatch` + +```ts +interface SearchMatch { + row: number; // negative for scrollback, 0+ for grid + col: number; + length: number; + text: string; +} +``` diff --git a/apps/docs/src/app/search/layout.tsx b/apps/docs/src/app/search/layout.tsx new file mode 100644 index 0000000..a24c3c0 --- /dev/null +++ b/apps/docs/src/app/search/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("search"); + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/docs/src/app/search/page.mdx b/apps/docs/src/app/search/page.mdx new file mode 100644 index 0000000..93bd60a --- /dev/null +++ b/apps/docs/src/app/search/page.mdx @@ -0,0 +1,142 @@ +# Search + +`@wterm/search` adds grid and scrollback search to wterm terminals. Browser `Ctrl+F` can only search visible DOM rows, but most terminal history lives in WASM scrollback. This package searches both. + +## Why + +Terminal apps often emit errors above the visible viewport. The browser can only find text already rendered in the DOM, so searching misses history still in scrollback. `@wterm/search` scans scrollback and grid together, in buffer order. + +## Install + +```bash +npm install @wterm/search +``` + +## Quick Start + +### Vanilla JS + +```js +import { WTerm } from "@wterm/dom"; +import { Search } from "@wterm/search"; +import "@wterm/dom/css"; + +const term = new WTerm(document.getElementById("terminal")); +await term.init(); + +term.write("error: db unavailable\\r\\n"); +term.write("info: retrying\\r\\n"); + +const search = new Search(term); +const match = search.findNext("error"); + +if (match) { + console.log(match.row, match.col, match.text); +} +``` + +## API Reference + +### SearchOptions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefaultDescription
caseSensitivebooleanfalseMatch case exactly
regexbooleanfalseTreat query as a regular expression
wholeWordbooleanfalseMatch only whole words
+ +### SearchMatch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
rownumberMatch row (-1 is most recent scrollback line, 0+ is grid row)
colnumberZero-based column of the match
lengthnumberMatch length in characters
textstringMatched text
+ +### Search + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodDescription
new Search(term)Create a search instance for an initialized terminal
findNext(query, opts?)Find the next match after the current cursor position
findPrevious(query, opts?)Find the previous match before the current cursor position
findAll(query, opts?)Return all matches in oldest-first order (scrollback then grid)
reset()Reset the internal cursor so the next findNext starts from the top
+ +## Limitations + +`@wterm/search` returns match positions and text only. Visual highlighting through the CSS Highlight API is a follow-up PR. diff --git a/apps/docs/src/lib/docs-navigation.ts b/apps/docs/src/lib/docs-navigation.ts index 854fa00..6d9ea12 100644 --- a/apps/docs/src/lib/docs-navigation.ts +++ b/apps/docs/src/lib/docs-navigation.ts @@ -34,6 +34,7 @@ export const navGroups: NavGroup[] = [ items: [ { name: "Just Bash", href: "/just-bash" }, { name: "Markdown", href: "/markdown" }, + { name: "Search", href: "/search" }, { name: "Core / Advanced", href: "/core" }, ], }, diff --git a/apps/docs/src/lib/page-titles.ts b/apps/docs/src/lib/page-titles.ts index ef9bedb..ecbdcc7 100644 --- a/apps/docs/src/lib/page-titles.ts +++ b/apps/docs/src/lib/page-titles.ts @@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = { vanilla: "Vanilla JS", "just-bash": "Just Bash", markdown: "Markdown", + search: "Search", core: "Core / Advanced", "api-reference": "API Reference", }; diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md index 34c04e3..cab7bbd 100644 --- a/packages/@wterm/core/README.md +++ b/packages/@wterm/core/README.md @@ -10,6 +10,7 @@ Headless terminal emulator core for [wterm](https://github.com/vercel-labs/wterm | [`@wterm/react`](https://www.npmjs.com/package/@wterm/react) | React component + `useTerminal` hook | | [`@wterm/just-bash`](https://www.npmjs.com/package/@wterm/just-bash) | In-browser Bash shell powered by just-bash | | [`@wterm/markdown`](https://www.npmjs.com/package/@wterm/markdown) | Streaming Markdown-to-ANSI renderer for terminals | +| [`@wterm/search`](https://www.npmjs.com/package/@wterm/search) | Grid and scrollback search | ## Install diff --git a/packages/@wterm/search/README.md b/packages/@wterm/search/README.md new file mode 100644 index 0000000..05d7828 --- /dev/null +++ b/packages/@wterm/search/README.md @@ -0,0 +1,71 @@ +# @wterm/search + +Grid and scrollback search for [wterm](https://github.com/vercel-labs/wterm) terminals. + +## Search + +Search across both visible rows and WASM-backed scrollback. + +## Install + +```bash +npm install @wterm/search +``` + +## Quick Start + +```ts +import { WTerm } from "@wterm/dom"; +import { Search } from "@wterm/search"; +import "@wterm/dom/css"; + +const term = new WTerm(document.getElementById("terminal")); +await term.init(); + +term.write("error: failed to connect\\r\\n"); +term.write("warning: retrying\\r\\n"); + +const search = new Search(term); +const match = search.findNext("error"); + +if (match) { + console.log(match.row, match.col, match.text); +} +``` + +## API + +### `SearchOptions` + +| Option | Type | Default | Description | +|---|---|---|---| +| `caseSensitive` | `boolean` | `false` | Match case exactly | +| `regex` | `boolean` | `false` | Treat `query` as a regular expression | +| `wholeWord` | `boolean` | `false` | Match only whole words | + +### `SearchMatch` + +| Field | Type | Description | +|---|---|---| +| `row` | `number` | Match row (`-1` is most recent scrollback line, `0+` is grid row) | +| `col` | `number` | Zero-based column of the match | +| `length` | `number` | Match length in characters | +| `text` | `string` | Matched text | + +### `Search` + +| Method | Description | +|---|---| +| `new Search(term)` | Create a search instance for an initialized `WTerm` | +| `findNext(query, opts?)` | Find the next match after the internal cursor | +| `findPrevious(query, opts?)` | Find the previous match before the internal cursor | +| `findAll(query, opts?)` | Return all matches in oldest-first buffer order | +| `reset()` | Clear the internal cursor | + +## Limitations + +`@wterm/search` returns match positions and text. Visual highlighting (for example via the CSS Highlight API) is planned as a follow-up PR. + +## License + +Apache-2.0 diff --git a/packages/@wterm/search/package.json b/packages/@wterm/search/package.json new file mode 100644 index 0000000..c3b4c12 --- /dev/null +++ b/packages/@wterm/search/package.json @@ -0,0 +1,46 @@ +{ + "name": "@wterm/search", + "version": "0.1.8", + "description": "Grid and scrollback search for wterm terminals", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "pnpm build", + "test": "vitest run", + "type-check": "tsc --noEmit" + }, + "peerDependencies": { + "@wterm/core": "workspace:*", + "@wterm/dom": "workspace:*" + }, + "devDependencies": { + "@internal/ts": "workspace:*", + "typescript": "^6.0.2" + }, + "keywords": [ + "terminal", + "wterm", + "search", + "scrollback", + "find" + ], + "license": "Apache-2.0", + "homepage": "https://wterm.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/wterm", + "directory": "packages/@wterm/search" + } +} diff --git a/packages/@wterm/search/src/__tests__/engine.test.ts b/packages/@wterm/search/src/__tests__/engine.test.ts new file mode 100644 index 0000000..071fc9a --- /dev/null +++ b/packages/@wterm/search/src/__tests__/engine.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { compileMatcher } from "../engine.js"; + +describe("compileMatcher", () => { + it("literal match is case-insensitive by default", () => { + const matcher = compileMatcher("error"); + expect(matcher.test("ERROR")).toBe(true); + }); + + it("caseSensitive true switches to case-sensitive", () => { + const matcher = compileMatcher("Error", { caseSensitive: true }); + expect(matcher.test("Error")).toBe(true); + matcher.lastIndex = 0; + expect(matcher.test("error")).toBe(false); + }); + + it("regex true compiles user regex", () => { + const matcher = compileMatcher("err(or)?", { regex: true }); + expect(matcher.test("error")).toBe(true); + matcher.lastIndex = 0; + expect(matcher.test("err")).toBe(true); + }); + + it("wholeWord wraps in word boundaries", () => { + const matcher = compileMatcher("error", { wholeWord: true }); + expect(matcher.test("an error happened")).toBe(true); + matcher.lastIndex = 0; + expect(matcher.test("terror")).toBe(false); + }); + + it("invalid regex throws wrapped error", () => { + expect(() => compileMatcher("(", { regex: true })).toThrow( + /^wterm: invalid search pattern:/, + ); + }); +}); diff --git a/packages/@wterm/search/src/__tests__/helpers.ts b/packages/@wterm/search/src/__tests__/helpers.ts new file mode 100644 index 0000000..eb790ae --- /dev/null +++ b/packages/@wterm/search/src/__tests__/helpers.ts @@ -0,0 +1,17 @@ +import type { WTerm } from "@wterm/dom"; +import { WasmBridge } from "@wterm/core"; + +export async function makeTerm( + cols: number, + rows: number, +): Promise> { + const bridge = await WasmBridge.load(); + bridge.init(cols, rows); + return { + bridge, + write(data: string | Uint8Array) { + if (typeof data === "string") bridge.writeString(data); + else bridge.writeRaw(data); + }, + }; +} diff --git a/packages/@wterm/search/src/__tests__/iterator.test.ts b/packages/@wterm/search/src/__tests__/iterator.test.ts new file mode 100644 index 0000000..d3da349 --- /dev/null +++ b/packages/@wterm/search/src/__tests__/iterator.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { iterRows } from "../engine.js"; +import { makeTerm } from "./helpers.js"; + +describe("iterRows", () => { + it("yields oldest scrollback rows first, then grid rows", async () => { + const term = await makeTerm(80, 3); + + for (let i = 0; i < 8; i++) { + term.write(`iter-${i}\r\n`); + } + + const rows = [...iterRows(term.bridge!)]; + const negatives = rows.filter((r) => r.row < 0); + const grid = rows.filter((r) => r.row >= 0); + + if (negatives.length > 1) { + for (let i = 1; i < negatives.length; i++) { + expect(negatives[i]!.row).toBe(negatives[i - 1]!.row + 1); + } + } + + expect(grid.map((r) => r.row)).toEqual([0, 1, 2]); + + if (negatives.length > 0 && grid.length > 0) { + expect(rows.indexOf(negatives[0]!)).toBeLessThan(rows.indexOf(grid[0]!)); + } + }); +}); diff --git a/packages/@wterm/search/src/__tests__/scrollback.test.ts b/packages/@wterm/search/src/__tests__/scrollback.test.ts new file mode 100644 index 0000000..fb78889 --- /dev/null +++ b/packages/@wterm/search/src/__tests__/scrollback.test.ts @@ -0,0 +1,21 @@ +import type { WTerm } from "@wterm/dom"; +import { describe, expect, it } from "vitest"; +import { Search } from "../index.js"; +import { makeTerm } from "./helpers.js"; + +describe("Search scrollback", () => { + it("finds matches in negative-row scrollback entries", async () => { + const term = await makeTerm(80, 10); + + for (let i = 0; i < 30; i++) { + term.write(`line-${i}\r\n`); + } + + const search = new Search(term as WTerm); + const match = search.findNext("line-0"); + + expect(match).not.toBeNull(); + expect(match!.row).toBeLessThan(0); + expect(match!.text).toBe("line-0"); + }); +}); diff --git a/packages/@wterm/search/src/__tests__/search.test.ts b/packages/@wterm/search/src/__tests__/search.test.ts new file mode 100644 index 0000000..ee9c13d --- /dev/null +++ b/packages/@wterm/search/src/__tests__/search.test.ts @@ -0,0 +1,105 @@ +import type { WTerm } from "@wterm/dom"; +import { describe, expect, it } from "vitest"; +import { Search } from "../index.js"; +import { makeTerm } from "./helpers.js"; + +describe("Search", () => { + it("throws when terminal is not initialized", () => { + expect(() => new Search({ bridge: null } as unknown as WTerm)).toThrow( + "wterm: search requires an initialized terminal", + ); + }); + + it("findNext returns first match", async () => { + const term = await makeTerm(40, 4); + term.write("error one\r\nerror two\r\n"); + + const search = new Search(term as WTerm); + const match = search.findNext("error"); + + expect(match).toEqual({ row: 0, col: 0, length: 5, text: "error" }); + }); + + it("successive findNext returns second match", async () => { + const term = await makeTerm(40, 4); + term.write("error one error two\r\n"); + + const search = new Search(term as WTerm); + const first = search.findNext("error"); + const second = search.findNext("error"); + + expect(first?.col).toBe(0); + expect(second?.col).toBeGreaterThan(0); + }); + + it("findNext returns null when no more matches and cursor stays put", async () => { + const term = await makeTerm(40, 4); + term.write("error\r\n"); + + const search = new Search(term as WTerm); + const first = search.findNext("error"); + const none = search.findNext("error"); + const prev = search.findPrevious("error"); + + expect(first).not.toBeNull(); + expect(none).toBeNull(); + expect(prev).not.toBeNull(); + expect(prev?.col).toBe(first?.col); + }); + + it("findPrevious walks backward", async () => { + const term = await makeTerm(40, 4); + term.write("error a error b error c\r\n"); + + const search = new Search(term as WTerm); + const m1 = search.findNext("error"); + const m2 = search.findNext("error"); + const m3 = search.findNext("error"); + + const back1 = search.findPrevious("error"); + const back2 = search.findPrevious("error"); + + expect(m1?.col).toBe(0); + expect(m2?.col).toBeGreaterThan(m1!.col); + expect(m3?.col).toBeGreaterThan(m2!.col); + expect(back1?.col).toBe(m3?.col); + expect(back2?.col).toBe(m2?.col); + }); + + it("reset returns cursor to top", async () => { + const term = await makeTerm(40, 4); + term.write("error one error two\r\n"); + + const search = new Search(term as WTerm); + const first = search.findNext("error"); + search.findNext("error"); + search.reset(); + const afterReset = search.findNext("error"); + + expect(afterReset?.col).toBe(first?.col); + }); + + it("findAll returns all matches in oldest-first order", async () => { + const term = await makeTerm(40, 3); + term.write("match-1\r\nmatch-2\r\nmatch-3\r\nmatch-4\r\n"); + + const search = new Search(term as WTerm); + const matches = search.findAll("match"); + + expect(matches.length).toBeGreaterThan(0); + const rows = matches.map((m) => m.row); + const sorted = [...rows].sort((a, b) => a - b); + expect(rows).toEqual(sorted); + }); + + it("empty query returns null and empty list", async () => { + const term = await makeTerm(40, 4); + term.write("anything\r\n"); + + const search = new Search(term as WTerm); + + expect(search.findNext("")).toBeNull(); + expect(search.findPrevious("")).toBeNull(); + expect(search.findAll("")).toEqual([]); + }); +}); diff --git a/packages/@wterm/search/src/engine.ts b/packages/@wterm/search/src/engine.ts new file mode 100644 index 0000000..c6571b4 --- /dev/null +++ b/packages/@wterm/search/src/engine.ts @@ -0,0 +1,156 @@ +import type { SearchMatch, SearchOptions } from "./index.js"; + +export interface BridgeLike { + getScrollbackCount(): number; + getScrollbackLineLen(offset: number): number; + getScrollbackCell( + offset: number, + col: number, + ): { + char: number; + fg: number; + bg: number; + flags: number; + }; + getRows(): number; + getCols(): number; + getCell( + row: number, + col: number, + ): { + char: number; + fg: number; + bg: number; + flags: number; + }; +} + +export interface RowText { + row: number; + text: string; + trimmedLen: number; +} + +const DEFAULT_COLOR = 256; +const DEFAULT_SPACE = 0x20; +const MAX_SCAN_MATCHES = 50000; + +function isDefaultSpaceCell(cell: { + char: number; + fg: number; + bg: number; + flags: number; +}): boolean { + return ( + cell.char === DEFAULT_SPACE && + cell.flags === 0 && + cell.fg === DEFAULT_COLOR && + cell.bg === DEFAULT_COLOR + ); +} + +function readLine( + len: number, + getCell: (col: number) => { + char: number; + fg: number; + bg: number; + flags: number; + }, +): { text: string; trimmedLen: number } { + const chars: string[] = new Array(len); + const cells = new Array(len); + + for (let col = 0; col < len; col++) { + const cell = getCell(col); + cells[col] = cell; + chars[col] = String.fromCodePoint(cell.char || DEFAULT_SPACE); + } + + let trimmedLen = len; + while (trimmedLen > 0 && isDefaultSpaceCell(cells[trimmedLen - 1]!)) { + trimmedLen--; + } + + return { + text: chars.slice(0, trimmedLen).join(""), + trimmedLen, + }; +} + +export function* iterRows(bridge: BridgeLike): IterableIterator { + const sbCount = bridge.getScrollbackCount(); + + for (let i = sbCount - 1; i >= 0; i--) { + const row = -(i + 1); + const len = bridge.getScrollbackLineLen(i); + const line = readLine(len, (col) => bridge.getScrollbackCell(i, col)); + yield { row, text: line.text, trimmedLen: line.trimmedLen }; + } + + const rows = bridge.getRows(); + const cols = bridge.getCols(); + + for (let row = 0; row < rows; row++) { + const line = readLine(cols, (col) => bridge.getCell(row, col)); + yield { row, text: line.text, trimmedLen: line.trimmedLen }; + } +} + +function escapeRegexLiteral(query: string): string { + return query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function compileMatcher( + query: string, + opts: SearchOptions = {}, +): RegExp { + const sourceBase = opts.regex ? query : escapeRegexLiteral(query); + const source = opts.wholeWord ? `\\b(?:${sourceBase})\\b` : sourceBase; + const flags = opts.caseSensitive ? "g" : "gi"; + + try { + return new RegExp(source, flags); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`wterm: invalid search pattern: ${message}`); + } +} + +export function findAllMatches( + bridge: BridgeLike, + matcher: RegExp, + limit = MAX_SCAN_MATCHES, +): SearchMatch[] { + const matches: SearchMatch[] = []; + + for (const row of iterRows(bridge)) { + matcher.lastIndex = 0; + + while (true) { + const match = matcher.exec(row.text); + if (!match) break; + + matches.push({ + row: row.row, + col: match.index, + length: match[0].length, + text: match[0], + }); + + if (matches.length >= limit) { + return matches; + } + + if (matcher.lastIndex === match.index) { + matcher.lastIndex++; + } + } + } + + return matches; +} + +export function getMaxScanMatches(): number { + return MAX_SCAN_MATCHES; +} diff --git a/packages/@wterm/search/src/index.ts b/packages/@wterm/search/src/index.ts new file mode 100644 index 0000000..d649803 --- /dev/null +++ b/packages/@wterm/search/src/index.ts @@ -0,0 +1,177 @@ +import type { WTerm } from "@wterm/dom"; +import { + compileMatcher, + findAllMatches, + getMaxScanMatches, + iterRows, +} from "./engine.js"; + +export interface SearchOptions { + caseSensitive?: boolean; + regex?: boolean; + wholeWord?: boolean; +} + +export interface SearchMatch { + // row: negative = scrollback (-1 is most recent scrollback line), 0+ = grid row + row: number; + col: number; + length: number; + text: string; +} + +interface Cursor { + row: number; + col: number; +} + +export class Search { + private _term: WTerm; + private _cursor: Cursor | null = null; + + constructor(term: WTerm) { + if (!term.bridge) { + throw new Error("wterm: search requires an initialized terminal"); + } + this._term = term; + } + + findNext(query: string, opts: SearchOptions = {}): SearchMatch | null { + if (!query) { + return null; + } + + const bridge = this._term.bridge; + if (!bridge) { + throw new Error("wterm: search requires an initialized terminal"); + } + + const matcher = compileMatcher(query, opts); + const maxScan = getMaxScanMatches(); + let scanned = 0; + + for (const row of iterRows(bridge)) { + if (this._cursor && row.row < this._cursor.row) { + continue; + } + + matcher.lastIndex = 0; + while (true) { + const match = matcher.exec(row.text); + if (!match) { + break; + } + + scanned++; + if (scanned > maxScan) { + return null; + } + + if ( + this._cursor && + row.row === this._cursor.row && + match.index <= this._cursor.col + ) { + if (matcher.lastIndex === match.index) { + matcher.lastIndex++; + } + continue; + } + + const found: SearchMatch = { + row: row.row, + col: match.index, + length: match[0].length, + text: match[0], + }; + + this._cursor = { + row: found.row, + col: found.col + found.length, + }; + + return found; + } + } + + return null; + } + + findPrevious(query: string, opts: SearchOptions = {}): SearchMatch | null { + if (!query) { + return null; + } + + const bridge = this._term.bridge; + if (!bridge) { + throw new Error("wterm: search requires an initialized terminal"); + } + + const matcher = compileMatcher(query, opts); + const maxScan = getMaxScanMatches(); + let scanned = 0; + + let candidate: SearchMatch | null = null; + + for (const row of iterRows(bridge)) { + matcher.lastIndex = 0; + + while (true) { + const match = matcher.exec(row.text); + if (!match) { + break; + } + + scanned++; + if (scanned > maxScan) { + return null; + } + + const found: SearchMatch = { + row: row.row, + col: match.index, + length: match[0].length, + text: match[0], + }; + + const isBeforeCursor = + this._cursor == null || + found.row < this._cursor.row || + (found.row === this._cursor.row && found.col < this._cursor.col); + + if (isBeforeCursor) { + candidate = found; + } + + if (matcher.lastIndex === match.index) { + matcher.lastIndex++; + } + } + } + + if (!candidate) { + return null; + } + + this._cursor = { row: candidate.row, col: candidate.col }; + return candidate; + } + + findAll(query: string, opts: SearchOptions = {}): SearchMatch[] { + if (!query) { + return []; + } + + const bridge = this._term.bridge; + if (!bridge) { + throw new Error("wterm: search requires an initialized terminal"); + } + + const matcher = compileMatcher(query, opts); + return findAllMatches(bridge, matcher); + } + + reset(): void { + this._cursor = null; + } +} diff --git a/packages/@wterm/search/tsconfig.json b/packages/@wterm/search/tsconfig.json new file mode 100644 index 0000000..67ad8c2 --- /dev/null +++ b/packages/@wterm/search/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@internal/ts/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/@wterm/search/vitest.config.ts b/packages/@wterm/search/vitest.config.ts new file mode 100644 index 0000000..4762117 --- /dev/null +++ b/packages/@wterm/search/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/__tests__/**"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0522f7c..c27166d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -503,6 +503,22 @@ importers: specifier: ^6.0.2 version: 6.0.2 + packages/@wterm/search: + dependencies: + '@wterm/core': + specifier: workspace:* + version: link:../core + '@wterm/dom': + specifier: workspace:* + version: link:../dom + devDependencies: + '@internal/ts': + specifier: workspace:* + version: link:../../@internal/ts + typescript: + specifier: ^6.0.2 + version: 6.0.2 + packages: '@adobe/css-tools@4.4.4': @@ -10402,7 +10418,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -10455,7 +10471,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10495,7 +10511,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9