From 7cde902978bbd48197e89dc6866074beb65237e2 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 5 Sep 2026 18:38:44 +0800 Subject: [PATCH 1/8] Rework column filters and toolbar toggles - Remove the drag-and-drop FilterBuilder in favor of the existing '+ Add filter' row editor in the Column Filters popover - Debounce filter value edits (400ms) so a query only fires after the user stops typing, fixing a race where the in-flight fetch guard silently dropped the final filter value - Make every toolbar toggle (filter, sort, auto-refresh, show/hide columns, export, bulk actions) a clean plain toggle by wrapping the button + dropdown in a shared ref so the outside-click handler no longer fights the button click --- AGENTS.md | 2 +- .../db-viewer/FilterBuilder.test.tsx | 27 ---- .../components/db-viewer/FilterBuilder.tsx | 132 ------------------ .../db-viewer/TableControls.test.tsx | 95 ++++++++++++- .../components/db-viewer/TableControls.tsx | 128 +++++++++++++---- 5 files changed, 189 insertions(+), 195 deletions(-) delete mode 100644 desktop/src/components/db-viewer/FilterBuilder.test.tsx delete mode 100644 desktop/src/components/db-viewer/FilterBuilder.tsx diff --git a/AGENTS.md b/AGENTS.md index d473e17..037b6de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,7 +290,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr | Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version | | Data grid pagination | ✅ | Page nav, page size selector persisted in settings | | Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE | -| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` | +| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) | | Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY | | Column show/hide | ✅ | Toggle visibility per column | | Column resize (drag handle) | ✅ | Double-click to auto-fit | diff --git a/desktop/src/components/db-viewer/FilterBuilder.test.tsx b/desktop/src/components/db-viewer/FilterBuilder.test.tsx deleted file mode 100644 index 0016a84..0000000 --- a/desktop/src/components/db-viewer/FilterBuilder.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { FilterBuilder } from "./FilterBuilder"; -import type { ColumnInfo } from "../../lib/types"; - -const cols: ColumnInfo[] = [ - { name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false }, - { name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }, -]; - -describe("FilterBuilder", () => { - it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => { - const onChange = vi.fn(); - render(); - fireEvent.click(screen.getByText("name")); - expect(onChange).toHaveBeenCalledWith([ - expect.objectContaining({ column: "name", operator: "contains" }), - ]); - }); - it("removing a rule calls onChange without it", () => { - const onChange = vi.fn(); - const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }]; - render(); - fireEvent.click(screen.getByLabelText("Remove filter name")); - expect(onChange).toHaveBeenCalledWith([]); - }); -}); \ No newline at end of file diff --git a/desktop/src/components/db-viewer/FilterBuilder.tsx b/desktop/src/components/db-viewer/FilterBuilder.tsx deleted file mode 100644 index 68a320d..0000000 --- a/desktop/src/components/db-viewer/FilterBuilder.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from "react"; -import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core"; -import { X } from "lucide-react"; -import { defaultFilterOperator } from "../grid/gridEditability"; -import type { ColumnInfo } from "../../lib/types"; -import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore"; - -interface Props { - columns: ColumnInfo[]; - rules: FilterRule[]; - onChange: (rules: FilterRule[]) => void; -} - -function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) { - const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ - id: `col-${col.name}`, - data: { column: col }, - }); - return ( - - ); -} - -const OPERATORS: FilterOperator[] = [ - "eq", - "neq", - "contains", - "starts", - "ends", - "gt", - "lt", - "null", - "notnull", -]; - -export function FilterBuilder({ columns, rules, onChange }: Props) { - const [val, setVal] = useState>({}); - - const addRule = (col: ColumnInfo) => { - const op = defaultFilterOperator(col.data_type); - onChange([ - ...rules, - { - id: `f-${Date.now()}-${col.name}`, - column: col.name, - operator: op, - value: "", - }, - ]); - }; - - const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" }); - - const remove = (id: string) => onChange(rules.filter((r) => r.id !== id)); - const update = (id: string, patch: Partial) => - onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); - - return ( - { - const id = e.active.id as string; - const colName = id.replace(/^col-/, ""); - const col = columns.find((c) => c.name === colName); - if (col && e.over?.id === "filter-dropzone") addRule(col); - }} - > -
- {columns.map((c) => ( - addRule(c)} /> - ))} -
-
- {rules.length === 0 && ( - - Drop columns here to add filters - - )} - {rules.map((r) => ( -
- {r.column} - - {!["null", "notnull"].includes(r.operator) && ( - { - setVal({ ...val, [r.id]: e.target.value }); - update(r.id, { value: e.target.value }); - }} - className="bg-surface border border-border rounded px-1 flex-1" - placeholder="value" - /> - )} - -
- ))} -
-
- ); -} \ No newline at end of file diff --git a/desktop/src/components/db-viewer/TableControls.test.tsx b/desktop/src/components/db-viewer/TableControls.test.tsx index 05ea9b2..744e00e 100644 --- a/desktop/src/components/db-viewer/TableControls.test.tsx +++ b/desktop/src/components/db-viewer/TableControls.test.tsx @@ -325,13 +325,100 @@ describe("TableControls", () => { expect(screen.queryByLabelText(/insert row/i)).toBeNull(); }); - it("renders the FilterBuilder inside the filter popover", () => { + it("renders the column filter popover with the + Add filter action", () => { seed([makeTab()], "tab-1"); renderControls(); fireEvent.click(screen.getByLabelText(/column filters/i)); - expect( - screen.getByText("Drop columns here to add filters"), - ).toBeInTheDocument(); + expect(screen.getByText("Column Filters")).toBeInTheDocument(); + expect(screen.getByText("+ Add filter")).toBeInTheDocument(); + }); + + it("debounces filter value edits so a query only fires after the user stops typing", () => { + vi.useFakeTimers(); + seed([makeTab()], "tab-1"); + const onFilterChange = vi.fn(); + renderControls({ + onFilterChange, + filterRules: [ + { id: "r1", column: "id", operator: "contains", value: "" }, + ], + }); + + fireEvent.click(screen.getByLabelText(/column filters/i)); + + const input = screen.getByPlaceholderText("value"); + fireEvent.change(input, { target: { value: "a" } }); + fireEvent.change(input, { target: { value: "ab" } }); + fireEvent.change(input, { target: { value: "abc" } }); + + // No commit yet — the debounce hasn't elapsed. + expect(onFilterChange).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(400); + }); + + // Only the final value is committed, once. + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledWith([ + expect.objectContaining({ value: "abc" }), + ]); + }); + + it("closes the filter popover when the button is clicked while it is open", () => { + seed([makeTab()], "tab-1"); + renderControls(); + + fireEvent.click(screen.getByLabelText(/column filters/i)); + expect(screen.getByText("Column Filters")).toBeInTheDocument(); + + // Clicking the button while open closes it (plain toggle). + fireEvent.click(screen.getByLabelText(/column filters/i)); + expect(screen.queryByText("Column Filters")).not.toBeInTheDocument(); + }); + + it("toggles the sort rules popover open and closed", () => { + seed([makeTab()], "tab-1"); + renderControls(); + + fireEvent.click(screen.getByLabelText(/sort rules/i)); + expect(screen.getByText("Sort Rules")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/sort rules/i)); + expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument(); + }); + + it("toggles the auto-refresh dropdown open and closed", () => { + seed([makeTab()], "tab-1"); + renderControls(); + + fireEvent.click(screen.getByLabelText(/auto-refresh/i)); + expect(screen.getByText("Off")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/auto-refresh/i)); + expect(screen.queryByText("Off")).not.toBeInTheDocument(); + }); + + it("toggles the show/hide columns dropdown open and closed", () => { + seed([makeTab()], "tab-1"); + renderControls(); + + fireEvent.click(screen.getByLabelText(/toggle columns/i)); + expect(screen.getByText("Visible columns")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/toggle columns/i)); + expect(screen.queryByText("Visible columns")).not.toBeInTheDocument(); + }); + + it("toggles the export dropdown open and closed", () => { + seed([makeTab()], "tab-1"); + renderControls(); + + fireEvent.click(screen.getByLabelText(/export/i)); + expect(screen.getByText("JSON")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/export/i)); + expect(screen.queryByText("JSON")).not.toBeInTheDocument(); }); it("export dropdown includes the Excel option", () => { diff --git a/desktop/src/components/db-viewer/TableControls.tsx b/desktop/src/components/db-viewer/TableControls.tsx index fad18aa..759317d 100644 --- a/desktop/src/components/db-viewer/TableControls.tsx +++ b/desktop/src/components/db-viewer/TableControls.tsx @@ -6,7 +6,6 @@ import { } from "lucide-react"; import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore"; import { useNotificationStore } from "../../stores/notificationStore"; -import { FilterBuilder } from "./FilterBuilder"; import { Tooltip } from "../ui/Tooltip"; import { exportData } from "../../lib/exportData"; import type { ColumnInfo } from "../../lib/types"; @@ -49,22 +48,28 @@ function DropdownMenu({ open, setOpen, align, + wrapRef, children, }: { open: boolean; setOpen: (v: boolean) => void; align?: "left" | "right"; + /** When provided, outside-click detection uses this wrapper (which should + * contain both the toggle button and the menu) so the button never fights + * the outside-click handler. */ + wrapRef?: React.RefObject; children: React.ReactNode; }) { const ref = useRef(null); useEffect(() => { if (!open) return; const close = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + const container = wrapRef?.current ?? ref.current; + if (container && !container.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); - }, [open, setOpen]); + }, [open, setOpen, wrapRef]); if (!open) return null; return ( @@ -79,28 +84,84 @@ function DropdownMenu({ ); } +/** + * Debounced free-text input for filter values. The value is committed to the + * rules (and thus triggers a server-side refetch) only after the user stops + * typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and + * the in-flight guard in `fetchData` silently drops intermediate fetches, so + * the final filter value can end up not being applied. + */ +const FILTER_DEBOUNCE_MS = 400; + +function DebouncedValueInput({ + value, + onCommit, + placeholder, +}: { + value: string; + onCommit: (v: string) => void; + placeholder?: string; +}) { + const [draft, setDraft] = useState(value); + const timerRef = useRef | null>(null); + + // Keep the draft in sync when the committed value changes externally + // (e.g. a rule is replaced or removed while this input is mounted). + useEffect(() => { + setDraft(value); + }, [value]); + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setDraft(v); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS); + }; + + return ( + + ); +} + function FilterModal({ columns, rules, onChange, open, setOpen, + wrapRef, }: { columns: ColumnInfo[]; rules: FilterRule[]; onChange: (rules: FilterRule[]) => void; open: boolean; setOpen: (v: boolean) => void; + wrapRef: React.RefObject; }) { - const ref = useRef(null); + // Close on outside click, but treat the toggle button (which lives inside + // the same wrapper) as part of the modal so it never fights the toggle. useEffect(() => { if (!open) return; const close = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); - }, [open, setOpen]); + }, [open, setOpen, wrapRef]); if (!open) return null; @@ -116,17 +177,13 @@ function FilterModal({ onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r))); return ( -
+
Column Filters
- {rules.map((rule) => (
{rule.operator !== "null" && rule.operator !== "notnull" && ( - updateRule(rule.id, { value: e.target.value })} + onCommit={(v) => updateRule(rule.id, { value: v })} placeholder="value" - className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0" /> )} - + - + {EXPORT_FORMATS.map((fmt) => ( - + {AUTO_REFRESH_OPTIONS.map((opt) => (
{/* Sort */} -
+
-- 2.54.0 From cd6cc7fde2bcc14d582ad0b23aa193116b4a5699 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 12:01:28 +0800 Subject: [PATCH 2/8] Add Ko-fi support button + de-AI copy pass on landing page --- www/src/data/faq.ts | 13 +++++++++---- www/src/layouts/Layout.astro | 3 ++- www/src/pages/index.astro | 16 ++++++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/www/src/data/faq.ts b/www/src/data/faq.ts index 7c44a74..f193a2c 100644 --- a/www/src/data/faq.ts +++ b/www/src/data/faq.ts @@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [ { question: "Is Gridline really free for commercial use?", answer: - "Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.", + "Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.", }, { question: "Which databases are supported?", answer: - "PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.", + "PostgreSQL and SQLite are fully supported today — browsing, querying, editing, backups, and the visual tools. MySQL and Redis connections are in active development.", }, { question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?", @@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [ { question: "Do you store my database credentials?", answer: - "Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", + "No. Passwords live in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", }, { - question: "Can I self-host or contribute?", + question: "How do you make money?", + answer: + "Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin — it's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.", + }, + { + question: "Can I contribute or self-host?", answer: "Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.", }, diff --git a/www/src/layouts/Layout.astro b/www/src/layouts/Layout.astro index fb18161..da2ccbf 100644 --- a/www/src/layouts/Layout.astro +++ b/www/src/layouts/Layout.astro @@ -149,12 +149,13 @@ const isProd = import.meta.env.PROD;
© 2026 Gridline contributors · Apache 2.0 - Made with care for databases. + Coffee-powered.
diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 5ed0b2c..9825e42 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -176,13 +176,21 @@ const jsonLd = [

Open Source

-

Built in public.
No VC roadmap, no rug pulls.

+

Built in public,
no VC roadmap attached.

Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.

- - Star on Gitea - + +

+ Gridline stays free — no feature is ever behind a payment. Ko-fi is only for those who want to say thanks. +

-- 2.54.0 From bc1c2b28139b643e6602c2a34929c51655c3ea52 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 12:02:24 +0800 Subject: [PATCH 3/8] Replace em dashes with commas/parens in landing copy; clean 404 title --- www/src/data/faq.ts | 6 +++--- www/src/layouts/Layout.astro | 6 +++--- www/src/pages/404.astro | 2 +- www/src/pages/index.astro | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/www/src/data/faq.ts b/www/src/data/faq.ts index f193a2c..5cc45c5 100644 --- a/www/src/data/faq.ts +++ b/www/src/data/faq.ts @@ -12,7 +12,7 @@ export const faqItems: FaqItem[] = [ { question: "Which databases are supported?", answer: - "PostgreSQL and SQLite are fully supported today — browsing, querying, editing, backups, and the visual tools. MySQL and Redis connections are in active development.", + "PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.", }, { question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?", @@ -22,12 +22,12 @@ export const faqItems: FaqItem[] = [ { question: "Do you store my database credentials?", answer: - "No. Passwords live in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.", + "No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.", }, { question: "How do you make money?", answer: - "Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin — it's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.", + "Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin. It's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.", }, { question: "Can I contribute or self-host?", diff --git a/www/src/layouts/Layout.astro b/www/src/layouts/Layout.astro index da2ccbf..15b898c 100644 --- a/www/src/layouts/Layout.astro +++ b/www/src/layouts/Layout.astro @@ -13,7 +13,7 @@ export interface Props { const { title, - description = "Gridline — the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.", + description = "Gridline, the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.", ogImage = "/og.png", noIndex = false, jsonLd = [], @@ -51,7 +51,7 @@ const isProd = import.meta.env.PROD; - + @@ -59,7 +59,7 @@ const isProd = import.meta.env.PROD; - + diff --git a/www/src/pages/404.astro b/www/src/pages/404.astro index e8b1df5..bb08335 100644 --- a/www/src/pages/404.astro +++ b/www/src/pages/404.astro @@ -1,7 +1,7 @@ --- import Layout from "../layouts/Layout.astro"; -const title = "Page not found — Gridline"; +const title = "Page not found · Gridline"; const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI."; --- diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 9825e42..01e3cfd 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -58,7 +58,7 @@ const jsonLd = [ ]), ]; --- - + @@ -70,7 +70,7 @@ const jsonLd = [ the open-source database gui

- Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required. + Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis, with no paywalls, no tab limits, no account required.

- Gridline stays free — no feature is ever behind a payment. Ko-fi is only for those who want to say thanks. + Gridline stays free. No feature is ever behind a payment. Ko-fi is only for those who want to say thanks.

-- 2.54.0 From ecc14c4850338f9c6c33ff255b9c8fced70939aa Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 12:03:22 +0800 Subject: [PATCH 4/8] Simplify OSS section headline --- www/src/pages/index.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 01e3cfd..93be98b 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -176,7 +176,7 @@ const jsonLd = [

Open Source

-

Built in public,
no VC roadmap attached.

+

Free. Open source. Apache 2.0.

Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.

-- 2.54.0 From bff4e612abb032e53f80d22e5195cbeec374aea7 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 12:10:54 +0800 Subject: [PATCH 5/8] Bump version to 0.7.15 across app shell, README, landing page, and changelog --- README.md | 36 ++++++++++++++------------- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/lib/bundle-config.test.ts | 6 ++--- desktop/src/lib/docs-coverage.test.ts | 14 +++++------ desktop/src/lib/version.test.ts | 4 +-- www/public/landing.js | 2 +- www/src/data/changelog.ts | 8 ++++++ www/src/pages/index.astro | 2 +- 11 files changed, 45 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 73b78ca..9f1032f 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ ## Download -Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.14**): +Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.15**): | OS | Architecture | Download | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | -| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.14_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_aarch64.dmg) | -| **macOS** | Intel | [Gridline_0.7.14_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64.dmg) | -| **Windows** | x64 | [Gridline_0.7.14_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_x64-setup.exe) | -| **Debian / Ubuntu** | amd64 | [Gridline_0.7.14_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.deb) | -| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.14-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline-0.7.14-1.x86_64.rpm) | -| **Other Linux** | amd64 | [Gridline_0.7.14_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.14/Gridline_0.7.14_amd64.AppImage) | +| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) | +| **macOS** | Intel | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) | +| **Windows** | x64 | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) | +| **Debian / Ubuntu** | amd64 | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) | +| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) | +| **Other Linux** | amd64 | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) | **macOS via Homebrew** (recommended for Mac users): @@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz > **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.