diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8e3a577..077b492 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -20,6 +20,11 @@ body: - Unix timestamp converter - Text case converter - Number base converter + - UUID Generator & Inspector + - Cron Expression Explainer + - CSV/TSV Formatter & Validator + - Regex Tester + - Markdown Live Preview - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/.github/assets/og-case-converter.png b/.github/assets/og-case-converter.png new file mode 100644 index 0000000..bb5787f Binary files /dev/null and b/.github/assets/og-case-converter.png differ diff --git a/.github/assets/og-color-converter.png b/.github/assets/og-color-converter.png new file mode 100644 index 0000000..c49b9e3 Binary files /dev/null and b/.github/assets/og-color-converter.png differ diff --git a/.github/assets/og-cron-explainer.png b/.github/assets/og-cron-explainer.png new file mode 100644 index 0000000..ddf25d5 Binary files /dev/null and b/.github/assets/og-cron-explainer.png differ diff --git a/.github/assets/og-csv-formatter.png b/.github/assets/og-csv-formatter.png new file mode 100644 index 0000000..d89a7b0 Binary files /dev/null and b/.github/assets/og-csv-formatter.png differ diff --git a/.github/assets/og-home.png b/.github/assets/og-home.png index b14bfd3..eed0e80 100644 Binary files a/.github/assets/og-home.png and b/.github/assets/og-home.png differ diff --git a/.github/assets/og-markdown-preview.png b/.github/assets/og-markdown-preview.png new file mode 100644 index 0000000..aaeaff1 Binary files /dev/null and b/.github/assets/og-markdown-preview.png differ diff --git a/.github/assets/og-number-base-converter.png b/.github/assets/og-number-base-converter.png new file mode 100644 index 0000000..3da7fc5 Binary files /dev/null and b/.github/assets/og-number-base-converter.png differ diff --git a/.github/assets/og-regex-tester.png b/.github/assets/og-regex-tester.png new file mode 100644 index 0000000..1153508 Binary files /dev/null and b/.github/assets/og-regex-tester.png differ diff --git a/.github/assets/og-timestamp-converter.png b/.github/assets/og-timestamp-converter.png new file mode 100644 index 0000000..f9e7528 Binary files /dev/null and b/.github/assets/og-timestamp-converter.png differ diff --git a/.github/assets/og-url-encoder.png b/.github/assets/og-url-encoder.png new file mode 100644 index 0000000..be75f30 Binary files /dev/null and b/.github/assets/og-url-encoder.png differ diff --git a/.github/assets/og-uuid-generator.png b/.github/assets/og-uuid-generator.png new file mode 100644 index 0000000..c82fd84 Binary files /dev/null and b/.github/assets/og-uuid-generator.png differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99c5c4d..dd9ea8d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,6 +108,11 @@ CI also builds the Cloudflare Worker and requests every page from it in local wo 2. Create `app/(tools)//page.tsx`, a `Client.tsx` client component, and `opengraph-image.tsx`. Copying `page.tsx` and `opengraph-image.tsx` from `app/(tools)/base64/` and changing the slug is the easiest start. 3. Put pure logic in `lib/.ts` with tests in `lib/__tests__/.test.ts`. Tests can import with `@/` like the app does. 4. Add the tool to the "Which tool?" dropdown in `.github/ISSUE_TEMPLATE/bug_report.yml`. +5. Add the tool to the hand-written lists that the registry doesn't feed: + - `README.md`: an image in the "Tools" gallery and a line in the list under it. + - `.github/assets/`: the tool's share image as `og-.png` (1200 x 630). Run `npm run dev`, find the `og:image` path in the tool page's source (it has a suffix, such as `//opengraph-image-190rzf`) and save the PNG from it. + - The home share card: name the tool in the description in `renderHomeOgImage` in `lib/og.tsx`, keep it to two lines (check `http://localhost:3000/opengraph-image`), and save the new card over `.github/assets/og-home.png`. + - `package.json`: the tool in `description` and a keyword in `keywords`. The home page, metadata, JSON-LD, sitemap, manifest and command palette pick the new tool up from the registry automatically. Several people may be adding tools at the same time, so rebase on `develop` before asking for review. diff --git a/README.md b/README.md index e818d70..0b5d71d 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,62 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien Invoice Generator + + + URL Encoder and Decoder + + + Unix Timestamp Converter + + + + + Color Converter and Contrast Checker + + + Text Case Converter + + + + + Number Base Converter + + + UUID Generator and Inspector + + + + + Cron Expression Explainer + + + CSV/TSV Formatter and Validator + + + + + Regex Tester + + + Markdown Live Preview + + - **[JSON formatter and validator](https://tools.codercops.com/json-formatter).** Format, minify, validate, collapsible tree view, and JSON to CSV / TypeScript / YAML / XML / query string. Large numbers are preserved losslessly. - **[JWT decoder and inspector](https://tools.codercops.com/jwt-decoder).** Decode header, payload, and claims; verify HS/RS/PS/ES signatures via WebCrypto; security audit; HS256/384/512 encoder. - **[Base64 encoder and decoder](https://tools.codercops.com/base64).** Text, images, and files; URL-safe variant; data URIs; magic-byte detection; Base32/58/85 and hashes. - **[Invoice generator](https://tools.codercops.com/invoice-generator).** Live preview, vector PDF export with Unicode currency support, a reusable sender profile, all saved locally. +- **[URL encoder and decoder](https://tools.codercops.com/url-encoder).** Percent-encode or decode a URL, a query value, or form data, as a component or a full URL, with plus as space, and every query param broken out with duplicates kept in order. +- **[Unix timestamp converter](https://tools.codercops.com/timestamp-converter).** Convert Unix timestamps and dates with automatic unit detection, UTC, local time, ISO 8601, and relative time. - **[Color converter & contrast checker](https://tools.codercops.com/color-converter).** Convert between HEX, RGB, and HSL with alpha channel support, live swatch, and WCAG AA/AAA contrast ratios for normal and large text. +- **[Text case converter](https://tools.codercops.com/case-converter).** Convert text between camelCase, snake_case, kebab-case, and 7 more naming conventions. Handles acronyms, digits, and multi-line input. +- **[Number base converter](https://tools.codercops.com/number-base-converter).** Convert integers between binary, octal, decimal, hex and any base from 2 to 36, with two's complement for 8, 16, 32 and 64 bits. +- **[UUID Generator & Inspector](https://tools.codercops.com/uuid-generator).** Generate UUID v4 and v7 identifiers locally, create batches, customize formatting, and inspect version, variant, and v7 timestamps. +- **[Cron expression explainer](https://tools.codercops.com/cron-explainer).** Paste a crontab line and get a plain-English description, a field-by-field breakdown and the next run times in UTC or your local time. +- **[CSV/TSV formatter & validator](https://tools.codercops.com/csv-formatter).** Parse, validate, and pretty-print CSV/TSV. Auto-detect delimiters, convert to JSON, TypeScript, YAML, SQL, and fix common formatting issues. +- **[Regex tester](https://tools.codercops.com/regex-tester).** Test a JavaScript regular expression against sample text with highlighted matches, capture groups, named groups and a replace preview. +- **[Markdown live preview](https://tools.codercops.com/markdown-preview).** Write GitHub-flavored Markdown with a sanitized live HTML preview, syntax-highlighted code, stats, and export tools. ## Why it exists diff --git a/app/(tools)/base64/Base64Client.tsx b/app/(tools)/base64/Base64Client.tsx index a1fb155..d63b712 100644 --- a/app/(tools)/base64/Base64Client.tsx +++ b/app/(tools)/base64/Base64Client.tsx @@ -88,6 +88,7 @@ export function Base64Client() { ))} { decode("data:text/plain,hello"); expect(notice()).not.toBeInTheDocument(); }); + + it("exposes invalid base64 through an alert", async () => { + decode("SGVs!bG8="); + + expect(await screen.findByRole("alert")).toHaveTextContent(/Invalid base64/i); + }); + + it("reports an invalid character position in the original data URI", async () => { + const input = " data:text/plain;base64,SGV!bG8="; + decode(input); + + expect(await screen.findByRole("alert")).toHaveTextContent( + `position ${input.indexOf("!") + 1}`, + ); + }); +}); + +describe("DecodeTab hex toggle", () => { + afterEach(cleanup); + + it("reflects the hex dump state with aria-pressed", () => { + render( + + + , + ); + fireEvent.change(screen.getByLabelText("Base64 to decode"), { target: { value: "SGVsbG8=" } }); + const hex = screen.getByRole("button", { name: "Hex" }); + expect(hex).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(hex); + expect(hex).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Hex dump")).toBeInTheDocument(); + fireEvent.click(hex); + expect(hex).toHaveAttribute("aria-pressed", "false"); + }); }); diff --git a/app/(tools)/base64/components/DecodeTab.tsx b/app/(tools)/base64/components/DecodeTab.tsx index e921892..32cc810 100644 --- a/app/(tools)/base64/components/DecodeTab.tsx +++ b/app/(tools)/base64/components/DecodeTab.tsx @@ -27,7 +27,10 @@ export function DecodeTab() { const result = useMemo(() => { if (!effectiveInput.trim()) return null; - const r = base64ToBytes(effectiveInput); + const inputOffset = autoStripped + ? input.length - input.trimStart().length + autoStripped.prefix.length + : 0; + const r = base64ToBytes(effectiveInput, { originalInput: input, inputOffset }); if (!r.ok) return { ok: false as const, error: r.error }; const bytes = r.value; let mime = getMimeFromMagicBytes(bytes); @@ -56,7 +59,7 @@ export function DecodeTab() { return null; })(), }; - }, [effectiveInput, autoStripped]); + }, [effectiveInput, autoStripped, input]); function download() { if (!result?.ok) return; @@ -104,6 +107,7 @@ export function DecodeTab() { - + {showLang && ( -
+
choose(event.target.value)} + placeholder="*/15 * * * *" + autoComplete="off" + autoCapitalize="off" + spellCheck={false} + aria-invalid={!parsed.ok && expression.trim() !== ""} + aria-describedby="cron-hint" + className={inputClass} + /> +

