diff --git a/.github/cla/cla.md b/.github/cla/cla.md new file mode 100644 index 00000000..c0e2a890 --- /dev/null +++ b/.github/cla/cla.md @@ -0,0 +1,42 @@ +# Stroke Contributor License Agreement + +By signing this CLA, you agree to the following terms: + +## Grant of License + +You grant **Stroke / Nischal Dahal** (the "Project") a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable license to: + +- Reproduce, prepare derivative works of, publicly display, publicly perform, + sublicense, and distribute your contributions and any derivative works. +- Re-license your contributions under any license, including proprietary and + commercial licenses, at the Project's sole discretion. + +## Representations + +You represent that: + +1. You are legally entitled to grant the above license. +2. Each of your contributions is your original creation, or you have sufficient + rights to submit it under this agreement. +3. Your contributions do not violate any third-party intellectual property rights. +4. If your employer has rights over your contributions, you have their written + permission to contribute under these terms, or your employer has signed a + Corporate CLA with the Project. + +## No Warranty + +Your contributions are provided on an **"AS IS" basis**, without warranties or +conditions of any kind. + +## What this means in practice + +- You keep your copyright. The CLA grants the Project a license, not ownership. +- Your code will be available under the MIT license in the public repo. +- The Project may also include your code in a commercial Pro build under a + separate proprietary license. This is necessary to sustain the project. +- You will be credited in the project's contributor list. + +--- + +*This CLA is based on the Apache Foundation Individual CLA, adapted for Stroke.* diff --git a/.github/cla/signatures.json b/.github/cla/signatures.json new file mode 100644 index 00000000..6d978e7e --- /dev/null +++ b/.github/cla/signatures.json @@ -0,0 +1,3 @@ +{ + "signedContributors": [] +} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000..e06bd81e --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,38 @@ +name: CLA Assistant + +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] + +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +jobs: + cla-assistant: + runs-on: ubuntu-latest + if: | + (github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || + (github.event_name == 'pull_request_target') + steps: + - name: CLA assistant + uses: contributor-assistant/github-action@v2.6.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Personal access token with repo scope — add as a repo secret named + # PERSONAL_ACCESS_TOKEN so signatures can be committed back to the repo. + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + with: + # Signatures are stored in this file in the default branch. + path-to-signatures: '.github/cla/signatures.json' + # The CLA document contributors will be asked to read. + path-to-document: 'https://github.com/${{ github.repository }}/blob/master/.github/cla/cla.md' + # The comment added to PRs asking contributors to sign. + custom-pr-sign-comment: > + I have read the CLA Document and I hereby sign the CLA + # Allowlist — bots and the repo owner don't need to sign. + allowlist: bot,broisnischal diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..38de1483 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +MIT License + +Copyright (c) 2024 Stroke Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +NOTICE: Certain features of this software ("Pro Features") are not covered by +the MIT License above and are subject to a separate commercial license. Pro +Features are clearly identified in the application and include: AI assistant, +Dashboard, ORM Runner, Security, Logs, Charts, Diagrams, Schema Timeline, +Data Diff, Extensions, and SQL Notebooks. Use of Pro Features in production +requires a valid Stroke Pro license. See https://stroke.click/pricing for details. diff --git a/README.md b/README.md index ca42d98d..921e95bb 100644 --- a/README.md +++ b/README.md @@ -2,53 +2,188 @@ # Stroke -**A fast, modern desktop database client.** +**A fast, minimal desktop database client.** -Connect to PostgreSQL, MySQL, SQLite, Turso/LibSQL, and Cloudflare D1 — browse schemas, edit data inline, write SQL, visualize, and let AI tools talk to your database through a built-in MCP server. +Connect to PostgreSQL, MySQL, SQLite, Turso/LibSQL, Cloudflare D1, and more — +browse schemas, edit data, write SQL, visualize, and let AI tools talk to your database through a built-in MCP server. -[Download](#install) · [Features](#features) · [Build from source](#build-from-source) · [Website](https://stroke.app) +[Download](#install) · [Features](#features) · [Build from source](#build-from-source) · [Website](https://stroke.click) --- +## Why Stroke + +Built with **Rust + Svelte** — a native backend paired with a reactive UI — so the app is snappy, memory-efficient, and never feels like a browser tab. + +- **Under 40 MB memory** at runtime +- **Handles millions of rows** without slowing down +- **Keyboard-first** — every feature is reachable without a mouse +- **Read-only mode** — safely browse production without risking accidental writes +- **Extensions** — expand functionality with plugins +- **Themes** — dark and light, out of the box + +--- + ## Supported databases | Database | Notes | |----------|-------| | **PostgreSQL** | Full schema, enums, sequences, triggers, indexes | -| **MySQL** | Standard host/port connections | +| **MySQL / MariaDB** | Standard host/port connections | | **SQLite** | Local file or `:memory:` | | **Turso / LibSQL** | Serverless SQLite at the edge | -| **Cloudflare D1** | Connect via OAuth or API token | +| **Cloudflare D1** | OAuth or API token | +| **CockroachDB** | PostgreSQL-compatible | +| **Neon** | Serverless Postgres | +| **Supabase** | Postgres-based backend | +| **PlanetScale** | MySQL-compatible serverless | + +Connect directly or through an **SSH tunnel** for databases behind a bastion host or private network. --- ## Features -- **Schema explorer** — tables, views, materialized views, and foreign tables with live row counts, plus an index browser. -- **Data grid** — paginated browsing, resizable columns (saved per table), column show/hide, multi-column sort, full-text search, and a visual filter builder. -- **Inline editing** — type-aware editors for text, numbers, booleans, enums, dates, UUIDs, and JSON. Insert and delete rows with smart defaults. -- **Row inspector** — view any row in formatted, JSON, or preview mode. -- **Foreign keys** — click a value to jump to the referenced table with the filter applied. -- **SQL console** — Monaco editor with syntax highlighting, schema-aware autocomplete, formatting, execution time, and CSV/JSON export. -- **Query history & saved queries** — every query is saved automatically; bookmark the ones you keep. -- **Schema diagrams** — auto-generated ERDs of your tables and relationships. -- **Charts & dashboards** — turn query results into charts and pin them to a dashboard. -- **ORM runner** — run and preview ORM-style queries. -- **Docker launch** — spin up a local Postgres or MySQL container in one click. -- **Backup & restore** — export and import your data. -- **AI chat** — an assistant with direct access to your database that runs queries, explains schemas, generates SQL, and renders diagrams (works with any OpenAI-compatible API). -- **MCP server** — expose your database to Claude, Cursor, and other MCP clients (see below). -- **Command palette** — `Cmd/Ctrl+K` to jump anywhere instantly. +### Data grid + +- Paginated browsing with configurable page size +- Resizable columns, saved per table +- Pin columns to keep them in view while scrolling +- Show/hide columns +- Reset column width to default +- Column statistics — min, max, avg, nulls, distinct count +- Multi-column sort +- Full-text search and visual filter builder +- Click any foreign-key value to jump to the referenced row + +**Cell right-click menu** + +Open, Edit, Copy, Filter by value, Exclude this value, Set NULL, Expand, Select row, Delete row +Copy row as → JSON · CSV · Plain text · Markdown table · INSERT statement + +**Column header right-click menu** + +Sort ascending · Sort descending · Filter by this column · Pin column · Hide column · Reset column width · Column stats + +### Inline editing + +Edit text, numbers, booleans, enums, dates, UUIDs, and JSON in place. +Insert rows with smart defaults. Delete rows. Set any cell to NULL in one action. + +### Row inspector + +View any row in formatted, raw JSON, or preview mode. + +### Schema explorer + +- Tables, views, materialized views, and foreign tables +- Live row counts in the sidebar +- Index browser +- View DDL for any object + +**Table right-click menu** + +Copy name · Close tab · Pin table · Deselect · View DDL · Export as SQL · Export data · Generate test data · Truncate table · Drop table + +### SQL console + +- Syntax highlighting and schema-aware autocomplete +- Query formatting and execution time +- CSV and JSON export +- Automatic query history — everything is saved +- Saved queries — bookmark the ones you keep +- SQL Notebooks (`.sqlnb`) — multi-cell notebooks for documenting and replaying query sequences + +### Quick access + +Open the quick-access screen to jump anywhere in one keystroke: + +| | | +|--|--| +| **SQL** | Console with history and saved queries | +| **Dashboard** | Pinned charts and saved views | +| **AI** | AI chat assistant | +| **ORM** | ORM query runner | +| **Schema** | Schema and index explorer | +| **Security** | Roles, grants, and privilege viewer | +| **Logs** | Query and audit logs | +| **Charts** | Turn query results into charts | +| **Diagrams** | Auto-generated ERDs | +| **Timeline** | Schema drift detection across snapshots | +| **Data Diff** | Row-level diff between snapshots or queries | +| **Extensions** | Install and manage plugins | +| **Connect** | Manage and switch connections | + +### Charts & dashboards + +Turn any query result into a chart and pin it to a dashboard. + +### Schema diagrams + +Auto-generated entity-relationship diagrams (ERDs) built from your live schema. + +### Schema timeline + +Track schema changes over time. See exactly what columns, indexes, or constraints were added or removed between snapshots. + +### Data diff + +Compare two query results or table snapshots row-by-row — added, removed, and modified rows are highlighted clearly. + +### ORM runner + +Write ORM-style queries, preview the generated SQL, and run them against the connected database. + +### Security viewer + +Inspect roles, grants, and privileges — see who can read or write what, at a glance. + +### Status bar + +Active connection, database name, row counts, query execution time, and connection health — always visible at the bottom. + +### Docker launch + +Spin up a local Postgres or MySQL container in one click without leaving the app. + +### Backup & restore + +Export and import data as SQL dumps or CSV. + +### Extensions + +Install community and first-party extensions to add new panels, query tools, or integrations. + +### Read-only mode + +Lock any connection so writes are blocked entirely — safe for browsing production. + +### AI chat + +An AI assistant with direct database access that runs queries, explains schemas, generates SQL, and renders diagrams and charts inline. +Works with any OpenAI-compatible API — configure a base URL, model, and API key in **Settings**. + +### MCP server + +Stroke ships a built-in **Model Context Protocol** server so Claude, Cursor, and other MCP clients can query your database directly. + +1. Open **Settings → MCP Server** +2. Click **Start** +3. Copy the one-click config for your AI tool + +Uses a stable bearer token — configure once and it just works. + +### Command palette + +`Cmd/Ctrl+K` — type anything (table name, feature, shortcut) to jump there instantly. --- ## Install -### Recommended: package managers (no warnings) - -The smoothest, warning-free way to install on macOS and Windows: +### Recommended: package managers **macOS** — [Homebrew](https://brew.sh) @@ -63,11 +198,9 @@ scoop bucket add stroke https://github.com/broisnischal/stroke scoop install stroke ``` -These handle the OS security prompts for you, so the app just opens. - ### Or download directly -Grab the installer for your platform from the [Releases](https://github.com/broisnischal/stroke/releases) page. +Grab the installer from the [Releases](https://github.com/broisnischal/stroke/releases) page. | Platform | File | |----------|------| @@ -77,15 +210,13 @@ Grab the installer for your platform from the [Releases](https://github.com/broi | Linux (Debian/Ubuntu) | `stroke_x.x.x_amd64.deb` | | Linux (AppImage) | `stroke_x.x.x_amd64.AppImage` | -**macOS** — open the `.dmg`, drag **Stroke** to Applications. Stroke is ad-hoc signed (no paid Apple cert), so on first launch macOS may say it's from an unidentified developer or "damaged". Fix it once with: +**macOS** — open the `.dmg`, drag Stroke to Applications. If macOS blocks it on first launch: ```bash xattr -cr /Applications/Stroke.app ``` -…then open normally. (The Homebrew install above does this for you.) - -**Windows** — run the `.exe` installer. It's unsigned, so SmartScreen shows "Windows protected your PC" — click **More info → Run anyway**. (The Scoop install above avoids this.) +**Windows** — run the `.exe`. If SmartScreen shows a warning, click **More info → Run anyway**. (Scoop skips this.) **Linux (Debian/Ubuntu)** @@ -104,28 +235,15 @@ chmod +x stroke_*_amd64.AppImage ## Connecting -Pick a database type on the connection screen and fill in the details. Hit **Test connection** to verify, then **Connect**. - -- **PostgreSQL / MySQL** — host, port, database, user, password (optional SSL). You can also paste a full connection string and click **Parse**. -- **SQLite** — point to a `.db`/`.sqlite` file, or use an in-memory database. -- **Turso / LibSQL** — enter the database URL and (optionally) an auth token. -- **Cloudflare D1** — sign in with Cloudflare, or enter Account ID, Database ID, and an API token manually. - -Connections are saved locally, and Stroke reopens your last one on launch. - ---- - -## AI & MCP server - -Configure any OpenAI-compatible provider (OpenAI, Mistral, Ollama, …) in **Settings** with a base URL, model, and API key to enable AI chat and SQL suggestions. - -To connect external AI tools, Stroke ships a built-in **Model Context Protocol** server: +Pick a database type and fill in your credentials. Hit **Test connection**, then **Connect**. -1. Open **Settings → MCP Server**. -2. Click **Start**. -3. Copy the one-click config for Claude or Cursor into your tool's config. +- **PostgreSQL / MySQL** — host, port, database, user, password, optional SSL. Paste a full connection string and click **Parse** to fill the form automatically. +- **SQLite** — point to a `.db`/`.sqlite` file, or use `:memory:`. +- **Turso / LibSQL** — database URL and optional auth token. +- **Cloudflare D1** — sign in with Cloudflare, or enter Account ID, Database ID, and an API token. +- **SSH tunnel** — any connection type can go through an SSH tunnel for databases in private networks. -The server uses a stable bearer token, so you only configure it once. +Connections are saved locally. Stroke reopens your last connection on launch. --- @@ -134,11 +252,14 @@ The server uses a stable bearer token, so you only configure it once. | Shortcut | Action | |----------|--------| | `Cmd/Ctrl+K` | Command palette | +| `Ctrl+T` | Quick access | | `Cmd/Ctrl+Shift+D` | Table data view | | `Cmd/Ctrl+Shift+S` | SQL console | | `Cmd/Ctrl+Shift+A` | AI chat | | `Cmd/Ctrl+Enter` | Run SQL query | | `Cmd/Ctrl+⌥+←/→` | Switch tabs | +| `Ctrl+B` | Toggle sidebar | +| `Ctrl+W` | Close tab | | `Cmd/Ctrl+?` | Show all shortcuts | --- @@ -151,17 +272,17 @@ Requires [Node.js](https://nodejs.org) 18+ and the [Rust toolchain](https://rust git clone https://github.com/broisnischal/stroke cd stroke npm install -npm run tauri # run in dev -npm run tauri:build # build a release binary +npm run tauri # dev +npm run tauri:build # release binary ``` -**Arch Linux** — the AppImage step can fail due to a linker quirk. Build only the `.deb`: +**Arch Linux** — build `.deb` only to avoid an AppImage linker issue: ```bash npm run tauri:build -- --bundles deb ``` -…or use the included helper for AppImage on Arch: +Or use the included Arch helper: ```bash npm run tauri:build:arch diff --git a/index.html b/index.html index 84ee77c5..7652a17f 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,13 @@ - + diff --git a/src/lib/components/AboutDialog.svelte b/src/lib/components/AboutDialog.svelte index 0d7ec607..f25fb03e 100644 --- a/src/lib/components/AboutDialog.svelte +++ b/src/lib/components/AboutDialog.svelte @@ -1,7 +1,7 @@ - - + + -
-
- -
-
-

