{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "editor-search-replace",
  "type": "registry:component",
  "title": "Search and replace",
  "description": "Find, match count, next/previous and replace-all",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2",
    "react@^19.2.4"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-primitives.json",
    "https://gpuixui.sudokw.com/gpuix-tokens.json",
    "https://gpuixui.sudokw.com/gpuix-tw.json"
  ],
  "files": [
    {
      "path": "packages/editor/src/internal/recipe.ts",
      "target": "@ui/gpuixui/editor/internal/recipe.ts",
      "type": "registry:lib",
      "content": "import type { StyleDesc } from \"@gpuix/react\";\nimport { classesToStyle } from \"../../tw/index.js\";\nimport type { Theme } from \"../../tokens/index.js\";\n\nexport const recipeStyle = (recipe: string, theme: Theme): StyleDesc =>\n  classesToStyle(recipe, theme).style;\n"
    },
    {
      "path": "packages/editor/src/internal/text.ts",
      "target": "@ui/gpuixui/editor/internal/text.ts",
      "type": "registry:lib",
      "content": "import { accessibleName } from \"../../primitives/core/index.js\";\n\nexport const draftOf = (value: unknown): string =>\n  typeof value === \"string\" ? value : \"\";\n\nexport const labelOf = (label: unknown, fallback: string): string =>\n  accessibleName(draftOf(label)) ?? fallback;\n"
    },
    {
      "path": "packages/editor/src/search/logic.ts",
      "target": "@ui/gpuixui/editor/search/logic.ts",
      "type": "registry:lib",
      "content": "import * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\n\nexport interface MatchRange {\n  readonly start: number;\n  readonly end: number;\n}\n\nexport interface FindOptions {\n  readonly caseSensitive?: boolean;\n\n  readonly wholeWord?: boolean;\n}\n\nconst WordCharacter = /[\\p{L}\\p{N}_]/u;\n\nconst isWordCharacter = (character: string | undefined): boolean =>\n  character !== undefined && WordCharacter.test(character);\n\nconst isWholeWord = (text: string, range: MatchRange): boolean =>\n  !isWordCharacter(text[range.start - 1]) && !isWordCharacter(text[range.end]);\n\nconst escapePattern = (query: string): string =>\n  query.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\nexport const findRanges = (\n  text: string,\n  query: string,\n  options: FindOptions = {},\n): ReadonlyArray<MatchRange> => {\n  if (query.length === 0) return [];\n  const candidates = occurrences(text, query, options.caseSensitive !== false);\n  const accepted =\n    options.wholeWord === true\n      ? Arr.filter(candidates, (range) => isWholeWord(text, range))\n      : candidates;\n  return nonOverlapping(accepted);\n};\n\nconst occurrences = (\n  text: string,\n  query: string,\n  caseSensitive: boolean,\n): ReadonlyArray<MatchRange> =>\n  caseSensitive ? findExact(text, query) : findCaseInsensitive(text, query);\n\nconst findExact = (text: string, query: string): ReadonlyArray<MatchRange> =>\n  Arr.unfold(\n    0,\n    (from: number): Option.Option<readonly [MatchRange, number]> => {\n      const index = text.indexOf(query, from);\n      return index === -1\n        ? Option.none()\n        : Option.some([\n            { start: index, end: index + query.length },\n            index + 1,\n          ] as const);\n    },\n  );\n\nconst findCaseInsensitive = (\n  text: string,\n  query: string,\n): ReadonlyArray<MatchRange> => {\n  const pattern = new RegExp(escapePattern(query), \"gi\");\n  return Arr.unfold(\n    0,\n    (from: number): Option.Option<readonly [MatchRange, number]> => {\n      pattern.lastIndex = from;\n      const found = pattern.exec(text);\n      return found === null\n        ? Option.none()\n        : Option.some([\n            { start: found.index, end: found.index + found[0].length },\n            found.index + 1,\n          ] as const);\n    },\n  );\n};\n\nconst nonOverlapping = (\n  ranges: ReadonlyArray<MatchRange>,\n): ReadonlyArray<MatchRange> => {\n  const [, kept] = Arr.mapAccum(\n    ranges,\n    -1,\n    (end, range): readonly [number, Option.Option<MatchRange>] =>\n      range.start >= end\n        ? [range.end, Option.some(range)]\n        : [end, Option.none()],\n  );\n  return Arr.getSomes(kept);\n};\n\nexport const matchReadout = (\n  query: string,\n  active: number,\n  count: number,\n): string =>\n  query.length === 0\n    ? \"\"\n    : count === 0\n      ? \"No matches\"\n      : `${active + 1} / ${count}`;\n\nexport const replaceAll = (\n  text: string,\n  query: string,\n  replacement: string,\n  options: FindOptions = {},\n): string => {\n  const ranges = findRanges(text, query, options);\n  return Arr.length(ranges) === 0\n    ? text\n    : Arr.reduce(\n        Arr.reverse(ranges),\n        text,\n        (result, range) =>\n          result.slice(0, range.start) + replacement + result.slice(range.end),\n      );\n};\n"
    },
    {
      "path": "packages/editor/src/search/search-replace.tsx",
      "target": "@ui/gpuixui/editor/search/search-replace.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { KeyEvent } from \"@gpuix/react\";\nimport { Button, Input, Toggle } from \"../../primitives/index.js\";\nimport { cn } from \"../../tw/index.js\";\nimport { useTheme } from \"../../tokens/react.js\";\nimport * as Arr from \"effect/Array\";\nimport { useState } from \"react\";\nimport { findRanges, matchReadout, replaceAll } from \"./logic.js\";\nimport { recipeStyle } from \"../internal/recipe.js\";\nimport { draftOf, labelOf } from \"../internal/text.js\";\n\nconst SEARCH_ROOT = \"flex flex-row items-center gap-2 flex-wrap\";\n\nconst FIND_NAME = \"Find\";\nconst REPLACE_FIELD_NAME = \"Replace with\";\nconst REPLACE_NAME = \"Replace\";\nconst REPLACE_ALL_NAME = \"Replace all\";\nconst NEXT_NAME = \"Next match\";\nconst PREVIOUS_NAME = \"Previous match\";\n\nexport interface SearchReplaceProps {\n  readonly value: string;\n\n  readonly onValueChange: (value: string) => void;\n\n  readonly placeholder?: string;\n\n  readonly replacePlaceholder?: string;\n\n  readonly findLabel?: string;\n\n  readonly replaceFieldLabel?: string;\n\n  readonly replaceLabel?: string;\n\n  readonly replaceAllLabel?: string;\n\n  readonly nextLabel?: string;\n\n  readonly previousLabel?: string;\n\n  readonly disabled?: boolean;\n\n  readonly className?: string;\n  readonly testId?: string;\n}\n\nexport const SearchReplace = ({\n  value,\n  onValueChange,\n  placeholder = \"Find\",\n  replacePlaceholder = \"Replace with\",\n  findLabel,\n  replaceFieldLabel,\n  replaceLabel,\n  replaceAllLabel,\n  nextLabel,\n  previousLabel,\n  disabled = false,\n  className,\n  testId = \"search-replace\",\n}: SearchReplaceProps) => {\n  const theme = useTheme();\n\n  const findName = labelOf(findLabel, FIND_NAME);\n  const replaceFieldName = labelOf(replaceFieldLabel, REPLACE_FIELD_NAME);\n  const replaceName = labelOf(replaceLabel, REPLACE_NAME);\n  const replaceAllName = labelOf(replaceAllLabel, REPLACE_ALL_NAME);\n  const nextName = labelOf(nextLabel, NEXT_NAME);\n  const previousName = labelOf(previousLabel, PREVIOUS_NAME);\n  const [query, setQuery] = useState(\"\");\n  const [replacement, setReplacement] = useState(\"\");\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [matchCase, setMatchCase] = useState(true);\n  const [wholeWord, setWholeWord] = useState(false);\n\n  const document = draftOf(value);\n  const options = { caseSensitive: matchCase, wholeWord };\n  const ranges = findRanges(document, query, options);\n  const count = ranges.length;\n  const active = count === 0 ? 0 : Math.min(activeIndex, count - 1);\n\n  const readout = matchReadout(query, active, count);\n\n  const go = (step: 1 | -1): void => {\n    if (disabled || count === 0) return;\n    setActiveIndex((active + step + count) % count);\n  };\n\n  const changeQuery = (next: string): void => {\n    setQuery(next);\n    setActiveIndex(0);\n  };\n\n  const changeMatchCase = (next: boolean): void => {\n    setMatchCase(next);\n    setActiveIndex(0);\n  };\n\n  const changeWholeWord = (next: boolean): void => {\n    setWholeWord(next);\n    setActiveIndex(0);\n  };\n\n  const replaceOne = (): void => {\n    if (disabled || count === 0) return;\n    const range = Arr.getUnsafe(ranges, active);\n    onValueChange(\n      document.slice(0, range.start) + replacement + document.slice(range.end),\n    );\n  };\n\n  const replaceEvery = (): void => {\n    if (disabled || count === 0) return;\n    onValueChange(replaceAll(document, query, replacement, options));\n  };\n\n  const onKeyDown = (event: KeyEvent): void => {\n    if (disabled) return;\n    if (event.key === \"f3\") {\n      event.preventDefault();\n      go(event.modifiers?.shift === true ? -1 : 1);\n    }\n  };\n\n  return (\n    <div testId={testId} style={recipeStyle(cn(SEARCH_ROOT, className), theme)}>\n      <Input\n        value={query}\n        onValueChange={changeQuery}\n        onKeyDown={onKeyDown}\n        placeholder={placeholder}\n        label={findName}\n        disabled={disabled}\n        testId={`${testId}-find`}\n        style={{ flexGrow: 1 }}\n      />\n      <Toggle\n        variant=\"outline\"\n        size=\"sm\"\n        square\n        pressed={matchCase}\n        onPressedChange={changeMatchCase}\n        label=\"Match case\"\n        disabled={disabled}\n        testId={`${testId}-case`}\n      >\n        Aa\n      </Toggle>\n      <Toggle\n        variant=\"outline\"\n        size=\"sm\"\n        square\n        pressed={wholeWord}\n        onPressedChange={changeWholeWord}\n        label=\"Match whole word\"\n        disabled={disabled}\n        testId={`${testId}-word`}\n      >\n        W\n      </Toggle>\n      {readout.length === 0 ? null : (\n        <text role=\"status\" aria-label={readout} testId={`${testId}-count`}>\n          {readout}\n        </text>\n      )}\n      <Button\n        variant=\"outline\"\n        size=\"sm\"\n        label={previousName}\n        disabled={disabled || count === 0}\n        onActivate={() => go(-1)}\n      >\n        ↑\n      </Button>\n      <Button\n        variant=\"outline\"\n        size=\"sm\"\n        label={nextName}\n        disabled={disabled || count === 0}\n        onActivate={() => go(1)}\n      >\n        ↓\n      </Button>\n      <Input\n        value={replacement}\n        onValueChange={setReplacement}\n        onKeyDown={onKeyDown}\n        placeholder={replacePlaceholder}\n        label={replaceFieldName}\n        disabled={disabled}\n        testId={`${testId}-replace`}\n        style={{ flexGrow: 1 }}\n      />\n      <Button\n        variant=\"secondary\"\n        size=\"sm\"\n        label={replaceName}\n        disabled={disabled || count === 0}\n        testId={`${testId}-replace-one`}\n        onActivate={replaceOne}\n      >\n        {replaceName}\n      </Button>\n      <Button\n        variant=\"outline\"\n        size=\"sm\"\n        label={replaceAllName}\n        disabled={disabled || count === 0}\n        testId={`${testId}-replace-all`}\n        onActivate={replaceEvery}\n      >\n        {replaceAllName}\n      </Button>\n    </div>\n  );\n};\n\nexport const searchReplaceMeta = {\n  name: \"Search and replace\",\n  tier: 4,\n  status: \"done\",\n  gpuixSupport: \"partial\",\n  limitations: [\n    \"the matcher is a literal scan — no regex mode; `Aa` (match case, on by default) and `W` (whole word, off) toggle the query\",\n    \"the active match cannot move the editor's caret or scroll to it: the runtime reports no caret or scroll offset\",\n    \"the bar owns the find/replace fields, not the document text; the caller applies every replacement\",\n  ],\n  docs: \"docs/search-replace.mdx\",\n  example: \"examples/search-replace.example.tsx\",\n  testFile: \"src/search/logic.test.ts\",\n} as const;\n"
    }
  ],
  "docs": "[Search and replace](https://gpuixui.sudokw.com/editor/search-replace) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/editor/search-replace",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}