+ minute hour day-of-month month day-of-week, or a macro such as @daily +

+
+ +
+ {PRESETS.map((preset) => ( + + ))} +
+ + {!parsed.ok && expression.trim() !== "" && ( +

+ {parsed.error} +

+ )} +
+ + {parsed.ok && ( + <> +
+
+

+ In plain English +

+ +
+
+

{description}

+ {parsed.value.macro && ( +

+ {parsed.value.macro} is the same as{" "} + {fields.map((field) => field.raw).join(" ")}. +

+ )} + {eitherDay && ( +

+ Both the day of month and the day of week are set, so cron runs on the days that match + either one. +

+ )} +
+
+ +
+
+

+ Field by field +

+
+
+ + + + + + + + + + + {fields.map((field) => ( + + + + + + ))} + +
What each field of the cron expression means
FieldValueMeaning
{FIELD_LABELS[field.name]}{field.raw}{describeField(field)}
+
+
+ +
+
+

+ Next {RUN_COUNT} runs +

+
+ {([["UTC", true], ["Local", false]] as const).map(([label, value]) => ( + + ))} +
+
+ {runs === null &&

Working out the next runs...

} + {runs !== null && runs.length === 0 && ( +

{noRunsMessage(parsed.value)}

+ )} + {runs !== null && runs.length > 0 && ( +
    + {runs.map((run) => ( +
  1. + +
  2. + ))} +
+ )} +
+ + {showCalendar && now !== null && } + + )} + + ); +} diff --git a/app/(tools)/cron-explainer/opengraph-image.tsx b/app/(tools)/cron-explainer/opengraph-image.tsx new file mode 100644 index 0000000..681e87b --- /dev/null +++ b/app/(tools)/cron-explainer/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("cron-explainer")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/cron-explainer/page.tsx b/app/(tools)/cron-explainer/page.tsx new file mode 100644 index 0000000..6d89928 --- /dev/null +++ b/app/(tools)/cron-explainer/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { CronExplainerClient } from "./CronExplainerClient"; +import { ToolPageLayout } from "@/components/shared/ToolPageLayout"; +import { generateToolMetadata } from "@/lib/toolMetadata"; +import { getTool } from "@/lib/tools"; + +const tool = getTool("cron-explainer")!; + +export const metadata: Metadata = generateToolMetadata(tool); + +export default function CronExplainerPage() { + return ( + + + + ); +} diff --git a/app/(tools)/csv-formatter/CsvFormatterClient.test.tsx b/app/(tools)/csv-formatter/CsvFormatterClient.test.tsx new file mode 100644 index 0000000..7ea3ff4 --- /dev/null +++ b/app/(tools)/csv-formatter/CsvFormatterClient.test.tsx @@ -0,0 +1,234 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ToastProvider } from "@/components/shared/Toast"; +import { downloadBlob } from "@/lib/download"; +import { CsvFormatterClient } from "./CsvFormatterClient"; + +vi.mock("@/lib/download", () => ({ downloadBlob: vi.fn() })); + +const mockWriteText = vi.fn().mockResolvedValue(undefined); + +function renderClient() { + return render( + + + , + ); +} + +const textarea = () => screen.getByLabelText("CSV or TSV input") as HTMLTextAreaElement; +const typeCsv = (value: string) => fireEvent.change(textarea(), { target: { value } }); + +describe("CsvFormatterClient", () => { + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + mockWriteText.mockClear(); + vi.mocked(downloadBlob).mockClear(); + }); + + afterEach(cleanup); + + it("starts with an empty-state prompt", () => { + renderClient(); + expect(textarea().value).toBe(""); + expect(screen.getByText(/Paste some CSV or TSV above/)).toBeInTheDocument(); + expect(screen.queryByLabelText("CSV statistics")).not.toBeInTheDocument(); + }); + + it("shows the parsed table and stats as soon as CSV is pasted", async () => { + renderClient(); + typeCsv("name,role,city\nAda,Engineer,London\nGrace,Admiral,New York"); + + expect( + await screen.findByText(/Parsed CSV table, 2 data rows and 3 columns/), + ).toBeInTheDocument(); + const table = screen.getByRole("table"); + expect(within(table).getByText("Ada")).toBeInTheDocument(); + expect(within(table).getByText("Grace")).toBeInTheDocument(); + expect(screen.getByText("Rows").nextElementSibling).toHaveTextContent("2"); + expect(screen.getByText("Columns").nextElementSibling).toHaveTextContent("3"); + }); + + it("auto-detects a semicolon delimiter and says so", async () => { + renderClient(); + typeCsv("name;age\nAda;36\nGrace;85"); + + expect(await screen.findByText("detected Semicolon")).toBeInTheDocument(); + expect(screen.getByText(/Parsed CSV table, 2 data rows and 2 columns/)).toBeInTheDocument(); + expect(within(screen.getByRole("table")).getByText("Ada")).toBeInTheDocument(); + }); + + it("honours a manually selected delimiter", async () => { + renderClient(); + typeCsv("name,age\nAda,36"); + + await screen.findByText(/Parsed CSV table, 1 data row and 2 columns/); + fireEvent.click(screen.getByRole("button", { name: "Tab" })); + + expect( + await screen.findByText(/Parsed CSV table, 1 data row and 1 column/), + ).toBeInTheDocument(); + }); + + it("treats the first row as data when the header checkbox is off", async () => { + renderClient(); + typeCsv("Ada,36\nGrace,85"); + await screen.findByText(/Parsed CSV table, 1 data row and 2 columns/); + + fireEvent.click(screen.getByLabelText("First row is the header")); + + expect( + await screen.findByText(/Parsed CSV table, 2 data rows and 2 columns/), + ).toBeInTheDocument(); + expect(within(screen.getByRole("table")).getByText("column_1")).toBeInTheDocument(); + }); + + it("reports ragged rows and unclosed quotes with line numbers", async () => { + renderClient(); + typeCsv('a,b,c\n1,2,3\n4,5\n6,"never closed'); + + expect(await screen.findByText(/Line 3 has 2 columns but the header has 3/)).toBeInTheDocument(); + expect(screen.getByText(/is never closed/)).toBeInTheDocument(); + expect(screen.getAllByText(/3 errors/).length).toBeGreaterThan(0); + }); + + it("converts to JSON", async () => { + renderClient(); + typeCsv("name,age\nAda,36"); + await screen.findByText(/Parsed CSV table/); + + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + + expect(await screen.findByText(/"name": "Ada"/)).toBeInTheDocument(); + expect(screen.getByText(/"age": "36"/)).toBeInTheDocument(); + }); + + it("converts to TypeScript and renames the interface", async () => { + renderClient(); + typeCsv("name,age,active\nAda,36,true"); + await screen.findByText(/Parsed CSV table/); + + fireEvent.click(screen.getByRole("tab", { name: "TypeScript" })); + expect(await screen.findByText(/export interface Row/)).toBeInTheDocument(); + expect(screen.getByText(/age: string;/)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Interface"), { target: { value: "Person" } }); + expect(await screen.findByText(/export interface Person/)).toBeInTheDocument(); + }); + + it("keeps identifier-like values lossless in every output", async () => { + renderClient(); + typeCsv("zip,phone,id,null,on\n02134,+15551234567,007,x,y"); + await screen.findByText(/Parsed CSV table/); + + fireEvent.click(screen.getByRole("tab", { name: "TypeScript" })); + expect(await screen.findByText(/zip: string;/)).toBeInTheDocument(); + expect(screen.getByText(/phone: string;/)).toBeInTheDocument(); + expect(screen.getByText(/id: string;/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "SQL" })); + expect( + await screen.findByText( + "INSERT INTO `records` (`zip`, `phone`, `id`, `null`, `on`) VALUES ('02134', '+15551234567', '007', 'x', 'y');", + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "YAML" })); + const yaml = await screen.findByText(/^- zip: "02134"/); + expect(yaml.textContent).toContain('phone: "+15551234567"'); + expect(yaml.textContent).toContain('"null": "x"'); + expect(yaml.textContent).toContain('"on": "y"'); + }); + + it("converts to YAML and SQL, using the table name field", async () => { + renderClient(); + typeCsv("name,age\nAda,36"); + await screen.findByText(/Parsed CSV table/); + + fireEvent.click(screen.getByRole("tab", { name: "YAML" })); + expect(await screen.findByText(/- name: "Ada"/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "SQL" })); + expect( + await screen.findByText("INSERT INTO `records` (`name`, `age`) VALUES ('Ada', 36);"), + ).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Table"), { target: { value: "people" } }); + expect( + await screen.findByText("INSERT INTO `people` (`name`, `age`) VALUES ('Ada', 36);"), + ).toBeInTheDocument(); + }); + + it("copies and downloads the current output", async () => { + renderClient(); + typeCsv("name\nAda"); + await screen.findByText(/Parsed CSV table/); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + + fireEvent.click(await screen.findByRole("button", { name: "Copy" })); + await waitFor(() => expect(mockWriteText).toHaveBeenCalledWith('[\n {\n "name": "Ada"\n }\n]')); + + fireEvent.click(screen.getByRole("button", { name: "Download" })); + expect(downloadBlob).toHaveBeenCalledWith( + expect.stringContaining('"name": "Ada"'), + "data.json", + expect.stringContaining("application/json"), + ); + }); + + it("downloads the CSV tab as .tsv when the delimiter is a tab", async () => { + renderClient(); + typeCsv("name\tage\nAda\t36"); + await screen.findByText(/detected Tab/); + + fireEvent.click(screen.getByRole("tab", { name: "CSV" })); + fireEvent.click(await screen.findByRole("button", { name: "Download" })); + + expect(downloadBlob).toHaveBeenCalledWith( + expect.stringContaining("name\tage"), + "data.tsv", + expect.stringContaining("tab-separated-values"), + ); + }); + + it("moves between output tabs with the arrow keys", async () => { + renderClient(); + typeCsv("name\nAda"); + await screen.findByText(/Parsed CSV table/); + + const tableTab = screen.getByRole("tab", { name: "Table" }); + tableTab.focus(); + fireEvent.keyDown(tableTab, { key: "ArrowRight" }); + + const csvTab = screen.getByRole("tab", { name: "CSV" }); + expect(csvTab).toHaveAttribute("aria-selected", "true"); + expect(csvTab).toHaveFocus(); + + fireEvent.keyDown(csvTab, { key: "End" }); + expect(screen.getByRole("tab", { name: "Aligned" })).toHaveAttribute("aria-selected", "true"); + }); + + it("loads the example and clears it again", async () => { + renderClient(); + fireEvent.click(screen.getByRole("button", { name: "Load example" })); + + await waitFor(() => expect(textarea().value).toContain("Ada Lovelace")); + expect(await screen.findByText(/Parsed CSV table, 4 data rows and 5 columns/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + await waitFor(() => expect(textarea().value).toBe("")); + expect(screen.getByText(/Paste some CSV or TSV above/)).toBeInTheDocument(); + }); + + it("shows a validation warning for duplicate headers", async () => { + renderClient(); + typeCsv("name,name\nAda,Lovelace"); + + expect(await screen.findByText(/appears more than once/)).toBeInTheDocument(); + expect(screen.getAllByText(/1 warning/).length).toBeGreaterThan(0); + }); +}); diff --git a/app/(tools)/csv-formatter/CsvFormatterClient.tsx b/app/(tools)/csv-formatter/CsvFormatterClient.tsx new file mode 100644 index 0000000..67eaa84 --- /dev/null +++ b/app/(tools)/csv-formatter/CsvFormatterClient.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useDeferredValue, useMemo, useRef, useState } from "react"; +import { FileUp, Sparkles, Trash2 } from "lucide-react"; +import { useRegisterCommands } from "@/components/shared/CommandPalette"; +import { useToast } from "@/components/shared/Toast"; +import { + DELIMITERS, + SAMPLE_CSV, + delimiterLabel, + parseCsv, + type CsvDelimiter, +} from "@/lib/csv-utils"; +import { IssueList } from "./components/IssueList"; +import { OutputPanel, type OutputTab } from "./components/OutputPanel"; +import { StatsBar } from "./components/StatsBar"; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; + +type DelimiterChoice = CsvDelimiter | "auto"; + +export function CsvFormatterClient() { + const { push } = useToast(); + const [input, setInput] = useState(""); + const [delimiter, setDelimiter] = useState("auto"); + const [hasHeader, setHasHeader] = useState(true); + const [tab, setTab] = useState("table"); + const [tableName, setTableName] = useState("records"); + const [typeName, setTypeName] = useState("Row"); + const [fileName, setFileName] = useState(null); + const fileInputRef = useRef(null); + + // Parsing is deferred so typing in a large paste never blocks the caret. + const deferredInput = useDeferredValue(input); + const parsed = useMemo( + () => parseCsv(deferredInput, { delimiter, hasHeader }), + [deferredInput, delimiter, hasHeader], + ); + + const handleLoadSample = useCallback(() => { + setInput(SAMPLE_CSV); + setDelimiter("auto"); + setHasHeader(true); + setTab("table"); + setFileName(null); + push("Example CSV loaded", "success"); + }, [push]); + + const handleClear = useCallback(() => { + setInput(""); + setFileName(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }, []); + + const handleFile = useCallback( + async (file: File | undefined) => { + if (!file) return; + if (file.size > MAX_FILE_BYTES) { + push("That file is larger than 10 MB. Paste a smaller sample instead.", "error"); + return; + } + try { + const text = await file.text(); + setInput(text); + setFileName(file.name); + setTab("table"); + push(`${file.name} loaded`, "success"); + } catch { + push("Could not read that file", "error"); + } + }, + [push], + ); + + useRegisterCommands( + "csv-formatter", + [ + { + id: "csv:sample", + section: "CSV Formatter", + title: "Load example", + run: handleLoadSample, + }, + { + id: "csv:clear", + section: "CSV Formatter", + title: "Clear input", + run: handleClear, + }, + { + id: "csv:json", + section: "CSV Formatter", + title: "Switch output to JSON", + run: () => setTab("json"), + }, + { + id: "csv:sql", + section: "CSV Formatter", + title: "Switch output to SQL", + run: () => setTab("sql"), + }, + ], + [handleLoadSample, handleClear], + ); + + return ( +
+
+
+ +
+ + + + void handleFile(event.target.files?.[0])} + /> +
+
+ +
+