Stroke

-

Database management, reimagined

+
+
+
-
- v{appVersion} +
+

Stroke

+

Database management, reimagined

+ + v{appVersion} +
- -
-

License

+ +
+ + +
{#if !$licenseStatus} -
- + +
+ Checking license…
- {:else if $licenseStatus.status === 'Valid'} + {:else if isValid} -
-
-
- +
+
+
+
- Licensed + Pro license active
-
-
+
+
Email - {$licenseStatus.email} + {$licenseStatus.email}
-
+
Plan - {$licenseStatus.plan} + {$licenseStatus.plan ?? 'Pro'}
-
+
Expires {$licenseStatus.expires_at @@ -103,101 +101,96 @@
- {:else if $licenseStatus.status === 'Trial'} - -
-
-
- -
-
-

Free Trial

-

- {$licenseStatus.days_remaining} {$licenseStatus.days_remaining === 1 ? 'day' : 'days'} remaining -

+ {:else if isTrial} + +
+
+
+
+ +
+ Free trial
+ + {$licenseStatus.days_remaining}d left +
- {#if !showActivate} + {#if showActivate} + { showActivate = false; open = false }} /> + onclick={() => (showActivate = false)} + class="text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors text-center" + >Cancel {:else} -
- { showActivate = false; open = false }} /> -
+ onclick={() => (showActivate = true)} + class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-muted/30 text-[11px] font-medium text-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground" + > + + Enter license key + {/if}
{:else} -
-
-
- +
+
+
+
-

Trial Expired

-

Activate a license to continue

+

No active license

+

Free features only

- {#if !showActivate} + {#if showActivate} + { showActivate = false; open = false }} /> + onclick={() => (showActivate = false)} + class="text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors text-center" + >Cancel {:else} -
- { showActivate = false; open = false }} /> -
+ onclick={() => (showActivate = true)} + class="flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-muted/30 text-[11px] font-medium text-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground" + > + + Enter license key + {/if}
{/if}
- -
+ +
-
+
- @@ -207,7 +200,7 @@ href="https://stroke.click" target="_blank" rel="noopener noreferrer" - class="inline-flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground" + class="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 transition-colors hover:text-foreground" > Website diff --git a/src/lib/components/AiChartRenderer.svelte b/src/lib/components/AiChartRenderer.svelte index 134ff496..c07653a4 100644 --- a/src/lib/components/AiChartRenderer.svelte +++ b/src/lib/components/AiChartRenderer.svelte @@ -6,7 +6,7 @@ import { onDestroy } from 'svelte' import { buildOption } from '$lib/chart-utils.js' import { isCurrentThemeDark } from '$lib/stores/settings.js' - import { toast } from 'svelte-sonner' + import { toast } from '$lib/components/ui/sonner/toast.svelte.js' import ChoroplethChart from './ChoroplethChart.svelte' import CarbonMeterChart from './CarbonMeterChart.svelte' diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index b560542a..13b7190c 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -61,7 +61,7 @@ } from "$lib/stores/ai-skills.js"; import { renderMermaidSync, THEMES } from "beautiful-mermaid"; import { mermaidThemeFor, normalizeThemeId } from "$lib/themes/registry.js"; - import { toast } from "svelte-sonner"; + import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import { aiSettings, aiProfiles, @@ -553,16 +553,18 @@ return; } let i = 0; + let fadeId = 0; const tick = () => { thinkingVisible = false; - setTimeout(() => { + fadeId = setTimeout(() => { + fadeId = 0; i = (i + 1) % THINKING_PHRASES.length; thinkingPhrase = THINKING_PHRASES[i]; thinkingVisible = true; }, 220); }; const id = setInterval(tick, 2600); - return () => clearInterval(id); + return () => { clearInterval(id); clearTimeout(fadeId); }; }); let inputText = $state(""); const isDraftChat = $derived(!activeConvId && items.length > 0); diff --git a/src/lib/components/BackupPage.svelte b/src/lib/components/BackupPage.svelte index bbd01efe..49ec0053 100644 --- a/src/lib/components/BackupPage.svelte +++ b/src/lib/components/BackupPage.svelte @@ -1,6 +1,6 @@ - -{#if $licenseStatus === null} - {@render children()} - - -{:else if $isBlocked} - - - -{:else} - {@render children()} -{/if} + +{@render children()} diff --git a/src/lib/components/MediaLightbox.svelte b/src/lib/components/MediaLightbox.svelte index 9da7eb31..dc387d4e 100644 --- a/src/lib/components/MediaLightbox.svelte +++ b/src/lib/components/MediaLightbox.svelte @@ -3,7 +3,7 @@ import ExternalLink from '@lucide/svelte/icons/external-link' import ZoomIn from '@lucide/svelte/icons/zoom-in' import ZoomOut from '@lucide/svelte/icons/zoom-out' - import { toast } from 'svelte-sonner' + import { toast } from '$lib/components/ui/sonner/toast.svelte.js' /** @type {{ url: string | null, type?: 'image' | 'pdf', onclose: () => void }} */ let { url = null, type = 'image', onclose } = $props() diff --git a/src/lib/components/MiniJsonViewer.svelte b/src/lib/components/MiniJsonViewer.svelte index 81ad39db..209ac57c 100644 --- a/src/lib/components/MiniJsonViewer.svelte +++ b/src/lib/components/MiniJsonViewer.svelte @@ -1,7 +1,7 @@ + +{#snippet proBadge()} + PRO +{/snippet} + {#snippet sep()} @@ -202,29 +256,40 @@ {#if savedConnections.length > 0} - + + Connections {#each savedConnections as conn (conn.id)} {@const isCurrent = conn.id === activeConnectionId} {@const Icon = connIcon(conn)} + {@const subtitle = conn.database && conn.database !== (conn.name ?? conn.host) ? conn.database : (conn.host ?? '')} { if (!isCurrent) onswitchconnection(conn); connOpen = false }} > - + + +
-
{conn.name ?? conn.host ?? conn.filePath ?? 'Connection'}
- {#if conn.database && conn.database !== (conn.name ?? conn.host)} -
{conn.database}
+
+ {conn.name ?? conn.host ?? conn.filePath ?? 'Connection'} +
+ {#if subtitle} +
{subtitle}
{/if}
- {#if isCurrent}{/if} + {#if isCurrent}{/if}
{/each} - { connOpen = false; onconnect() }}> - + { connOpen = false; onconnect() }}> + + + Manage connections…
@@ -237,7 +302,7 @@
{ if (o && databases.length === 0) void fetchDatabases(); if (!o) dbSearch = '' }} + onOpenChange={(o) => { if (o && dbList.length === 0) void fetchDatabases(); if (!o) dbSearch = '' }} > {#if connection?.type === 'sqlite'} + {:else if isD1} + {:else} {/if} - {currentDb || 'No database'} - {#if isPostgres} + {currentDbLabel || 'No database'} + {#if canSwitchDb} {/if} - {#if databases.length > 5} + {#if dbList.length > 5}
{ if (e.key === 'Escape') { dbSearch = ''; dbOpen = false } }} @@ -278,24 +345,28 @@ {dbSearch ? 'No match' : 'No databases found'}
{:else} - {#each dbFiltered.slice(0, 200) as db (db)} - {@const isCurrent = db === currentDb} + {#each dbFiltered.slice(0, 200) as db (db.key)} + {@const isCurrent = db.key === currentDbKey} switchDb(db)} > - - {db} + {#if isD1} + + {:else} + + {/if} + {db.label} {#if isCurrent}{/if} {/each} {/if}
- {#if isPostgres} + {#if canSwitchDb}
- {databases.length} database{databases.length === 1 ? '' : 's'} + {dbList.length} database{dbList.length === 1 ? '' : 's'}
- + {#if isPostgres} + + {/if}
{/if} @@ -447,41 +520,20 @@ {#if connection} - {@const dbT = connection.type ?? 'postgres'} - + Tools - {#if dbT === 'postgres' || dbT === 'mysql'} - - Schema Explorer + {#each toolItems as item (item.label)} + {@const Icon = item.icon} + + + {item.label} + {#if !hasPro}{@render proBadge()}{/if} - {/if} - - Activity Log - - {#if dbT === 'postgres'} - - Security - - {/if} - - ORM Runner - - - Backup & Restore - - - Charts - - - Dashboard - - - Diagrams - + {/each} {@render sep()} @@ -632,6 +684,6 @@ connType={connection?.type ?? 'postgres'} oncreate={async (opts) => { await oncreatedatabase(opts) - databases = [] + dbList = [] }} /> diff --git a/src/lib/components/StructureView.svelte b/src/lib/components/StructureView.svelte index 3de77ebc..19d80efc 100644 --- a/src/lib/components/StructureView.svelte +++ b/src/lib/components/StructureView.svelte @@ -1,6 +1,6 @@ (showConnectionModal = true)} onsample={handleSampleConnect} /> - onConnected(conn, id)} /> + onConnected(conn, id)} maxConnections={$hasPro ? Infinity : FREE_CONNECTION_LIMIT} /> (showReportIssueDialog = true)} /> + + + + +
+ +
+

Stroke Pro required

+

This feature is not available on the free plan. Upgrade to Stroke Pro to unlock AI, dashboards, ORM runner, schema explorer, and more.

+
+ + +
+
+
+
+ (statusBarHasUpdate = true)} /> @@ -3275,55 +3367,25 @@ let rowSearch = $state('') {#if autoConnecting}
- -
-
-
-
- -
- -
-
-
- {#each [80, 60, 95, 70, 55, 85, 65] as w} -
- {/each} -
-
- - -
- -
-
-
-
- -
-
-
-
-
- -
- - - - - -

Connecting…

-
+ +
+ +
+
- -
-
-
+ +
+

Reconnecting

+

Establishing database connection…

{/if} @@ -3818,6 +3880,7 @@ let rowSearch = $state('') onhistoryselect={(sql) => void openQueryInEditor(sql)} onsavequery={handleSaveQuery} onfixwithai={handleFixWithAi} + onprorequired={() => (showProGate = true)} /> {/await} @@ -3930,6 +3993,7 @@ let rowSearch = $state('') ondeleteselected={() => void deleteSelectedRows()} onexport={handleExport} onaddrow={() => dtBeginInsertRow?.()} + onopeninsql={openTableInSqlEditor} readonly={tableReadonly} {hiddenColumns} virtualColCount={vcolCount} @@ -3945,6 +4009,7 @@ let rowSearch = $state('') hiddenColumns = next if (activeTable) saveHiddenCols(persistConnectionId, activeSchema, activeTable, next) }} + onfocuscolumn={(name) => focusTableColumn?.(name)} onprev={async () => { if (page <= 1) return await handlePageChange(page - 1) @@ -3985,6 +4050,7 @@ let rowSearch = $state('') bind:resetEdits bind:scrollToTop={scrollTableTop} bind:scrollToBottom={scrollTableBottom} + bind:focusColumn={focusTableColumn} bind:getScroll={tableGetScroll} bind:applyScroll={tableApplyScroll} bind:vcolPanelOpen @@ -4040,9 +4106,12 @@ let rowSearch = $state('') {#if !activeTab || activeTab.kind === 'welcome'} {@const isMac = navigator.platform.toUpperCase().includes('MAC')} {@const mod = isMac ? '⌘' : 'Ctrl'} - {@const cell = 'group flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-3 text-left transition-colors hover:border-border hover:bg-accent/40'} + {@const cell = 'group relative flex flex-col gap-3 rounded-lg border border-border/60 bg-card p-3 text-left transition-colors hover:border-border hover:bg-accent/40'} + {@const proCell = 'group relative flex flex-col gap-3 rounded-lg border border-border/40 bg-card/70 p-3 text-left cursor-not-allowed transition-colors hover:border-amber-500/30 hover:bg-amber-500/[0.03]'} {@const iconCls = 'size-3.5 text-muted-foreground transition-colors group-hover:text-foreground'} + {@const proIconCls = 'size-3.5 text-muted-foreground/40'} {@const labelCls = 'text-[11px] font-medium leading-none text-foreground/70 transition-colors group-hover:text-foreground'} + {@const proLabelCls = 'text-[11px] font-medium leading-none text-foreground/35'} {@const hotkeyCls = 'text-[9px] tabular-nums text-muted-foreground/50 group-hover:text-muted-foreground transition-colors self-end'} -
+ +
{#if tableViewMode === "structure"} {#if tableViewMode !== "structure"} + +
+ + {/snippet} + {#snippet header()} +
+ Jump to column +
+ {/snippet} + {#snippet item(it)} + + {it.label} + {/snippet} + + + +
+ @@ -653,11 +713,10 @@ {#if total > 0} {to.toLocaleString("en-US")} of {total.toLocaleString("en-US")} loaded - {#if queryMs > 0}· {queryMs}ms{/if} {/if} {:else} @@ -672,14 +731,9 @@ {:else} of {total.toLocaleString("en-US")} {/if} - {#if queryMs > 0}· {queryMs}ms{/if} - {:else if queryMs > 0} - {queryMs}ms {/if} - - - + { if (open) { @@ -840,6 +894,7 @@ + { diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 950569b1..e44c1572 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -7,6 +7,9 @@ import Sparkles from '@lucide/svelte/icons/sparkles' import MessageSquare from '@lucide/svelte/icons/message-square' import X from '@lucide/svelte/icons/x' + import Minus from '@lucide/svelte/icons/minus' + import Square from '@lucide/svelte/icons/square' + import Copy from '@lucide/svelte/icons/copy' import KeyRound from '@lucide/svelte/icons/key-round' import { cn } from '$lib/utils.js' import { detectOs } from '$lib/platform.js' @@ -74,6 +77,8 @@ async function winToggleMaximize() { try { await getCurrentWindow().toggleMaximize() } catch {} } const iconBtn = 'inline-flex size-[24px] items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-white/[0.07] hover:text-muted-foreground' + // Windows / Linux native-style caption buttons: full-height, square, flush right. + const winCtl = 'pointer-events-auto inline-flex h-full w-[46px] items-center justify-center text-muted-foreground/55 transition-colors hover:bg-white/[0.08] hover:text-foreground' {#if isTauri && !fullscreen} @@ -95,33 +100,38 @@
- -
- - - + -
+ + +
- -
+ +
+ {:else} + +
+ {/if}
-
+
{#if $isTrialActive} @@ -216,6 +226,25 @@
+ {#if !isMac} + +
+ + + +
+ {/if} +
{/if} diff --git a/src/lib/components/ui/sonner/index.js b/src/lib/components/ui/sonner/index.js index 1f016b4e..ca2daed6 100644 --- a/src/lib/components/ui/sonner/index.js +++ b/src/lib/components/ui/sonner/index.js @@ -1 +1,2 @@ -export { default as Toaster } from "./sonner.svelte"; \ No newline at end of file +export { default as Toaster } from './sonner.svelte' +export { toast } from './toast.svelte.js' diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte index d8248add..bd48d644 100644 --- a/src/lib/components/ui/sonner/sonner.svelte +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -1,52 +1,41 @@
{#if showClearAll} {/if} - - {#snippet loadingIcon()} - - {/snippet} - {#snippet successIcon()} - - {/snippet} - {#snippet errorIcon()} - - {/snippet} - {#snippet infoIcon()} - - {/snippet} - {#snippet warningIcon()} -
+ {/each} diff --git a/src/lib/components/ui/sonner/toast.svelte.js b/src/lib/components/ui/sonner/toast.svelte.js new file mode 100644 index 00000000..e1fe0a40 --- /dev/null +++ b/src/lib/components/ui/sonner/toast.svelte.js @@ -0,0 +1,105 @@ +/** + * Self-contained toast store (replaces svelte-sonner). + * + * svelte-sonner tracks per-toast heights in a parallel array that diverges from + * the toast list, which crashes / infinite-loops (`effect_update_depth_exceeded`) + * when several toasts dismiss at once. This implementation keeps a single plain + * reactive list and lets Svelte transitions handle enter/exit animation, so there + * is no height bookkeeping and no way to form a read-write reactive cycle. + * + * API surface mirrors the parts of svelte-sonner the app uses: + * toast.success/error/info/warning/message(title, { description?, duration? }) + * toast.dismiss(id?) — dismiss one, or all when id is omitted + * toast.getActiveToasts() — current toasts (reactive read) + */ + +let counter = 0 +const DEFAULT_DURATION = 4500 + +/** + * @typedef {'success' | 'error' | 'info' | 'warning' | 'message'} ToastType + * @typedef {{ id: number, type: ToastType, title: string, description: string }} Toast + * @typedef {{ description?: string, duration?: number }} ToastOptions + */ + +class ToastStore { + /** @type {Toast[]} */ + toasts = $state([]) + + /** @type {Map }>} */ + #timers = new Map() + + /** + * @param {number} id + * @param {number} duration + */ + #schedule(id, duration) { + if (!Number.isFinite(duration) || duration <= 0) return + const handle = setTimeout(() => this.dismiss(id), duration) + this.#timers.set(id, { remaining: duration, start: Date.now(), handle }) + } + + /** + * @param {ToastType} type + * @param {unknown} title + * @param {ToastOptions} [opts] + */ + #add(type, title, opts = {}) { + const id = ++counter + const duration = opts.duration ?? DEFAULT_DURATION + // newest first; immutable reassignment keeps the read-side reactive + this.toasts = [ + { id, type, title: String(title ?? ''), description: opts.description ?? '' }, + ...this.toasts, + ] + this.#schedule(id, duration) + return id + } + + /** @param {unknown} title @param {ToastOptions} [opts] */ + success = (title, opts) => this.#add('success', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + error = (title, opts) => this.#add('error', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + info = (title, opts) => this.#add('info', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + warning = (title, opts) => this.#add('warning', title, opts) + /** @param {unknown} title @param {ToastOptions} [opts] */ + message = (title, opts) => this.#add('message', title, opts) + + /** @param {number} [id] */ + dismiss = (id) => { + if (id === undefined) { + for (const t of this.#timers.values()) clearTimeout(t.handle) + this.#timers.clear() + this.toasts = [] + return + } + const t = this.#timers.get(id) + if (t) { + clearTimeout(t.handle) + this.#timers.delete(id) + } + this.toasts = this.toasts.filter((x) => x.id !== id) + } + + getActiveToasts = () => this.toasts + + /** Pause every auto-dismiss timer (e.g. while the pointer is over the stack). */ + pauseAll = () => { + for (const t of this.#timers.values()) { + clearTimeout(t.handle) + t.remaining -= Date.now() - t.start + } + } + + /** Resume previously-paused auto-dismiss timers. */ + resumeAll = () => { + for (const [id, t] of this.#timers) { + t.start = Date.now() + t.handle = setTimeout(() => this.dismiss(id), Math.max(0, t.remaining)) + } + } +} + +export const toast = new ToastStore() diff --git a/src/lib/stores/license.js b/src/lib/stores/license.js index f5d5b9bb..3376361b 100644 --- a/src/lib/stores/license.js +++ b/src/lib/stores/license.js @@ -77,3 +77,35 @@ export async function runLicenseCheck() { // Non-critical — ignore errors, license state is already loaded } } + +// ── Free-tier feature gating ───────────────────────────────────────────────── + +export const FREE_CONNECTION_LIMIT = 3 + +/** Features only available on a Pro plan (Valid license or active Trial). */ +const PRO_FEATURES = new Set([ + 'ai', 'dashboard', 'orm', 'security', 'logs', + 'sql_chart', 'sql_json', 'sql_explain', + 'charts', 'diagrams', 'schema_timeline', 'data_diff', + 'extensions', 'backup', 'erd', 'search', 'notebook', 'json_tab', +]) + +/** + * Reactive: true when the user has Pro access (Valid license or active Trial). + * Null state (still loading from Tauri) also returns true to prevent UI flicker. + */ +export const hasPro = derived( + licenseStatus, + ($s) => $s === null || $s?.status === 'Valid' || $s?.status === 'Trial', +) + +/** + * Check feature availability given a pro status value. + * Use in Svelte templates: canUseFeature('ai', $hasPro) + * @param {string} feature + * @param {boolean} pro + * @returns {boolean} + */ +export function canUseFeature(feature, pro) { + return pro || !PRO_FEATURES.has(feature) +} diff --git a/src/lib/table-query.js b/src/lib/table-query.js index 356e81ab..4b0c6b07 100644 --- a/src/lib/table-query.js +++ b/src/lib/table-query.js @@ -112,3 +112,92 @@ export function hasTableQuery(search, filters, sort) { Boolean(sort?.column) ) } + +/** + * Build a SELECT statement that reflects the current table view (columns, + * filters, search, sort). Meant as an editable starting point in the SQL editor, + * NOT a parameterized query — values are inlined and escaped for display. + * + * @param {object} opts + * @param {string} [opts.schema] + * @param {string} opts.table + * @param {{ name: string }[]} [opts.columns] — when given, emits an explicit column list + * @param {TableFilter[]} [opts.filters] + * @param {string} [opts.search] + * @param {TableSort | null} [opts.sort] + * @param {number} [opts.limit] + * @param {string} [opts.engine] — connection type, for quoting/casing + * @returns {string} + */ +export function buildSelectSql({ schema, table, columns = [], filters = [], search = '', sort = null, limit = 100, engine = 'postgres' }) { + const mysql = engine === 'mysql' || engine === 'mariadb' + const pg = !mysql // postgres/sqlite/etc. use double-quote identifiers + ILIKE-ish + /** quote identifier */ + const q = (/** @type {string} */ id) => + mysql ? '`' + id.replace(/`/g, '``') + '`' : '"' + id.replace(/"/g, '""') + '"' + /** quote a string literal */ + const lit = (/** @type {string} */ v) => `'${String(v).replace(/'/g, "''")}'` + const like = pg ? 'ILIKE' : 'LIKE' + /** text-cast a column for substring search */ + const asText = (/** @type {string} */ col) => (engine === 'postgres' ? `${col}::text` : col) + + const target = schema ? `${q(schema)}.${q(table)}` : q(table) + // SELECT * keeps the generated query clean and avoids referencing virtual / + // hidden columns. `columns` is still used below to expand the search clause. + const cols = '*' + + /** @param {TableFilter} f */ + const cond = (f) => { + const col = q(f.column) + const v = f.value.trim() + switch (f.op) { + case 'eq': return `${col} = ${lit(v)}` + case 'neq': return `${col} <> ${lit(v)}` + case 'gt': return `${col} > ${lit(v)}` + case 'gte': return `${col} >= ${lit(v)}` + case 'lt': return `${col} < ${lit(v)}` + case 'lte': return `${col} <= ${lit(v)}` + case 'contains': return `${asText(col)} ${like} ${lit('%' + v + '%')}` + case 'not_contains': return `${asText(col)} NOT ${like} ${lit('%' + v + '%')}` + case 'starts_with': return `${asText(col)} ${like} ${lit(v + '%')}` + case 'ends_with': return `${asText(col)} ${like} ${lit('%' + v)}` + case 'is_null': return `${col} IS NULL` + case 'is_not_null': return `${col} IS NOT NULL` + case 'between': { + const [a = '', b = ''] = v.split(',').map((s) => s.trim()) + return `${col} BETWEEN ${lit(a)} AND ${lit(b)}` + } + default: return '' + } + } + + /** @type {string[]} */ + const where = [] + + // Cross-column search (only when we know the columns) + const s = search.trim() + if (s && columns.length) { + const ors = columns.map((c) => `${asText(q(c.name))} ${like} ${lit('%' + s + '%')}`) + where.push(`(${ors.join(' OR ')})`) + } + + // Column filters, chained with their and/or conjunct + const fs = activeFilters(filters).filter((f) => f.column !== ANY_COLUMN) + fs.forEach((f, i) => { + const c = cond(f) + if (!c) return + where.push(i === 0 && where.length === 0 ? c : `${(f.conjunct ?? 'and').toUpperCase()} ${c}`) + }) + + let sql = `SELECT ${cols}\nFROM ${target}` + if (where.length) { + // First clause has no leading AND/OR; the search group (if present) leads. + sql += `\nWHERE ${where[0].replace(/^(AND|OR)\s+/, '')}` + for (let i = 1; i < where.length; i++) { + sql += /^(AND|OR)\s/.test(where[i]) ? `\n ${where[i]}` : `\n AND ${where[i]}` + } + } + if (sort?.column) sql += `\nORDER BY ${q(sort.column)} ${sort.direction === 'desc' ? 'DESC' : 'ASC'}` + if (limit && limit > 0 && limit !== MAX_PAGE_SIZE) sql += `\nLIMIT ${limit}` + return sql + ';' +} diff --git a/src/main.js b/src/main.js index 940fd864..c33f866b 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,19 @@ import './app.css' import App from './App.svelte' import { applySettings, installZoomShortcuts, loadSettings, resetWebviewZoom } from '$lib/stores/settings.js' +// Clear all local storage when VITE_FRESH_START=1 (used by `npm run tauri:fresh`) +if (import.meta.env.VITE_FRESH_START === '1') { + try { + localStorage.clear() + sessionStorage.clear() + if (window.indexedDB?.databases) { + window.indexedDB.databases().then((dbs) => { + for (const db of dbs) if (db.name) window.indexedDB.deleteDatabase(db.name) + }) + } + } catch (_) {} +} + applySettings(loadSettings()) installZoomShortcuts() resetWebviewZoom()