diff --git a/Cargo.lock b/Cargo.lock index 080111a..4fec1de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3248,6 +3248,7 @@ dependencies = [ "tree-sitter", "tree-sitter-json", "tree-sitter-rust", + "tree-sitter-sequel", "uuid", "windows 0.58.0", ] @@ -9097,6 +9098,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/Cargo.toml b/Cargo.toml index 7fe8ac1..a8d7a10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ categories = ["database", "gui"] # component catalog all come from the same pinned revision. Mixing the # crates.io release with the Git one would put two incompatible copies of # `gpui-base` in the build. -gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" } +# `tree-sitter-sql`: the SQL editor and the SQL in `.dash` heredocs are the only +# code DuckLocal highlights, so it links that one grammar rather than the set. +gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da", features = ["tree-sitter-sql"] } gpui-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" } gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "13c716b687b47f96a677aaf46c8fa341fa7208da" } duckdb = { version = "1", features = ["bundled", "json", "parquet"] } diff --git a/README.md b/README.md index f17b656..b257255 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Your data stays on your machine: nothing is uploaded, and there is no account. - Results grid with filtering, cell copy, CSV/Parquet export and built-in charts - Query history with one-click refill into the editor - Optional S3 support via httpfs; credentials are session-only -- Light and dark themes, English and 简体中文 +- Light and dark themes, four interface sizes (⌘+ / ⌘− / ⌘0), English and 简体中文 ## AI CLI and official skill @@ -72,7 +72,7 @@ Inspect existing destinations before overwriting. No global configuration is cha ## Documentation -Guides live in [`docs/`](docs/index.md): [getting started](docs/getting-started.md), [data sources](docs/data-sources.md), [S3 and httpfs](docs/s3.md), [SQL editor](docs/sql-editor.md), [schema browser and history](docs/schema-and-history.md), [results and charts](docs/results-and-charts.md), [settings and app data](docs/settings-and-data.md), and [development](docs/development.md). +Guides live in [`docs/`](docs/index.md): [getting started](docs/getting-started.md), a [10-minute tutorial](docs/tutorial.md) with sample data, [troubleshooting](docs/troubleshooting.md), [data sources](docs/data-sources.md), [S3 and httpfs](docs/s3.md), [SQL editor](docs/sql-editor.md), [schema browser and history](docs/schema-and-history.md), [results and charts](docs/results-and-charts.md), [settings and app data](docs/settings-and-data.md), and [development](docs/development.md). The same pages are published with VitePress. With Node.js 22 or later (24 LTS recommended), run `npm --prefix docs ci`, then `npm --prefix docs run build` to generate `target/docs-site`, or `npm --prefix docs run dev` for local development. CI deploys the site to GitHub Pages whenever `docs/` changes on `main`. See [development](docs/development.md#the-documentation-site) for preview commands and configuration. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4da6e29..1496497 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,7 +30,7 @@ ducklocal warehouse.duckdb # 或已有的 DuckDB 数据库 - 结果表格支持筛选、单元格复制、CSV/Parquet 导出和内置图表 - 查询历史,单击回填编辑器 - 可选 S3 支持(httpfs),凭据仅当前会话有效 -- 明暗主题切换,中英文界面 +- 明暗主题切换,四档界面大小(⌘+ / ⌘− / ⌘0),中英文界面 ## AI CLI 与官方 skill @@ -72,7 +72,7 @@ cp -R skills/ducklocal /path/to/your-project/.claude/skills/ ## 文档 -完整指南在 [`docs/`](docs/zh/index.md):[快速上手](docs/zh/getting-started.md)、[数据源](docs/zh/data-sources.md)、[S3 与 httpfs](docs/zh/s3.md)、[SQL 编辑器](docs/zh/sql-editor.md)、[Schema 浏览与历史](docs/zh/schema-and-history.md)、[结果与图表](docs/zh/results-and-charts.md)、[设置与应用数据](docs/zh/settings-and-data.md)、[开发](docs/zh/development.md)。 +完整指南在 [`docs/`](docs/zh/index.md):[快速上手](docs/zh/getting-started.md)、带样例数据的 [10 分钟上手教程](docs/zh/tutorial.md)、[常见问题排查](docs/zh/troubleshooting.md)、[数据源](docs/zh/data-sources.md)、[S3 与 httpfs](docs/zh/s3.md)、[SQL 编辑器](docs/zh/sql-editor.md)、[Schema 浏览与历史](docs/zh/schema-and-history.md)、[结果与图表](docs/zh/results-and-charts.md)、[设置与应用数据](docs/zh/settings-and-data.md)、[开发](docs/zh/development.md)。 这些页面同时会以 VitePress 站点形式发布。需要 Node.js 22 及以上(推荐 24 LTS):`npm --prefix docs ci` 安装依赖,`npm --prefix docs run build` 生成到 `target/docs-site`,`npm --prefix docs run dev` 本地开发。CI 在 `main` 分支的 `docs/` 有改动时自动部署到 GitHub Pages。详见[开发](docs/zh/development.md#文档站)。 diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 40a2db3..dc5e0a0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -25,7 +25,11 @@ function sidebar(zh = false): DefaultTheme.SidebarItem[] { return [ { text: zh ? '入门' : 'Getting started', - items: [item('getting-started', 'Quick start', '快速上手')], + items: [ + item('getting-started', 'Install and first query', '安装与第一条查询'), + item('tutorial', 'Your first 10 minutes', '10 分钟上手教程'), + item('troubleshooting', 'Troubleshooting', '常见问题排查'), + ], }, { text: zh ? '使用指南' : 'Guides', @@ -39,13 +43,16 @@ function sidebar(zh = false): DefaultTheme.SidebarItem[] { ], }, { - text: zh ? '开发' : 'Development', + text: zh ? '自动化与扩展' : 'Automate and extend', items: [ - item('cli', 'AI CLI and official skill', 'AI CLI 与官方 skill'), - item('analysis-app', 'Analysis apps', '分析应用'), - item('development', 'Development guide', '开发指南'), + item('cli', 'CLI and agent skill', 'CLI 与 agent skill'), + item('analysis-app', 'Apps and dashboards', '分析应用与 Dashboard'), ], }, + { + text: zh ? '参与开发' : 'Contributing', + items: [item('development', 'Development guide', '开发指南')], + }, ] } @@ -63,8 +70,9 @@ export default defineConfig({ lang: 'en', themeConfig: { nav: [ - { text: 'Guide', link: '/getting-started' }, - { text: 'Development', link: '/development' }, + { text: 'Get started', link: '/getting-started' }, + { text: 'Tutorial', link: '/tutorial' }, + { text: 'CLI', link: '/cli' }, { text: 'Download', link: `${repository}/releases/latest` }, ], sidebar: sidebar(), @@ -76,8 +84,9 @@ export default defineConfig({ description: '本地优先的数据查询与分析工作台,原生基于 DuckDB。', themeConfig: { nav: [ - { text: '指南', link: '/zh/getting-started' }, - { text: '开发', link: '/zh/development' }, + { text: '快速上手', link: '/zh/getting-started' }, + { text: '教程', link: '/zh/tutorial' }, + { text: 'CLI', link: '/zh/cli' }, { text: '下载', link: `${repository}/releases/latest` }, ], sidebar: sidebar(true), diff --git a/docs/analysis-app.md b/docs/analysis-app.md index c24feb2..b23bfdf 100644 --- a/docs/analysis-app.md +++ b/docs/analysis-app.md @@ -254,9 +254,9 @@ Because it is the loading state, a statement an app only runs on a click is not A `.dash` file is a dashboard declared as data — query and plot blocks, no JavaScript — for the common case of standard plots over saved queries. The file format and `ducklocal check` validation are in [the CLI guide](cli.md#check-a-dashboard-spec). -Open one like an app: name it on the command line (`ducklocal dashboard.dash`) or drag the file onto the window, and it opens as a dashboard tab beside your queries and apps. The tab runs the spec's queries on the window's own connection — so a dashboard sees connection-local state such as `TEMP` tables, and queues with the editor's queries — and renders each plot as one panel of a vertical stack whose dividers drag to resize. Only read-only statements run: a query that could write (DDL, DML, `COPY`, `ATTACH`, …) is refused before it reaches the connection, so opening a `.dash` file someone sent you cannot change your data. A plot whose query fails shows the reason in its own panel; the rest of the dashboard still draws. The toolbar's reload re-reads the file and re-runs everything, and a reload that fails validation never replaces a working dashboard — the reason appears above it instead. Open dashboards are remembered between launches, exactly like apps. +Open one like an app: choose **Open dashboard…** from the tab strip's `+` menu, name it on the command line (`ducklocal dashboard.dash`), or drag the file onto the window, and it opens as a dashboard tab beside your queries and apps. The tab runs the spec's queries on the window's own connection — so a dashboard sees connection-local state such as `TEMP` tables, and queues with the editor's queries — and renders each plot as one panel of a vertical stack whose dividers drag to resize. Only read-only statements run: a query that could write (DDL, DML, `COPY`, `ATTACH`, …) is refused before it reaches the connection, so opening a `.dash` file someone sent you cannot change your data. A plot whose query fails shows the reason in its own panel; the rest of the dashboard still draws. The toolbar's reload re-reads the file and re-runs everything, and a reload that fails validation never replaces a working dashboard — the reason appears above it instead. Open dashboards are remembered between launches, exactly like apps. -The tab is also an editor: its source view edits the file with completion and diagnostics, and writes back with the save button or ⌘S — a save whose spec no longer validates keeps the last working dashboard up and says why. Outside the GUI, `ducklocal lsp` serves the same completion, diagnostics, hover and go-to-definition to any LSP-capable editor (see [the CLI guide](cli.md#edit-a-dashboard-spec-with-lsp)). +The tab is also an editor: its source view edits the file with syntax highlighting — the SQL in heredocs coloured as it is in the SQL editor — completion and diagnostics, and writes back with the save button or ⌘S — a save whose spec no longer validates keeps the last working dashboard up and says why. Outside the GUI, `ducklocal lsp` serves the same completion, diagnostics, hover and go-to-definition to any LSP-capable editor (see [the CLI guide](cli.md#edit-a-dashboard-spec-with-lsp)). ## Known limits diff --git a/docs/getting-started.md b/docs/getting-started.md index 765f028..2b0dff7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,22 +2,27 @@ **[中文](zh/getting-started.md)** · [Docs](index.md) -## Requirements +DuckLocal is a desktop app for asking questions of the data files on your +machine with SQL. There is no server to run, no connection to configure, and +no account — you point it at files and start querying. This page takes you +from download to your first result in about five minutes. -The released build runs on **macOS 12.0 or later, Apple silicon only**. The -distributed disk image is signed and notarized, so it opens without a -Gatekeeper workaround. +## 1. Install {#install} -To build from source you need Rust stable, **1.85.1 or newer**, and a C++ -toolchain — DuckDB is compiled from vendored sources. The first build takes a -while and needs a few gigabytes of disk. +You need **macOS 12 or later on Apple silicon** (M1 or newer). -## Install +1. Download [`ducklocal-macos-arm64.dmg`](https://github.com/JetSquirrel/DuckLocal/releases/latest) + from the latest release. +2. Open it and drag **DuckLocal** into **Applications**. +3. Launch DuckLocal from Applications or Spotlight. -**From the disk image.** Download `ducklocal-macos-arm64.dmg`, open it, and -drag `DuckLocal.app` to Applications. +The disk image is signed and notarized by Apple, so it opens without any +Gatekeeper workaround. -**From source.** +::: details Building from source instead +You need Rust stable 1.85.1 or newer and a C++ toolchain (Xcode Command Line +Tools). DuckDB is compiled from vendored sources, so the first build takes +several minutes and a few gigabytes of disk. ```bash git clone https://github.com/JetSquirrel/DuckLocal @@ -26,53 +31,77 @@ cargo build --release ./target/release/ducklocal ``` -`cargo run` works too, and is quicker to iterate on. Builds are slow the first -time because of DuckDB; afterwards only DuckLocal itself recompiles. +The binary is `./target/release/ducklocal`; use it wherever this guide says +`ducklocal`. See [Development](development.md) for bundling a `.app`. +::: -## Open your data +## 2. Add the `ducklocal` command (optional) {#add-command} -DuckLocal will open whatever you name on the command line: +Everything in the app works without a terminal. If you also want to open data +from the command line — or let an AI agent run queries with the +[CLI](cli.md) — link the binary inside the app onto your `PATH`: ```bash -ducklocal ./logs/ # every data file under a folder, recursively -ducklocal ./billing.parquet # one file -ducklocal './data/*.csv' # a pattern (quote it, so the shell does not expand it) -ducklocal warehouse.duckdb # or an existing DuckDB database +sudo mkdir -p /usr/local/bin +sudo ln -sf /Applications/DuckLocal.app/Contents/MacOS/ducklocal /usr/local/bin/ducklocal +ducklocal --version ``` -You can name several paths at once, and mix them freely. +The link follows the app, so updating DuckLocal in Applications updates the +command too. To remove it: `sudo rm /usr/local/bin/ducklocal`. + +## 3. Open some data -There are two more ways in, both equivalent: +The first launch opens on a screen titled **Drop your data in**. From here, +any of these gets data in: -- **Open data…** in the title bar opens a dialog where you type or browse for - paths. An **In-memory** button in that dialog switches to a plain in-memory - workspace with nothing attached. -- **Drag files or folders onto the window.** +- **Drag** CSV, TSV, Parquet, JSON or Excel files — or a whole folder — onto + the window. +- Click **Open files…** or **Open folder…**. +- From a terminal, name the paths: -Every CSV, TSV, Parquet, JSON, or Excel file becomes a queryable relation, and every file is -registered — the next launch starts with the same workspace. See -[Data sources](data-sources.md) for the details. + ```bash + ducklocal ./sales.csv # one file + ducklocal ./logs/ # every data file under a folder, recursively + ducklocal './data/*.parquet' # a pattern — quote it so the shell leaves it alone + ducklocal warehouse.duckdb # an existing DuckDB database + ``` -## Run a query +Each file becomes a view named after the file — `sales.csv` becomes `sales` — +and shows up under **Local files** in the sidebar with its columns and row +count. DuckLocal reads the files where they are; nothing is copied or +uploaded. -The workspace opens with one query tab. Type SQL and press **⌘↵** (Cmd+Enter) -to run it. `Run`, `Format`, and `EXPLAIN` sit in the toolbar above the editor. +Files you open are remembered, so the next launch starts with the same +workspace. [Data sources](data-sources.md) has the details on formats, +folders, patterns and databases. -Results appear in the panel below, as a grid or a chart. +::: tip No data handy? +The [first-10-minutes tutorial](tutorial.md) walks through a small sample +file step by step. +::: -## The first-run screen +## 4. Run a query + +Click **New query** (or open some data — the editor appears on its own). Type +SQL and press **⌘↵** (Cmd+Enter): + +```sql +SELECT * FROM sales LIMIT 20; +``` -When nothing is attached and nothing has been registered yet, the workspace -shows three buttons instead of the editor: **Open file…**, **Open folder…**, -and **New query**. Pick one of the first two to attach data, or **New query** -to get an editor anyway. +The result appears in the panel under the editor. Switch it to **Chart** for a +quick picture, or export it as CSV or Parquet. The toolbar above the editor +has **Run**, **Format** and **EXPLAIN**. -One quirk worth knowing: on this screen **⌘↵** reveals the editor rather than -running anything. +A shortcut worth knowing: in the sidebar, click the play button beside a +table to get a ready-made `SELECT * … LIMIT 100`, or a column name to select +just that column. ## Where to go next +- [Your first 10 minutes](tutorial.md) — a guided tour with sample data - [Data sources](data-sources.md) — formats, folders, patterns, databases -- [SQL editor](sql-editor.md) — tabs, shortcuts, EXPLAIN -- [Results and charts](results-and-charts.md) — what you can do with a result -- [Settings and app data](settings-and-data.md) — where everything is stored +- [SQL editor](sql-editor.md) — tabs, shortcuts, autocompletion, EXPLAIN +- [Results and charts](results-and-charts.md) — filtering, export, charts +- [Troubleshooting](troubleshooting.md) — when something does not behave diff --git a/docs/index.md b/docs/index.md index 085c2b0..0d2202f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,8 @@ layout: home hero: name: DuckLocal - text: Your data. Your workspace. - tagline: A local-first workspace for querying and exploring your data, built natively on DuckDB. + text: SQL on your files, right on your Mac. + tagline: Drop in CSV, Parquet, JSON or Excel and query it with DuckDB. No server, no setup, no upload. image: src: /assets/logo.png alt: DuckLocal @@ -11,62 +11,73 @@ hero: - theme: brand text: Get started link: /getting-started + - theme: alt + text: Try the 10-minute tutorial + link: /tutorial - theme: alt text: Download for macOS link: https://github.com/JetSquirrel/DuckLocal/releases/latest - - theme: alt - text: GitHub - link: https://github.com/JetSquirrel/DuckLocal features: - - title: Query local files - details: Open CSV, Parquet, JSON, Excel, or a whole folder. No connection setup or uploads. + - title: Query files where they are + details: A file, a folder, a glob or a .duckdb database becomes queryable the moment you open it — and stays there next launch. link: /data-sources - linkText: Explore data sources + linkText: Data sources + - title: Nothing leaves your machine + details: No account, no telemetry, no cloud. Files are read in place and never copied or uploaded. + link: /settings-and-data + linkText: What is stored, and where - title: A focused SQL workspace - details: Write SQL with highlighting, autocompletion, formatting, and multiple query tabs. + details: Query tabs, autocompletion from your own tables, one-click formatting and EXPLAIN. link: /sql-editor - linkText: Meet the editor - - title: Explore your results - details: Filter the results grid, copy cells, export CSV or Parquet, and explore built-in charts. + linkText: The SQL editor + - title: From rows to a picture + details: Filter the grid, chart the result, and export it as CSV or Parquet. link: /results-and-charts linkText: Results and charts + - title: Built for AI agents + details: A headless CLI with a precise JSON contract, plus an official agent skill for schema-first analysis. + link: /cli + linkText: CLI and skill + - title: Dashboards as files + details: Declare queries and plots in a .dash file, or script a custom view and export it as one HTML page to share. + link: /analysis-app + linkText: Apps and dashboards --- -# DuckLocal +

中文文档 · macOS 12+ on Apple silicon · Free and open source (Apache-2.0)

+ +## Up and running in three steps -**[中文文档](zh/index.md)** · [Project README](https://github.com/JetSquirrel/DuckLocal#readme) +1. **[Download](https://github.com/JetSquirrel/DuckLocal/releases/latest)** + the disk image and drag DuckLocal to Applications. +2. **Drop a data file** — or a whole folder — onto the window. Each file + becomes a view named after it. +3. **Press ⌘↵** on a query: -A local-first workspace for querying and exploring your data, built natively on DuckDB. -Point it at your files — there is no connection to configure, no schema to create, -and nothing is uploaded anywhere. +```sql +SELECT channel, sum(revenue) AS revenue +FROM sales +GROUP BY channel +ORDER BY revenue DESC; +``` -macOS 12 or later, Apple silicon. Free and open source under Apache-2.0. -[Download for macOS](https://github.com/JetSquirrel/DuckLocal/releases/latest) · -[View on GitHub](https://github.com/JetSquirrel/DuckLocal) +New here? [Getting started](getting-started.md) covers installation and the +first launch; [Your first 10 minutes](tutorial.md) is a guided tour with a +sample file. ![How DuckLocal works: CSV, Parquet and DuckDB files, plus S3-compatible object storage, all feeding one local workspace](assets/intro.jpg) -## Guides +## Find your way -| Guide | What it covers | +| I want to… | Read | | --- | --- | -| [Getting started](getting-started.md) | Install DuckLocal, open your first files, run your first query | -| [Data sources](data-sources.md) | Which file formats are supported, how folders and patterns resolve, how databases open, view naming, and the limits | -| [S3 and httpfs](s3.md) | Point DuckLocal at an S3-compatible bucket, browse it, and query objects | -| [SQL editor](sql-editor.md) | Query tabs, running and formatting SQL, EXPLAIN, and autocompletion | -| [Schema browser and history](schema-and-history.md) | Browse tables and columns, generate SELECT statements, alter column types, and reuse past queries | -| [Results and charts](results-and-charts.md) | The results grid, filtering, copying, export, and the built-in charts | -| [Settings and app data](settings-and-data.md) | Where DuckLocal keeps its files, what survives a restart, themes, language, and troubleshooting | -| [AI CLI and official skill](cli.md) | Run headless SQL, inspect the JSON contract, and install the official agent skill | -| [Analysis apps](analysis-app.md) | Open a window whose contents are a JavaScript app you wrote, over the same connection | -| [Development](development.md) | Build, test, bundle a `.app`, and cut a signed and notarized release | - -## At a glance - -- Local CSV / TSV / Parquet / JSON / Excel files, or whole folders — - from the command line, the file dialog, or a drop on the window -- SQL editor with syntax highlighting, autocompletion, formatting, and - multiple tabs -- Schema sidebar for browsing databases, schemas, tables, and columns -- Results grid with filtering, cell copy, CSV and Parquet export, and charts -- Query history, optional S3 support, light and dark themes, English and 简体中文 +| Install DuckLocal and run a first query | [Getting started](getting-started.md) | +| Learn by doing, with sample data | [Your first 10 minutes](tutorial.md) | +| Open folders, globs, Excel or a `.duckdb` file | [Data sources](data-sources.md) | +| Query objects in S3 or a compatible store | [S3 and httpfs](s3.md) | +| Get more out of the editor | [SQL editor](sql-editor.md) · [Schema and history](schema-and-history.md) | +| Chart or export a result | [Results and charts](results-and-charts.md) | +| Let an AI agent query my data | [CLI and agent skill](cli.md) | +| Build a dashboard | [Apps and dashboards](analysis-app.md) | +| Fix something that is not working | [Troubleshooting](troubleshooting.md) | +| Build DuckLocal from source or contribute | [Development](development.md) | diff --git a/docs/public/samples/sales.csv b/docs/public/samples/sales.csv new file mode 100644 index 0000000..04503b7 --- /dev/null +++ b/docs/public/samples/sales.csv @@ -0,0 +1,225 @@ +date,channel,region,orders,revenue +2026-09-01,Mobile app,East,72,4181.04 +2026-09-01,Mobile app,North,92,6169.52 +2026-09-01,Mobile app,South,84,5012.28 +2026-09-01,Mobile app,West,68,4052.8 +2026-09-01,Phone,East,43,2186.98 +2026-09-01,Phone,North,31,1352.53 +2026-09-01,Phone,South,15,678.9 +2026-09-01,Phone,West,15,601.95 +2026-09-01,Store,East,42,4276.02 +2026-09-01,Store,North,38,3637.36 +2026-09-01,Store,South,22,2496.78 +2026-09-01,Store,West,54,6003.72 +2026-09-01,Web,East,81,7195.23 +2026-09-01,Web,North,61,5410.7 +2026-09-01,Web,South,69,5477.91 +2026-09-01,Web,West,53,3864.76 +2026-09-02,Mobile app,East,74,4582.08 +2026-09-02,Mobile app,North,78,5551.26 +2026-09-02,Mobile app,South,86,4802.24 +2026-09-02,Mobile app,West,70,4955.3 +2026-09-02,Phone,East,49,2129.05 +2026-09-02,Phone,North,45,2136.6 +2026-09-02,Phone,South,21,872.13 +2026-09-02,Phone,West,37,2128.98 +2026-09-02,Store,East,56,5820.64 +2026-09-02,Store,North,20,2072.6 +2026-09-02,Store,South,44,4369.2 +2026-09-02,Store,West,20,2099.4 +2026-09-02,Web,East,75,5667.0 +2026-09-02,Web,North,47,3519.83 +2026-09-02,Web,South,55,3979.8 +2026-09-02,Web,West,47,3298.93 +2026-09-03,Mobile app,East,83,4711.91 +2026-09-03,Mobile app,North,95,6247.2 +2026-09-03,Mobile app,South,87,4897.23 +2026-09-03,Mobile app,West,95,5283.9 +2026-09-03,Phone,East,16,731.52 +2026-09-03,Phone,North,44,2291.96 +2026-09-03,Phone,South,28,1356.32 +2026-09-03,Phone,West,12,633.48 +2026-09-03,Store,East,53,5880.35 +2026-09-03,Store,North,57,5621.34 +2026-09-03,Store,South,33,3307.59 +2026-09-03,Store,West,33,3578.52 +2026-09-03,Web,East,68,5310.12 +2026-09-03,Web,North,48,3657.6 +2026-09-03,Web,South,64,4516.48 +2026-09-03,Web,West,80,5796.8 +2026-09-04,Mobile app,East,81,5357.34 +2026-09-04,Mobile app,North,77,5404.63 +2026-09-04,Mobile app,South,77,5302.22 +2026-09-04,Mobile app,West,61,4158.37 +2026-09-04,Phone,East,26,1249.82 +2026-09-04,Phone,North,38,1934.2 +2026-09-04,Phone,South,30,1638.9 +2026-09-04,Phone,West,30,1436.4 +2026-09-04,Store,East,55,6034.6 +2026-09-04,Store,North,59,6747.83 +2026-09-04,Store,South,51,5293.8 +2026-09-04,Store,West,35,3608.85 +2026-09-04,Web,East,66,4832.52 +2026-09-04,Web,North,70,5554.5 +2026-09-04,Web,South,46,3279.8 +2026-09-04,Web,West,54,4763.34 +2026-09-05,Mobile app,East,73,5418.79 +2026-09-05,Mobile app,North,93,6013.38 +2026-09-05,Mobile app,South,69,3907.47 +2026-09-05,Mobile app,West,85,6031.6 +2026-09-05,Phone,East,42,1726.2 +2026-09-05,Phone,North,14,604.1 +2026-09-05,Phone,South,30,1746.6 +2026-09-05,Phone,West,38,1920.14 +2026-09-05,Store,East,39,4457.31 +2026-09-05,Store,North,27,3059.64 +2026-09-05,Store,South,59,5836.87 +2026-09-05,Store,West,51,5417.22 +2026-09-05,Web,East,74,5448.62 +2026-09-05,Web,North,70,5996.2 +2026-09-05,Web,South,78,6978.66 +2026-09-05,Web,West,70,5709.2 +2026-09-06,Mobile app,East,76,5447.68 +2026-09-06,Mobile app,North,88,5925.04 +2026-09-06,Mobile app,South,96,6958.08 +2026-09-06,Mobile app,West,80,5599.2 +2026-09-06,Phone,East,39,2287.35 +2026-09-06,Phone,North,27,1281.96 +2026-09-06,Phone,South,35,2013.55 +2026-09-06,Phone,West,43,1882.54 +2026-09-06,Store,East,38,3627.48 +2026-09-06,Store,North,50,5333.5 +2026-09-06,Store,South,42,4701.48 +2026-09-06,Store,West,42,4334.82 +2026-09-06,Web,East,77,5929.0 +2026-09-06,Web,North,49,3685.29 +2026-09-06,Web,South,81,5887.08 +2026-09-06,Web,West,65,4801.55 +2026-09-07,Mobile app,East,94,6196.48 +2026-09-07,Mobile app,North,74,4816.66 +2026-09-07,Mobile app,South,90,5544.0 +2026-09-07,Mobile app,West,82,4623.98 +2026-09-07,Phone,East,45,1926.45 +2026-09-07,Phone,North,17,915.96 +2026-09-07,Phone,South,33,1909.05 +2026-09-07,Phone,West,25,1378.5 +2026-09-07,Store,East,44,4763.44 +2026-09-07,Store,North,48,5143.2 +2026-09-07,Store,South,40,4189.6 +2026-09-07,Store,West,40,3981.2 +2026-09-07,Web,East,47,4055.16 +2026-09-07,Web,North,67,5360.67 +2026-09-07,Web,South,59,4259.8 +2026-09-07,Web,West,83,5892.17 +2026-09-08,Mobile app,East,91,6604.78 +2026-09-08,Mobile app,North,79,4871.93 +2026-09-08,Mobile app,South,95,5770.3 +2026-09-08,Mobile app,West,79,5142.11 +2026-09-08,Phone,East,40,1774.0 +2026-09-08,Phone,North,28,1399.44 +2026-09-08,Phone,South,20,1085.4 +2026-09-08,Phone,West,28,1209.6 +2026-09-08,Store,East,53,5707.04 +2026-09-08,Store,North,41,4316.89 +2026-09-08,Store,South,25,2580.0 +2026-09-08,Store,West,49,5187.63 +2026-09-08,Web,East,84,7511.28 +2026-09-08,Web,North,72,5986.8 +2026-09-08,Web,South,64,4893.44 +2026-09-08,Web,West,56,5036.08 +2026-09-09,Mobile app,East,69,4767.21 +2026-09-09,Mobile app,North,89,6023.52 +2026-09-09,Mobile app,South,97,7213.89 +2026-09-09,Mobile app,West,73,5391.78 +2026-09-09,Phone,East,30,1472.4 +2026-09-09,Phone,North,26,1308.58 +2026-09-09,Phone,South,34,1848.24 +2026-09-09,Phone,West,34,1996.14 +2026-09-09,Store,East,43,4254.85 +2026-09-09,Store,North,23,2323.46 +2026-09-09,Store,South,39,3759.21 +2026-09-09,Store,West,55,5753.0 +2026-09-09,Web,East,62,5238.38 +2026-09-09,Web,North,82,6930.64 +2026-09-09,Web,South,58,4538.5 +2026-09-09,Web,West,74,5942.2 +2026-09-10,Mobile app,East,76,5609.56 +2026-09-10,Mobile app,North,72,4371.84 +2026-09-10,Mobile app,South,80,4496.8 +2026-09-10,Mobile app,West,96,6099.84 +2026-09-10,Phone,East,47,2220.28 +2026-09-10,Phone,North,11,522.83 +2026-09-10,Phone,South,11,595.32 +2026-09-10,Phone,West,11,440.77 +2026-09-10,Store,East,22,2203.3 +2026-09-10,Store,North,26,2674.36 +2026-09-10,Store,South,42,4454.94 +2026-09-10,Store,West,34,3499.28 +2026-09-10,Web,East,77,5477.01 +2026-09-10,Web,North,49,3949.4 +2026-09-10,Web,South,57,3990.57 +2026-09-10,Web,West,81,6433.02 +2026-09-11,Mobile app,East,68,4282.64 +2026-09-11,Mobile app,North,64,3875.2 +2026-09-11,Mobile app,South,72,4880.16 +2026-09-11,Mobile app,West,72,4214.16 +2026-09-11,Phone,East,15,614.85 +2026-09-11,Phone,North,11,549.78 +2026-09-11,Phone,South,11,616.33 +2026-09-11,Phone,West,43,2160.32 +2026-09-11,Store,East,54,5685.12 +2026-09-11,Store,North,34,3677.78 +2026-09-11,Store,South,58,5697.92 +2026-09-11,Store,West,50,5717.5 +2026-09-11,Web,East,61,5454.62 +2026-09-11,Web,North,73,6291.87 +2026-09-11,Web,South,81,6530.22 +2026-09-11,Web,West,65,4909.45 +2026-09-12,Mobile app,East,67,4927.18 +2026-09-12,Mobile app,North,79,5074.17 +2026-09-12,Mobile app,South,63,3725.82 +2026-09-12,Mobile app,West,95,5423.55 +2026-09-12,Phone,East,40,2183.6 +2026-09-12,Phone,North,12,509.52 +2026-09-12,Phone,South,36,1544.76 +2026-09-12,Phone,West,36,1946.88 +2026-09-12,Store,East,37,3587.52 +2026-09-12,Store,North,49,5245.45 +2026-09-12,Store,South,33,3136.32 +2026-09-12,Store,West,57,5906.91 +2026-09-12,Web,East,68,4894.64 +2026-09-12,Web,North,80,6690.4 +2026-09-12,Web,South,48,4146.24 +2026-09-12,Web,West,64,4608.64 +2026-09-13,Mobile app,East,80,5264.0 +2026-09-13,Mobile app,North,60,3672.6 +2026-09-13,Mobile app,South,60,3516.0 +2026-09-13,Mobile app,West,84,6225.24 +2026-09-13,Phone,East,27,1422.63 +2026-09-13,Phone,North,31,1716.16 +2026-09-13,Phone,South,31,1266.35 +2026-09-13,Phone,West,23,1112.74 +2026-09-13,Store,East,26,2620.28 +2026-09-13,Store,North,30,3148.5 +2026-09-13,Store,South,38,4030.28 +2026-09-13,Store,West,30,3279.9 +2026-09-13,Web,East,73,5390.32 +2026-09-13,Web,North,45,3339.45 +2026-09-13,Web,South,53,4159.44 +2026-09-13,Web,West,61,4942.83 +2026-09-14,Mobile app,East,94,6103.42 +2026-09-14,Mobile app,North,82,4736.32 +2026-09-14,Mobile app,South,82,5849.06 +2026-09-14,Mobile app,West,90,5265.0 +2026-09-14,Phone,East,37,1845.56 +2026-09-14,Phone,North,49,2920.89 +2026-09-14,Phone,South,41,2195.96 +2026-09-14,Phone,West,25,1079.75 +2026-09-14,Store,East,20,2027.0 +2026-09-14,Store,North,56,5881.12 +2026-09-14,Store,South,24,2536.08 +2026-09-14,Store,West,48,5481.6 +2026-09-14,Web,East,47,4151.51 +2026-09-14,Web,North,67,5384.12 +2026-09-14,Web,South,83,7172.03 +2026-09-14,Web,West,67,5422.98 diff --git a/docs/schema-and-history.md b/docs/schema-and-history.md index aaef21c..e759342 100644 --- a/docs/schema-and-history.md +++ b/docs/schema-and-history.md @@ -2,17 +2,22 @@ **[中文](zh/schema-and-history.md)** · [Docs](index.md) -The sidebar has two tabs: **表结构** (schema) and **查询历史** (history). +The sidebar has two tabs: **Schema** and **History** (**表结构** and **查询历史** in Chinese). Beside them, the collapse button (or **⌘B**) hides the sidebar; the button at the left of the title bar, or ⌘B again, brings it back. Whether it is hidden is remembered. ## The schema tree The tree shows, from the top: -- **Local files** — the data files you have registered, each with its view - name, file name, and row count. Its children are the view's columns. +- **Local files** — the data files you have registered, each named by its + view, with its row count. The file name appears beside the view name only + when the two differ (`sales_2` from `sales.csv`), and the folder when two + files share a view name; hover a row for the full path. Its children are the + view's columns. - **Apps** / **Dashboards** — the analysis apps and `.dash` dashboards you - have opened recently. Click one to reopen it as a tab, the way you would a - file; an entry whose path no longer exists is dropped from the list. + have opened recently, sorted by name so a row stays put when you click it. + Click one to reopen it as a tab, the way you would a file; the × on hover + takes it off the list, leaving its files and any open tab alone. An entry + whose path no longer exists is dropped from the list. - **S3** — present once [S3 is configured](s3.md). Loads lazily. - Each database, then its schemas, then tables and views, then columns. diff --git a/docs/settings-and-data.md b/docs/settings-and-data.md index 544d6de..9cbbbf1 100644 --- a/docs/settings-and-data.md +++ b/docs/settings-and-data.md @@ -13,7 +13,9 @@ duration of a session. | Linux (if built there) | `~/.local/share/ducklocal/history.duckdb` | That single DuckDB file holds three tables: the query history, the registered -data files, and a small key/value settings table. It lives beside a `.wal` +data files, and a small key/value settings table — the interface language, the +open app and dashboard tabs, the recent-documents list, and the app folders +you have trusted. It lives beside a `.wal` while the app is running. Your data is never copied into it. Registered files are referenced by path and @@ -26,10 +28,15 @@ re-attached on launch. | Registered data files — path, view name, kind | app data file | | Query history, including failures | app data file | | Interface language | app data file | +| Interface size | app data file | +| Whether the sidebar is hidden | app data file | +| Open app and dashboard tabs, with their titles | app data file | +| Recently opened apps and dashboards (the sidebar's **Apps** / **Dashboards**) | app data file | +| App folders you chose to trust | app data file | | Not remembered | Notes | | --- | --- | -| Open tabs and their SQL | in memory only | +| Open query tabs and their SQL | in memory only — past queries are in **History** | | The results panel contents | in memory only | | Which database file is open | each launch starts in memory unless you name one | | Window size and position | fixed at 1440×900, minimum 960×600 | @@ -58,12 +65,31 @@ English, whatever the interface language is. ## Themes The sun/moon button toggles between the light and dark theme. There is no -theme picker and no theme file to edit — the two modes are what you get. The -base font size is 14px. +theme picker and no theme file to edit — the two modes are what you get. + +## Interface size {#interface-size} + +Text, icons and controls scale together, in four steps: + +| Size | Base text | Editor text | +| --- | --- | --- | +| Small | 13px | 12px | +| Default | 14px | 13px | +| Large | 16px | 15px | +| Extra large | 18px | 17px | + +Choose one from the **Aa** button in the title bar, or step through them with +**⌘+** (larger), **⌘−** (smaller) and **⌘0** (back to default) — these work +wherever the focus is, the SQL editor included. The choice is remembered, and +survives switching between light and dark. + +Panel sizes you dragged, and the height of a dashboard's plots, stay in pixels: +they are yours to adjust, not the interface's. ## Resetting -To start over — clearing history, registered files, and the language setting — +To start over — clearing history, registered files, remembered tabs, trusted +app folders and the language setting — quit DuckLocal and remove its app data directory: ```bash @@ -75,17 +101,4 @@ this**, only DuckLocal's own records. ## Troubleshooting -| Symptom | Cause | -| --- | --- | -| `File not found` on a path you named | The path does not exist. DuckLocal cannot create a database by naming a new file | -| A folder reports no data files | Nothing under it matched `.csv`, `.tsv`, `.txt`, `.parquet`, `.json`, `.ndjson`, `.jsonl`, `.xlsx`, `.xls`, `.xlsb`, or `.ods` | -| A folder produced fewer files than expected | Hidden entries and symlinks are skipped, and one open request attaches at most 256 files | -| A path opened as a database unexpectedly | Any existing non-data file is treated as a DuckDB database; if it will not open, DuckLocal falls back to memory and shows the error | -| Run does nothing | A query is already running. There is no cancel and no timeout | -| Results stop early with a truncation notice | The 100,000-row or 2,000,000-cell cap was reached | -| S3 credentials forgotten | Opening or switching a database clears them | -| Timestamps look shifted | The grid renders timestamps in UTC, not local time | -| The theme resets to light | It is not persisted | - -If a query seems stuck, the status bar shows the DuckDB version and connection -state. Quitting and relaunching is the only way to abandon a running query. +Symptoms, causes and fixes are collected on the [Troubleshooting](troubleshooting.md) page. diff --git a/docs/sql-editor.md b/docs/sql-editor.md index 51282ce..aaf5e23 100644 --- a/docs/sql-editor.md +++ b/docs/sql-editor.md @@ -69,13 +69,16 @@ function will appear. ## Keyboard shortcuts -⌘↵ is the only shortcut DuckLocal itself defines. Everything else comes from +DuckLocal defines a handful of shortcuts itself; everything else comes from the editor and table components, and behaves as it does in any macOS text field: | Shortcut | Action | | --- | --- | | ⌘↵ | Run the query | +| ⌘S | Save a dashboard's source, in its source view | +| ⌘+ / ⌘− / ⌘0 | Make the interface larger / smaller / default size — see [Interface size](settings-and-data.md#interface-size) | +| ⌘B | Hide or show the sidebar | | ⌘F / ⇧⌘F | Find / replace in the editor | | ⌘Z / ⇧⌘Z | Undo / redo | | ⌘A | Select all | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..5107b7e --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,78 @@ +# Troubleshooting + +**[中文](zh/troubleshooting.md)** · [Docs](index.md) + +Find the symptom, read the cause, apply the fix. If yours is not here, +[open an issue](https://github.com/JetSquirrel/DuckLocal/issues) with what you +did and what you saw. + +## Installing and launching + +| Symptom | Cause and fix | +| --- | --- | +| The app will not open on an Intel Mac | Releases are built for Apple silicon only. [Build from source](getting-started.md#install) on Intel | +| `ducklocal: command not found` | The disk image installs the app, not a command. [Link the binary onto your PATH](getting-started.md#add-command) | +| The terminal is busy while the window is open | Opening the GUI from a terminal runs it in the foreground. Launch from Applications instead, or leave that terminal to it | +| History, settings or remembered files stop being saved | A second DuckLocal is running and holds the app-data file. Quit the other one and relaunch | + +## Opening data + +| Symptom | Cause and fix | +| --- | --- | +| `File not found` on a path you named | The path does not exist, or is relative to a different directory than you think. Use an absolute path to be sure | +| A folder reports no data files | Nothing under it matched `.csv`, `.tsv`, `.txt`, `.parquet`, `.json`, `.ndjson`, `.jsonl`, `.xlsx`, `.xls`, `.xlsb` or `.ods` | +| A folder produced fewer files than expected | Hidden entries and symlinks are skipped, and one open request attaches at most 256 files. Open subfolders separately | +| A path opened as a database unexpectedly | Any existing file that is not a data file is treated as a DuckDB database. If it will not open, DuckLocal falls back to memory and shows the error | +| **Not attached …: the database already has a table or view named …** | The open database has its own relation with the name a remembered file uses. DuckLocal never replaces it. Rename or drop one of the two, or remove the file from **Local files** and open it again to get a new name | +| **Cannot restore relative path …** | A file was remembered by a relative path, which cannot be resolved in a new session. Remove it from **Local files** and open it again | +| A view shows `sales_2` instead of `sales` | Another file or table already had that name, so the new one got a suffix | +| A file's view is stale | Views read the file on every query. If a CSV's columns changed shape, use **Refresh schema** in the sidebar | + +## Running queries + +| Symptom | Cause and fix | +| --- | --- | +| **Run** does nothing | A query is already running. There is no cancel and no timeout; quitting and relaunching is the only way to abandon one | +| **⌘↵** shows the editor instead of running | On the first-run screen, ⌘↵ means "give me an editor". Press it again to run | +| Results stop early with a truncation notice | The 100,000-row or 2,000,000-cell cap was reached. Narrow the query; there is no pagination | +| Timestamps look shifted | The grid renders timestamps in UTC, not local time | +| Clicking a header does not sort | There is no client-side sorting. Use `ORDER BY` | +| Clicking a table or history entry wiped my SQL | Those clicks replace the whole editor buffer. Use a new query tab for scratch work | +| An export silently replaced a file | Export writes to the path you give without asking. Choose a new name | + +## S3 + +| Symptom | Cause and fix | +| --- | --- | +| S3 credentials were forgotten | They are session-only by design, and opening or switching a database clears them. Configure S3 again | +| Configuring S3 fails offline | The first use runs `INSTALL httpfs`, which downloads the extension. Connect once, then it is cached | +| Temporary (STS) credentials are rejected | No session token is sent. Use long-lived keys — see [S3 and httpfs](s3.md#limitations) | +| A bucket looks smaller than it is | Listing stops at 1,000 entries per level. Query with a glob such as `'s3://bucket/prefix/*.parquet'` instead | + +## Apps and dashboards + +| Symptom | Cause and fix | +| --- | --- | +| An app tab asks **Run this app?** | The first time a folder opens as an app, DuckLocal asks before running its code, because an app's SQL can read and write anything the SQL editor can. **View source** first if you did not write it; **Trust and run** is remembered for that folder | +| A dashboard plot says a query must be read-only | A `.dash` query tried to write (DDL, DML, `COPY`, `ATTACH`, …). Dashboards only run read-only statements; move the setup into the SQL editor | +| A `PIVOT` in a dashboard is rejected | Without an explicit `IN (…)` list, DuckDB splits `PIVOT` into more than one statement. List the values: `PIVOT t ON channel IN ('Web', 'Store') USING sum(revenue)` | + +## Appearance + +| Symptom | Cause and fix | +| --- | --- | +| The theme resets to light on every launch | The theme is not persisted; the language is | +| Text or icons are too small or too large | Use the **Aa** button in the title bar, or ⌘+ / ⌘− / ⌘0. See [Interface size](settings-and-data.md#interface-size) | +| The interface is in the wrong language | Use the `EN` / `中` button in the title bar; the choice is remembered | + +## Starting over + +To clear history, remembered files, open tabs and settings, quit DuckLocal and +remove its app-data directory: + +```bash +rm -rf ~/Library/Application\ Support/DuckLocal +``` + +Your data files are never touched — only DuckLocal's own records. See +[Settings and app data](settings-and-data.md) for what lives there. diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..a589852 --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,133 @@ +# Your first 10 minutes + +**[中文](zh/tutorial.md)** · [Docs](index.md) + +A guided tour: open a small sales file, ask it a few questions, chart the +answers, and export one. It assumes DuckLocal is installed — if not, start +with [Getting started](getting-started.md). + +## 1. Get the sample file + +Download [sales.csv](/samples/sales.csv) — two weeks of made-up orders, 224 +rows. Or from a terminal: + +```bash +curl -LO https://jetsquirrel.github.io/DuckLocal/samples/sales.csv +``` + +It has five columns: + +| Column | Type | Example | +| --- | --- | --- | +| `date` | DATE | `2026-09-01` | +| `channel` | VARCHAR | `Web`, `Mobile app`, `Store`, `Phone` | +| `region` | VARCHAR | `North`, `South`, `East`, `West` | +| `orders` | BIGINT | `72` | +| `revenue` | DOUBLE | `4181.04` | + +You did not have to tell DuckLocal any of that: DuckDB reads the header and +infers the types on its own. + +## 2. Open it + +Drag `sales.csv` onto the DuckLocal window. (Or click **Open files…**, or run +`ducklocal sales.csv` if you [added the command](getting-started.md#add-command).) + +A notification confirms **Created view sales.** In the sidebar, **Local +files** now lists `sales` with its 224 rows; expand it to see the columns and +their types. + +## 3. Look at the rows + +In the sidebar, click the play button beside `sales`. The editor fills with a +`SELECT * … LIMIT 100` for it — press **⌘↵** to run it. + +The grid under the editor shows the rows. Try the filter box above it: type +`Store` and press Enter to keep only the rows that mention it. The filter +works on the rows already loaded and never re-queries. + +## 4. Ask a question + +Replace the SQL with this and press **⌘↵**: + +```sql +SELECT channel, round(sum(revenue), 2) AS revenue +FROM sales +GROUP BY channel +ORDER BY revenue DESC; +``` + +| channel | revenue | +| --- | --- | +| Mobile app | 290549.76 | +| Web | 289544.8 | +| Store | 240067.6 | +| Phone | 82511.2 | + +Now switch the results panel from **Results** to **Chart**. Because the first +column is text, you get a bar per channel, sized by the first numeric column. + +::: tip Autocompletion +While you type, the editor offers table and column names from the sidebar +first, then DuckDB functions and SQL keywords — type `rev` and `revenue` is +the first suggestion. +::: + +## 5. Chart a trend + +Charts are drawn from the rows exactly as the query returns them, so shape +the result the way you want it plotted. One column per channel gives one line +per channel: + +```sql +SELECT + date, + round(sum(revenue) FILTER (WHERE channel = 'Web'), 2) AS web, + round(sum(revenue) FILTER (WHERE channel = 'Mobile app'), 2) AS mobile, + round(sum(revenue) FILTER (WHERE channel = 'Store'), 2) AS store +FROM sales +GROUP BY date +ORDER BY date; +``` + +With a date in the first column, **Chart** draws an area chart over time, +one series per numeric column. [Results and charts](results-and-charts.md) +covers the rules and limits. + +## 6. Export the answer + +With the trend still in the results panel, click **Export CSV**. The dialog +suggests `~/Desktop/export.csv`; change the path if you like and confirm. + +Export re-runs the query, so the file holds the full result — not just what +is visible in the grid. **It overwrites an existing file without asking.** + +## 7. Find it again tomorrow + +Open the **History** tab in the sidebar: every query you ran is there, the +failed ones included. Click one to put it back in the editor — note that this +replaces what the editor holds. + +Quit DuckLocal and launch it again. `sales` is still under **Local files**: +opened files are remembered by path and re-read on every launch, so an edit +to the CSV shows up the next time you query it. + +## 8. Bonus: the same question from a terminal + +If you added the `ducklocal` command, the CLI answers without opening a +window — handy for scripts and for AI agents: + +```bash +ducklocal query --format md --sql " + SELECT channel, round(sum(revenue), 2) AS revenue + FROM 'sales.csv' GROUP BY channel ORDER BY revenue DESC" +``` + +Leave out `--format md` for structured JSON. The [CLI guide](cli.md) and the +official agent skill are the place to start. + +## Next + +- Point DuckLocal at your own files or folders — see [Data sources](data-sources.md) +- Query files in S3-compatible storage — see [S3 and httpfs](s3.md) +- Build a dashboard of saved queries — see [Analysis apps and dashboards](analysis-app.md#dashboard-specs-dash) diff --git a/docs/zh/analysis-app.md b/docs/zh/analysis-app.md index 271ab09..f44c959 100644 --- a/docs/zh/analysis-app.md +++ b/docs/zh/analysis-app.md @@ -220,13 +220,13 @@ npx --yes -p typescript tsc -p <应用目录>/jsconfig.json --noImplicitAny fals 因为它是加载状态,只在点击时才执行的语句不在报告里,重新加载之后的内容也不在。应用的 JavaScript 权限始终如一:导出就是运行应用,和把它开在标签页里完全一样。 -## Dashboard 规格文件(.dash) +## Dashboard 规格文件(.dash) {#dashboard-specs-dash} `.dash` 文件把 dashboard 声明为数据——query 与 plot block,没有 JavaScript——适合「已保存查询 + 标准图表」的常见场景。文件格式与 `ducklocal check` 校验见 [CLI 指南](cli.md#校验-dashboard-规格文件)。 -打开方式与应用相同:在命令行指定(`ducklocal dashboard.dash`),或把文件拖到窗口上,就会以 dashboard 标签页的形式打开,与查询、应用并列。标签页在窗口自己的连接上执行规格里的查询——因此能看到 `TEMP` 表等连接级状态,也会与编辑器的查询排队——并把每个 plot 画成竖直堆叠中的一格,分隔条可拖动调整高度。只有只读语句会被执行:可能写入的查询(DDL、DML、`COPY`、`ATTACH` 等)在到达连接之前就被拒绝,因此打开别人发来的 `.dash` 文件不会改动你的数据。某个 plot 的查询失败时,原因显示在它自己的格子里,其余部分照常绘制。工具栏的重新加载会重读文件并重跑所有查询;重载后的规格若不再通过校验,不会替换掉仍在工作的 dashboard,而是在上方显示原因。已打开的 dashboard 会像应用一样被记住,下次启动自动恢复。 +打开方式与应用相同:在标签栏的 `+` 菜单里选 **打开 Dashboard…**,在命令行指定(`ducklocal dashboard.dash`),或把文件拖到窗口上,就会以 dashboard 标签页的形式打开,与查询、应用并列。标签页在窗口自己的连接上执行规格里的查询——因此能看到 `TEMP` 表等连接级状态,也会与编辑器的查询排队——并把每个 plot 画成竖直堆叠中的一格,分隔条可拖动调整高度。只有只读语句会被执行:可能写入的查询(DDL、DML、`COPY`、`ATTACH` 等)在到达连接之前就被拒绝,因此打开别人发来的 `.dash` 文件不会改动你的数据。某个 plot 的查询失败时,原因显示在它自己的格子里,其余部分照常绘制。工具栏的重新加载会重读文件并重跑所有查询;重载后的规格若不再通过校验,不会替换掉仍在工作的 dashboard,而是在上方显示原因。已打开的 dashboard 会像应用一样被记住,下次启动自动恢复。 -标签页同时也是编辑器:源码视图可以直接修改文件,带补全和诊断,用保存按钮或 ⌘S 写回——保存后的规格若不再通过校验,仍在工作的 dashboard 不会被替换,并会显示原因。在 GUI 之外,`ducklocal lsp` 把同样的补全、诊断、悬停和跳转定义提供给任何支持 LSP 的编辑器(见 [CLI 指南](cli.md#用-lsp-编辑-dashboard-规格文件))。 +标签页同时也是编辑器:源码视图可以直接修改文件,带语法高亮(heredoc 里的 SQL 与 SQL 编辑器同样着色)、补全和诊断,用保存按钮或 ⌘S 写回——保存后的规格若不再通过校验,仍在工作的 dashboard 不会被替换,并会显示原因。在 GUI 之外,`ducklocal lsp` 把同样的补全、诊断、悬停和跳转定义提供给任何支持 LSP 的编辑器(见 [CLI 指南](cli.md#用-lsp-编辑-dashboard-规格文件))。 ## 已知限制 diff --git a/docs/zh/getting-started.md b/docs/zh/getting-started.md index 32c8d7c..a55c25e 100644 --- a/docs/zh/getting-started.md +++ b/docs/zh/getting-started.md @@ -2,17 +2,20 @@ **[English](../getting-started.md)** · [文档](index.md) -## 环境要求 +DuckLocal 是一个桌面应用,让你直接用 SQL 查询本机上的数据文件。不需要启动服务器、不需要配置连接,也不需要注册账号——指向文件,就能开始查询。本页带你从下载走到第一条查询结果,大约五分钟。 -发布的构建版运行在 **macOS 12.0 及以上,仅支持 Apple silicon**。分发的磁盘映像已签名并公证,因此无需绕过 Gatekeeper 就能打开。 +## 1. 安装 {#install} -从源码构建需要 Rust stable,**1.85.1 或更新版本**,以及 C++ 工具链——DuckDB 由内置源码编译而来。首次构建要花一段时间,并占用几 GB 磁盘空间。 +需要 **macOS 12 及以上、Apple silicon 芯片**(M1 或更新)。 -## 安装 +1. 从最新版本下载 [`ducklocal-macos-arm64.dmg`](https://github.com/JetSquirrel/DuckLocal/releases/latest)。 +2. 打开磁盘映像,把 **DuckLocal** 拖进“应用程序”。 +3. 从“应用程序”或聚焦搜索启动 DuckLocal。 -**从磁盘映像安装。** 下载 `ducklocal-macos-arm64.dmg`,打开它,把 `DuckLocal.app` 拖到“应用程序”。 +磁盘映像已经过 Apple 签名和公证,打开时不需要任何绕过 Gatekeeper 的操作。 -**从源码安装。** +::: details 改为从源码构建 +需要 Rust stable 1.85.1 或更新版本,以及 C++ 工具链(Xcode Command Line Tools)。DuckDB 由内置源码编译,首次构建需要几分钟,并占用几 GB 磁盘空间。 ```bash git clone https://github.com/JetSquirrel/DuckLocal @@ -21,43 +24,60 @@ cargo build --release ./target/release/ducklocal ``` -`cargo run` 也可以,迭代更快。因为有 DuckDB,首次构建很慢;之后只有 DuckLocal 自身会重新编译。 +可执行文件是 `./target/release/ducklocal`,本指南中写 `ducklocal` 的地方都可以用它代替。打包 `.app` 的方法见[开发指南](development.md)。 +::: -## 打开数据 +## 2. 添加 `ducklocal` 命令(可选) {#add-command} -DuckLocal 会打开你在命令行里指定的任何内容: +应用里的所有功能都不需要终端。如果你还想从命令行打开数据,或者让 AI agent 通过 [CLI](cli.md) 执行查询,就把应用内的可执行文件链接到 `PATH` 上: ```bash -ducklocal ./logs/ # 递归打开文件夹下的所有数据文件 -ducklocal ./billing.parquet # 单个文件 -ducklocal './data/*.csv' # 通配符(加引号,避免被 shell 提前展开) -ducklocal warehouse.duckdb # 或已有的 DuckDB 数据库 +sudo mkdir -p /usr/local/bin +sudo ln -sf /Applications/DuckLocal.app/Contents/MacOS/ducklocal /usr/local/bin/ducklocal +ducklocal --version ``` -你可以一次指定多个路径,并且自由混合。 +这个链接指向应用本身,所以在“应用程序”里更新 DuckLocal 后,命令也随之更新。要删除它:`sudo rm /usr/local/bin/ducklocal`。 -还有两种入口,二者等价: +## 3. 打开数据 -- 标题栏中的 **打开数据…** 会打开一个对话框,你可以在其中输入或浏览路径。该对话框里的 **内存模式** 按钮会切换到一个干净的内存工作区,不挂载任何内容。 -- **把文件或文件夹拖到窗口上。** +首次启动会显示一个标题为 **把数据拖进来** 的界面。以下任意一种方式都能导入数据: -每个 CSV、TSV、Parquet、JSON 或 Excel 文件都会成为一个可查询的关系,每个文件都会被登记——下次启动时就是同一个工作区。详见[数据源](data-sources.md)。 +- 把 CSV、TSV、Parquet、JSON 或 Excel 文件——或整个文件夹——**拖到窗口上**。 +- 点击 **打开文件…** 或 **打开文件夹…**。 +- 在终端里写出路径: -## 运行查询 + ```bash + ducklocal ./sales.csv # 单个文件 + ducklocal ./logs/ # 递归打开文件夹下的所有数据文件 + ducklocal './data/*.parquet' # 通配符——加引号,避免被 shell 提前展开 + ducklocal warehouse.duckdb # 已有的 DuckDB 数据库 + ``` -工作区打开时带有一个查询 Tab。输入 SQL 并按 **⌘↵**(Cmd+Enter)运行。**运行**、**格式化** 和 **EXPLAIN** 位于编辑器上方的工具栏中。 +每个文件都会成为一个以文件名命名的视图——`sales.csv` 变成 `sales`——并连同列和行数一起出现在侧边栏的 **本地文件** 下。DuckLocal 在原位置读取文件,不复制,也不上传。 -结果出现在下方的面板里,以表格或图表的形式呈现。 +打开过的文件会被记住,下次启动时就是同一个工作区。格式、文件夹、通配符和数据库的细节见[数据源](data-sources.md)。 -## 首次运行界面 +::: tip 手头没有数据? +[10 分钟上手教程](tutorial.md)会用一个小样例文件一步步带你走一遍。 +::: -当没有任何内容被挂载、也没有任何内容被登记时,工作区会显示三个按钮而不是编辑器:**打开文件…**、**打开文件夹…** 和 **新建查询**。选择前两个之一来挂载数据,或者选 **新建查询** 直接得到一个编辑器。 +## 4. 运行查询 -有一个小特性值得知道:在这个界面上,**⌘↵** 会显示编辑器,而不是运行任何东西。 +点击 **新建查询**(或者直接打开数据——编辑器会自动出现)。输入 SQL,按 **⌘↵**(Cmd+Enter): + +```sql +SELECT * FROM sales LIMIT 20; +``` + +结果出现在编辑器下方的面板里。切换到 **图表** 可以快速看个大概,也可以导出为 CSV 或 Parquet。编辑器上方的工具栏有 **运行**、**格式化** 和 **EXPLAIN**。 + +一个值得知道的捷径:在侧边栏里,点击表旁边的播放按钮会生成现成的 `SELECT * … LIMIT 100`,点击列名则只查询这一列。 ## 接下来 +- [10 分钟上手教程](tutorial.md) —— 用样例数据走一遍 - [数据源](data-sources.md) —— 格式、文件夹、通配符、数据库 -- [SQL 编辑器](sql-editor.md) —— Tab、快捷键、EXPLAIN -- [结果与图表](results-and-charts.md) —— 你可以对结果做什么 -- [设置与应用数据](settings-and-data.md) —— 所有内容的存放位置 +- [SQL 编辑器](sql-editor.md) —— Tab、快捷键、自动补全、EXPLAIN +- [结果与图表](results-and-charts.md) —— 过滤、导出、图表 +- [常见问题排查](troubleshooting.md) —— 遇到不对劲的地方时 diff --git a/docs/zh/index.md b/docs/zh/index.md index a4fa558..854f427 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -2,8 +2,8 @@ layout: home hero: name: DuckLocal - text: 你的数据,你的工作台。 - tagline: 本地优先的数据查询与分析工作台,原生基于 DuckDB。 + text: 在 Mac 上直接用 SQL 查你的文件。 + tagline: 拖入 CSV、Parquet、JSON 或 Excel,用 DuckDB 查询。不用服务器,不用配置,不用上传。 image: src: /assets/logo.png alt: DuckLocal @@ -11,60 +11,69 @@ hero: - theme: brand text: 快速上手 link: /zh/getting-started + - theme: alt + text: 10 分钟上手教程 + link: /zh/tutorial - theme: alt text: 下载 macOS 版 link: https://github.com/JetSquirrel/DuckLocal/releases/latest - - theme: alt - text: GitHub - link: https://github.com/JetSquirrel/DuckLocal features: - - title: 查询本地文件 - details: 将 CSV、Parquet、JSON、Excel 或整个文件夹直接打开。不用配置连接,也不上传数据。 + - title: 文件在哪,就在哪查 + details: 单个文件、文件夹、通配符或 .duckdb 数据库,一打开就能查询——下次启动还在。 link: /zh/data-sources - linkText: 探索数据源 + linkText: 数据源 + - title: 数据不离开你的电脑 + details: 没有账号、没有遥测、没有云端。文件原地读取,从不复制或上传。 + link: /zh/settings-and-data + linkText: 存了什么、存在哪里 - title: 专注的 SQL 工作区 - details: 语法高亮、自动补全、格式化和多个查询 Tab,让你专注于 SQL。 + details: 多个查询 Tab,基于你自己表结构的自动补全,一键格式化与 EXPLAIN。 link: /zh/sql-editor - linkText: 了解编辑器 - - title: 探索查询结果 - details: 筛选结果表格、复制单元格、导出 CSV 或 Parquet,并用内置图表探索数据。 + linkText: SQL 编辑器 + - title: 从数字到图表 + details: 过滤结果表格、把结果画成图,并导出为 CSV 或 Parquet。 link: /zh/results-and-charts linkText: 结果与图表 + - title: 为 AI agent 而生 + details: 无界面 CLI 与严格的 JSON 契约,外加一个官方 agent skill,按先看 schema 再分析的方式工作。 + link: /zh/cli + linkText: CLI 与 skill + - title: Dashboard 即文件 + details: 在 .dash 文件里声明查询与图表,或用脚本写一个定制视图,再导出成一个 HTML 页面分享出去。 + link: /zh/analysis-app + linkText: 分析应用与 Dashboard --- -# DuckLocal +

English · macOS 12+,Apple silicon · 开源免费(Apache-2.0)

+ +## 三步上手 -**[English](../index.md)** · [项目 README](https://github.com/JetSquirrel/DuckLocal/blob/main/README.zh-CN.md) +1. **[下载](https://github.com/JetSquirrel/DuckLocal/releases/latest)**磁盘映像,把 DuckLocal 拖进“应用程序”。 +2. 把一个数据文件——或整个文件夹——**拖到窗口上**。每个文件都会成为一个以文件名命名的视图。 +3. 对一条查询按 **⌘↵**: -本地优先的数据查询与分析工作台,原生基于 DuckDB。 -直接指向你的文件即可——不用配置连接、不用建 schema,也不会上传任何数据。 +```sql +SELECT channel, sum(revenue) AS revenue +FROM sales +GROUP BY channel +ORDER BY revenue DESC; +``` -要求 macOS 12 及以上、Apple 芯片。开源免费,Apache-2.0。 -[下载 macOS 版](https://github.com/JetSquirrel/DuckLocal/releases/latest) · -[在 GitHub 上查看](https://github.com/JetSquirrel/DuckLocal) +第一次用?[快速上手](getting-started.md)讲安装和首次启动;[10 分钟上手教程](tutorial.md)用一个样例文件带你走一遍。 ![DuckLocal 工作原理:CSV、Parquet、DuckDB 文件与 S3 兼容对象存储汇入同一个本地工作区](../assets/intro.jpg) -## 指南 +## 按需查找 -| 指南 | 内容 | +| 我想… | 请看 | | --- | --- | -| [快速上手](getting-started.md) | 安装 DuckLocal、打开你的第一批文件、运行第一条查询 | -| [数据源](data-sources.md) | 支持哪些文件格式,文件夹与通配符如何解析,数据库如何打开,视图命名,以及各项限制 | -| [S3 与 httpfs](s3.md) | 让 DuckLocal 指向 S3 兼容的存储桶,浏览其中的内容并查询对象 | -| [SQL 编辑器](sql-editor.md) | 查询 Tab、运行与格式化 SQL、EXPLAIN,以及自动补全 | -| [Schema 浏览与历史](schema-and-history.md) | 浏览表与列、生成 SELECT 语句、修改列的数据类型,以及复用历史查询 | -| [结果与图表](results-and-charts.md) | 结果表格、筛选、复制、导出,以及内置图表 | -| [设置与应用数据](settings-and-data.md) | DuckLocal 把文件保存在哪里,重启后哪些内容仍在,主题、语言,以及疑难排查 | -| [AI CLI 与官方 skill](cli.md) | 无界面运行 SQL、了解 JSON 契约、安装官方 agent skill | -| [分析应用](analysis-app.md) | 打开一个由你编写的 JavaScript 应用窗口,使用同一个连接 | -| [开发](development.md) | 构建、测试、打包 `.app`,以及发布已签名并公证的版本 | - -## 一览 - -- 本地 CSV / TSV / Parquet / JSON / Excel 文件或整个文件夹——入口可以是 - 命令行、文件对话框,或把文件拖放到窗口 -- SQL 编辑器:语法高亮、自动补全、格式化,支持多个查询 Tab -- Schema 侧栏:浏览数据库、schema、表与列 -- 结果表格:支持筛选、单元格复制、CSV 与 Parquet 导出,以及图表 -- 查询历史、可选的 S3 支持、明暗主题、English 与 简体中文 +| 安装 DuckLocal 并跑第一条查询 | [快速上手](getting-started.md) | +| 用样例数据边做边学 | [10 分钟上手教程](tutorial.md) | +| 打开文件夹、通配符、Excel 或 `.duckdb` 文件 | [数据源](data-sources.md) | +| 查询 S3 或兼容存储里的对象 | [S3 与 httpfs](s3.md) | +| 用好编辑器 | [SQL 编辑器](sql-editor.md) · [Schema 浏览与历史](schema-and-history.md) | +| 把结果画成图或导出 | [结果与图表](results-and-charts.md) | +| 让 AI agent 查询我的数据 | [CLI 与 agent skill](cli.md) | +| 搭一个 dashboard | [分析应用与 Dashboard](analysis-app.md) | +| 解决某个不正常的问题 | [常见问题排查](troubleshooting.md) | +| 从源码构建或参与开发 | [开发指南](development.md) | diff --git a/docs/zh/s3.md b/docs/zh/s3.md index 1a67e56..262309e 100644 --- a/docs/zh/s3.md +++ b/docs/zh/s3.md @@ -61,7 +61,7 @@ LIMIT 100; 无法识别为数据文件的键名不可点击。 -## 限制 +## 限制 {#limitations} - **浏览是只读的。** 浏览器没有上传、下载到文件、删除、复制 或创建存储桶的操作。查询仍会下载远端数据。 diff --git a/docs/zh/schema-and-history.md b/docs/zh/schema-and-history.md index 793adde..c11172d 100644 --- a/docs/zh/schema-and-history.md +++ b/docs/zh/schema-and-history.md @@ -2,14 +2,14 @@ **[English](../schema-and-history.md)** · [文档](index.md) -侧栏有两个 Tab:**表结构**(schema)和**查询历史**(history)。 +侧栏有两个 Tab:**表结构** 和 **查询历史**(英文界面中为 **Schema** 和 **History**)。旁边的收起按钮(或 **⌘B**)可以收起侧栏;点标题栏最左侧的按钮,或再按一次 ⌘B,即可展开。收起状态会被记住。 ## Schema 树 树从上到下依次显示: -- **本地文件**——你已注册的数据文件,每个都带有视图名、文件名和行数。它的子节点是该视图的列。 -- **应用** / **仪表盘**——你最近打开过的 analysis app 和 `.dash` 仪表盘。点击即可像打开文件一样重新打开对应的 Tab;路径已不存在的条目会从列表中移除。 +- **本地文件**——你已注册的数据文件,以视图名显示,并带有行数。只有文件名与视图名不同(如 `sales.csv` 对应 `sales_2`)时才在旁边显示文件名,两个文件视图名相同时显示所在文件夹;鼠标悬停可看到完整路径。它的子节点是该视图的列。 +- **应用** / **仪表盘**——你最近打开过的 analysis app 和 `.dash` 仪表盘,按名称排序,点击后位置不会变。点击即可像打开文件一样重新打开对应的 Tab;悬停时的 × 会把它从列表移除,文件和已打开的 Tab 都不受影响;路径已不存在的条目会从列表中移除。 - **S3**——在[配置好 S3](s3.md) 之后出现。延迟加载。 - 每个数据库,然后是它的 schema,接着是表和视图,最后是列。 diff --git a/docs/zh/settings-and-data.md b/docs/zh/settings-and-data.md index 341e97f..084dc90 100644 --- a/docs/zh/settings-and-data.md +++ b/docs/zh/settings-and-data.md @@ -11,7 +11,7 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 | macOS | `~/Library/Application Support/DuckLocal/history.duckdb` | | Linux(如果在该平台构建) | `~/.local/share/ducklocal/history.duckdb` | -这单个 DuckDB 文件包含三张表:查询历史、已注册的数据文件,以及一张小型的键值设置表。应用运行期间,它旁边会有一个 `.wal` 文件。 +这单个 DuckDB 文件包含三张表:查询历史、已注册的数据文件,以及一张小型的键值设置表——界面语言、打开的应用与 dashboard 标签页、最近文档列表,以及你信任过的应用文件夹。应用运行期间,它旁边会有一个 `.wal` 文件。 你的数据永远不会被复制进去。已注册的文件只按路径引用,并在启动时重新挂载。 @@ -22,10 +22,15 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 | 已注册的数据文件 —— 路径、视图名、类型 | 应用数据文件 | | 查询历史,包括失败记录 | 应用数据文件 | | 界面语言 | 应用数据文件 | +| 界面大小 | 应用数据文件 | +| 侧栏是否收起 | 应用数据文件 | +| 打开的应用与 dashboard 标签页及其标题 | 应用数据文件 | +| 最近打开的应用与 dashboard(侧栏的 **应用** / **仪表盘**) | 应用数据文件 | +| 你选择信任的应用文件夹 | 应用数据文件 | | 不会记住 | 说明 | | --- | --- | -| 打开的 Tab 及其 SQL | 仅在内存中 | +| 打开的查询 Tab 及其 SQL | 仅在内存中——运行过的查询在 **查询历史** 里 | | 结果面板的内容 | 仅在内存中 | | 打开的是哪个数据库文件 | 每次启动都从内存模式开始,除非你指定一个文件 | | 窗口大小与位置 | 固定为 1440×900,最小 960×600 | @@ -46,11 +51,26 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 ## 主题 -太阳/月亮按钮在浅色与深色主题之间切换。没有主题选择器,也没有可编辑的主题文件 —— 你得到的就是这两种模式。基础字号为 14px。 +太阳/月亮按钮在浅色与深色主题之间切换。没有主题选择器,也没有可编辑的主题文件 —— 你得到的就是这两种模式。 + +## 界面大小 {#interface-size} + +文字、图标和控件一起缩放,共四档: + +| 大小 | 界面文字 | 编辑器文字 | +| --- | --- | --- | +| 小 | 13px | 12px | +| 默认 | 14px | 13px | +| 大 | 16px | 15px | +| 特大 | 18px | 17px | + +在标题栏的 **Aa** 按钮里选择,或用 **⌘+**(放大)、**⌘−**(缩小)、**⌘0**(复原)逐档切换——无论焦点在哪里都有效,包括 SQL 编辑器。选择会被记住,切换明暗主题后也保持不变。 + +你拖动过的面板尺寸,以及 dashboard 图表的高度,仍以像素计:它们由你调整,不随界面缩放。 ## 重置 -要重新开始 —— 清空查询历史、已注册的文件和语言设置 —— 请退出 DuckLocal 并删除它的应用数据目录: +要重新开始 —— 清空查询历史、已注册的文件、记住的标签页、信任过的应用文件夹和语言设置 —— 请退出 DuckLocal 并删除它的应用数据目录: ```bash rm -rf ~/Library/Application\ Support/DuckLocal @@ -60,16 +80,4 @@ rm -rf ~/Library/Application\ Support/DuckLocal ## 疑难排查 -| 现象 | 原因 | -| --- | --- | -| 在你自己指定的路径上出现 `File not found` | 该路径不存在。DuckLocal 无法通过指定一个新文件来创建数据库 | -| 文件夹报告没有数据文件 | 其中没有任何条目匹配 `.csv`、`.tsv`、`.txt`、`.parquet`、`.json`、`.ndjson`、`.jsonl`、`.xlsx`、`.xls`、`.xlsb` 或 `.ods` | -| 文件夹产生的文件比预期少 | 隐藏条目和符号链接会被跳过,并且一次打开请求最多挂载 256 个文件 | -| 某个路径意外地作为数据库打开 | 任何已存在的非数据文件都会被当作 DuckDB 数据库处理;如果无法打开,DuckLocal 会回退到内存模式并显示错误 | -| 运行没有任何反应 | 已经有查询正在运行。没有取消,也没有超时 | -| 结果显示到一半就停下并给出截断提示 | 触及了 100,000 行或 2,000,000 个单元格的上限 | -| S3 凭据被遗忘 | 打开或切换数据库会清除它们 | -| 时间戳看起来有偏移 | 结果表格以 UTC 而非本地时间渲染时间戳 | -| 主题重置为浅色 | 它不会被持久化 | - -如果查询像是卡住了,状态栏会显示 DuckDB 版本和连接状态。退出并重新启动是放弃正在运行的查询的唯一方式。 +症状、原因与解决办法都整理在[常见问题排查](troubleshooting.md)页面。 diff --git a/docs/zh/sql-editor.md b/docs/zh/sql-editor.md index e2d7330..e7ec6d3 100644 --- a/docs/zh/sql-editor.md +++ b/docs/zh/sql-editor.md @@ -49,11 +49,14 @@ Tab 只存在于内存中。其中的 SQL 不会被保存,因此退出后会 ## 键盘快捷键 -⌘↵ 是 DuckLocal 自己定义的唯一快捷键。其余快捷键都来自编辑器和表格组件,行为与任何 macOS 文本框中一致: +DuckLocal 自己定义了少数几个快捷键,其余都来自编辑器和表格组件,行为与任何 macOS 文本框中一致: | 快捷键 | 操作 | | --- | --- | | ⌘↵ | 运行查询 | +| ⌘S | 在 dashboard 的源码视图中保存 | +| ⌘+ / ⌘− / ⌘0 | 放大 / 缩小 / 复原界面大小——见[界面大小](settings-and-data.md#interface-size) | +| ⌘B | 收起或展开侧栏 | | ⌘F / ⇧⌘F | 在编辑器中查找 / 替换 | | ⌘Z / ⇧⌘Z | 撤销 / 重做 | | ⌘A | 全选 | diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md new file mode 100644 index 0000000..99a2232 --- /dev/null +++ b/docs/zh/troubleshooting.md @@ -0,0 +1,74 @@ +# 常见问题排查 + +**[English](../troubleshooting.md)** · [文档](index.md) + +找到症状,看原因,照着修。如果这里没有你遇到的情况,请[提交 issue](https://github.com/JetSquirrel/DuckLocal/issues),写明你做了什么、看到了什么。 + +## 安装与启动 + +| 症状 | 原因与解决 | +| --- | --- | +| 在 Intel Mac 上打不开 | 发布版只为 Apple silicon 构建。在 Intel 上请[从源码构建](getting-started.md#install) | +| `ducklocal: command not found` | 磁盘映像只安装应用,不安装命令。请[把可执行文件链接到 PATH](getting-started.md#add-command) | +| 窗口开着时终端被占用 | 从终端打开 GUI 会在前台运行。改从“应用程序”启动,或者把那个终端留给它 | +| 历史、设置或已登记的文件不再保存 | 另一个 DuckLocal 正在运行,占用了应用数据文件。退出另一个后重新启动 | + +## 打开数据 + +| 症状 | 原因与解决 | +| --- | --- | +| 指定的路径报 `文件不存在` | 路径不存在,或者相对于的不是你以为的目录。用绝对路径最稳妥 | +| 文件夹报告没有数据文件 | 里面没有匹配 `.csv`、`.tsv`、`.txt`、`.parquet`、`.json`、`.ndjson`、`.jsonl`、`.xlsx`、`.xls`、`.xlsb` 或 `.ods` 的文件 | +| 文件夹导入的文件比预期少 | 隐藏条目和符号链接会被跳过,一次打开最多挂载 256 个文件。请分子文件夹打开 | +| 某个路径意外地被当作数据库打开 | 任何已存在、但不是数据文件的文件都会被当作 DuckDB 数据库。打不开时,DuckLocal 会回退到内存模式并显示错误 | +| **未挂载 …:当前数据库已有名为 … 的表或视图** | 当前数据库里有一个与已登记文件同名的表或视图。DuckLocal 不会替换它。把其中一个改名或删除,或者从 **本地文件** 移除该文件后重新打开,让它拿到新名字 | +| **无法恢复相对路径 …** | 这个文件是按相对路径登记的,新会话里无法解析。从 **本地文件** 移除后重新打开 | +| 视图名是 `sales_2` 而不是 `sales` | 已有同名的文件或表,新的那个就加了后缀 | +| 文件对应的视图不是最新的 | 视图每次查询都会读取文件。如果 CSV 的列结构变了,点侧边栏的 **刷新 Schema** | + +## 运行查询 + +| 症状 | 原因与解决 | +| --- | --- | +| **运行** 没反应 | 已有查询在运行。没有取消,也没有超时;想放弃一条查询只能退出重开 | +| **⌘↵** 显示了编辑器而不是运行 | 在首次运行界面上,⌘↵ 的意思是“给我一个编辑器”。再按一次就会运行 | +| 结果提前截断并有提示 | 达到了 100,000 行或 2,000,000 个单元格的上限。请缩小查询范围;没有分页 | +| 时间戳看起来偏了 | 表格按 UTC 显示时间戳,而不是本地时间 | +| 点击表头不排序 | 没有客户端排序。请用 `ORDER BY` | +| 点了表或历史记录后我的 SQL 没了 | 这些点击会替换整个编辑器内容。临时的尝试请用新的查询 Tab | +| 导出时悄悄覆盖了文件 | 导出会直接写入你给的路径,不会询问。请换个文件名 | + +## S3 + +| 症状 | 原因与解决 | +| --- | --- | +| S3 凭据丢了 | 凭据按设计只在本次会话有效,打开或切换数据库也会清除。请重新配置 | +| 离线时配置 S3 失败 | 首次使用会执行 `INSTALL httpfs`,需要下载扩展。联网一次后就会缓存 | +| 临时(STS)凭据被拒绝 | 不会发送 session token。请使用长期密钥——见 [S3 与 httpfs](s3.md#limitations) | +| bucket 看起来比实际少 | 每一层最多列出 1,000 个条目。改用通配符查询,比如 `'s3://bucket/prefix/*.parquet'` | + +## 应用与 Dashboard + +| 症状 | 原因与解决 | +| --- | --- | +| 应用标签页问 **要运行这个应用吗?** | 一个文件夹第一次作为应用打开时,DuckLocal 会在运行其代码前询问,因为应用的 SQL 能读写 SQL 编辑器能碰到的一切。不是你写的就先点 **先看源码**;**信任并运行** 会按文件夹记住 | +| dashboard 的图表提示查询必须只读 | `.dash` 里的查询试图写入(DDL、DML、`COPY`、`ATTACH` 等)。Dashboard 只执行只读语句;准备工作请放到 SQL 编辑器里做 | +| dashboard 里的 `PIVOT` 被拒绝 | 不写明 `IN (…)` 列表时,DuckDB 会把 `PIVOT` 拆成多条语句。请列出取值:`PIVOT t ON channel IN ('Web', 'Store') USING sum(revenue)` | + +## 外观 + +| 症状 | 原因与解决 | +| --- | --- | +| 每次启动主题都回到浅色 | 主题不会被保存;语言会 | +| 文字或图标太小 / 太大 | 用标题栏的 **Aa** 按钮,或 ⌘+ / ⌘− / ⌘0。见[界面大小](settings-and-data.md#interface-size) | +| 界面语言不对 | 用标题栏的 `EN` / `中` 按钮切换,选择会被记住 | + +## 从头开始 + +要清除历史、已登记的文件、记住的标签页和设置,先退出 DuckLocal,再删除它的应用数据目录: + +```bash +rm -rf ~/Library/Application\ Support/DuckLocal +``` + +你的数据文件不会受任何影响——只会删除 DuckLocal 自己的记录。里面具体存了什么,见[设置与应用数据](settings-and-data.md)。 diff --git a/docs/zh/tutorial.md b/docs/zh/tutorial.md new file mode 100644 index 0000000..6ccd2b6 --- /dev/null +++ b/docs/zh/tutorial.md @@ -0,0 +1,108 @@ +# 10 分钟上手教程 + +**[English](../tutorial.md)** · [文档](index.md) + +跟着做一遍:打开一个小的销售数据文件,问它几个问题,把答案画成图,再导出一份。本教程假设你已经装好了 DuckLocal——如果还没有,先看[快速上手](getting-started.md)。 + +## 1. 获取样例文件 + +下载 [sales.csv](/samples/sales.csv)——两周的虚构订单数据,共 224 行。也可以在终端里下载: + +```bash +curl -LO https://jetsquirrel.github.io/DuckLocal/samples/sales.csv +``` + +它有五列: + +| 列 | 类型 | 示例 | +| --- | --- | --- | +| `date` | DATE | `2026-09-01` | +| `channel` | VARCHAR | `Web`、`Mobile app`、`Store`、`Phone` | +| `region` | VARCHAR | `North`、`South`、`East`、`West` | +| `orders` | BIGINT | `72` | +| `revenue` | DOUBLE | `4181.04` | + +这些都不需要你告诉 DuckLocal:DuckDB 会读取表头并自行推断类型。 + +## 2. 打开文件 + +把 `sales.csv` 拖到 DuckLocal 窗口上。(也可以点击 **打开文件…**;如果你已经[添加了命令](getting-started.md#add-command),还可以运行 `ducklocal sales.csv`。) + +会弹出通知 **已创建视图 sales**。侧边栏的 **本地文件** 下出现了 `sales` 和它的 224 行;展开它可以看到各列及其类型。 + +## 3. 看看数据 + +在侧边栏里,点击 `sales` 旁边的播放按钮。编辑器会填入一条针对它的 `SELECT * … LIMIT 100`——按 **⌘↵** 运行。 + +编辑器下方的表格显示了这些行。试试上方的过滤框:输入 `Store` 并回车,只保留包含它的行。过滤只作用于已经加载的行,不会重新查询。 + +## 4. 提一个问题 + +把 SQL 换成下面这条,按 **⌘↵**: + +```sql +SELECT channel, round(sum(revenue), 2) AS revenue +FROM sales +GROUP BY channel +ORDER BY revenue DESC; +``` + +| channel | revenue | +| --- | --- | +| Mobile app | 290549.76 | +| Web | 289544.8 | +| Store | 240067.6 | +| Phone | 82511.2 | + +现在把结果面板从 **结果** 切到 **图表**。因为第一列是文本,你会得到每个渠道一根柱子,高度取第一个数值列。 + +::: tip 自动补全 +输入时,编辑器会先提示侧边栏里的表名和列名,然后是 DuckDB 函数和 SQL 关键字——输入 `rev`,第一个候选就是 `revenue`。 +::: + +## 5. 画一条趋势 + +图表直接取查询返回的行来画,所以想怎么画,就把结果整理成什么形状。每个渠道一列,就得到每个渠道一条线: + +```sql +SELECT + date, + round(sum(revenue) FILTER (WHERE channel = 'Web'), 2) AS web, + round(sum(revenue) FILTER (WHERE channel = 'Mobile app'), 2) AS mobile, + round(sum(revenue) FILTER (WHERE channel = 'Store'), 2) AS store +FROM sales +GROUP BY date +ORDER BY date; +``` + +第一列是日期时,**图表** 会画出随时间变化的面积图,每个数值列一条序列。规则和限制见[结果与图表](results-and-charts.md)。 + +## 6. 导出结果 + +趋势结果还在面板里时,点击 **导出 CSV**。对话框默认填入 `~/Desktop/export.csv`;按需修改路径后确认。 + +导出会重新执行查询,所以文件里是完整结果,而不只是表格里看得到的部分。**如果文件已存在,会直接覆盖,不会询问。** + +## 7. 明天还能找到 + +打开侧边栏的 **查询历史** 标签:你运行过的每条查询都在这里,失败的也在。点击一条可以把它放回编辑器——注意这会替换编辑器里原有的内容。 + +退出 DuckLocal 再重新启动。`sales` 仍然在 **本地文件** 下:打开过的文件按路径记住,每次启动时重新读取,所以 CSV 改过之后,下次查询就能看到新内容。 + +## 8. 附加:在终端里问同一个问题 + +如果你添加了 `ducklocal` 命令,CLI 可以不开窗口直接回答——写脚本或交给 AI agent 都很方便: + +```bash +ducklocal query --format md --sql " + SELECT channel, round(sum(revenue), 2) AS revenue + FROM 'sales.csv' GROUP BY channel ORDER BY revenue DESC" +``` + +去掉 `--format md` 就得到结构化的 JSON。入门请看 [CLI 指南](cli.md)和官方 agent skill。 + +## 接下来 + +- 用 DuckLocal 打开你自己的文件或文件夹——见[数据源](data-sources.md) +- 查询 S3 兼容存储里的文件——见 [S3 与 httpfs](s3.md) +- 用保存的查询搭一个 dashboard——见[分析应用与 Dashboard](analysis-app.md#dashboard-specs-dash) diff --git a/src/app.rs b/src/app.rs index d108567..8fcc816 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use gpui_kit::component::notification::Notification; use gpui_kit::component::resizable::{h_resizable, resizable_panel}; use gpui_kit::component::{v_flex, ActiveTheme, WindowExt}; +use gpui_kit::prelude::FluentBuilder; use gpui_kit::*; use crate::analysis::apps; @@ -14,10 +15,9 @@ use crate::ui::sidebar::Sidebar; use crate::ui::status_bar::StatusBarView; use crate::ui::title_bar::TitleBarView; use crate::ui::workspace::Workspace; -use crate::ui::{apply_open_outcome, open_paths}; +use crate::ui::{apply_open_outcome, open_paths, ToggleSidebar}; pub struct DuckLocalApp { - #[allow(dead_code)] state: Entity, title_bar: Entity, sidebar: Entity, @@ -35,6 +35,8 @@ impl DuckLocalApp { let sidebar = cx.new(|cx| Sidebar::new(state.clone(), workspace.clone(), window, cx)); let title_bar = cx.new(|cx| TitleBarView::new(state.clone(), cx)); let status_bar = cx.new(|cx| StatusBarView::new(state.clone(), cx)); + cx.subscribe(&state, |_, _, _: &state::SidebarToggled, cx| cx.notify()) + .detach(); let open_state = state.clone(); cx.spawn_in(window, async move |this, cx| { @@ -149,19 +151,26 @@ impl Render for DuckLocalApp { .border_2() .border_color(cx.theme().background) .drag_over::(|style, _, _, cx| style.border_color(cx.theme().primary)) + .on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| { + this.state.update(cx, |state, cx| state.toggle_sidebar(cx)); + })) .child(self.title_bar.clone()) - .child( - div().flex_1().min_h_0().child( - h_resizable("main-split") - .child( - resizable_panel() - .size(px(280.)) - .size_range(px(220.)..px(420.)) - .child(self.sidebar.clone()), - ) - .child(resizable_panel().child(self.workspace.clone())), - ), - ) + .child(div().flex_1().min_h_0().map(|this| { + if self.state.read(cx).is_sidebar_collapsed() { + this.child(self.workspace.clone()) + } else { + this.child( + h_resizable("main-split") + .child( + resizable_panel() + .size(px(280.)) + .size_range(px(220.)..px(420.)) + .child(self.sidebar.clone()), + ) + .child(resizable_panel().child(self.workspace.clone())), + ) + } + })) .child(self.status_bar.clone()) } } diff --git a/src/app_export/run.rs b/src/app_export/run.rs index b14ffb0..4c0bb1e 100644 --- a/src/app_export/run.rs +++ b/src/app_export/run.rs @@ -116,7 +116,7 @@ pub fn capture(job: Job, finish: impl FnOnce(Outcome) -> std::convert::Infallibl }}; } gpui_kit::application() - .with_assets(gpui_kit::assets::Assets) + .with_assets(crate::assets::AppAssets) .run(move |cx| { let started = Instant::now(); let timeout = job.timeout; diff --git a/src/assets.rs b/src/assets.rs new file mode 100644 index 0000000..42b5531 --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,103 @@ +//! The asset source the application registers. +//! +//! The component library's default bundle holds only the icons its own +//! components draw. An icon outside it is still a valid `IconName` — the +//! enum covers the whole catalog — so it compiles, renders blank and logs +//! "could not find asset" on every frame. The icons DuckLocal draws beyond +//! the defaults are embedded here, one by one, rather than the whole catalog +//! (`AllAssets`): the release build is optimized for size, and a thousand +//! unused SVGs are not free. +//! +//! Adding an icon from outside the default bundle means adding it to +//! `ExtraIcons` too; `every_icon_the_app_draws_is_served` checks that. + +use std::borrow::Cow; + +use gpui_kit::assets::{icon_assets, Assets}; +use gpui_kit::{AssetSource, Result, SharedString}; + +icon_assets!( + ExtraIcons, + [Save, AppWindow, WandSparkles, ListTree, Pencil, Download, Code] +); + +/// The default component bundle, plus [`ExtraIcons`]. +pub struct AppAssets; + +impl AssetSource for AppAssets { + fn load(&self, path: &str) -> Result>> { + match ExtraIcons.load(path)? { + Some(bytes) => Ok(Some(bytes)), + None => Assets.load(path), + } + } + + fn list(&self, path: &str) -> Result> { + let mut paths = Assets.list(path)?; + paths.extend(ExtraIcons.list(path)?); + Ok(paths) + } +} + +#[cfg(test)] +mod tests { + use super::AppAssets; + use gpui_kit::assets::IconName; + use gpui_kit::AssetSource; + + /// Every icon named in the source, found by scanning for `IconName::` and + /// its alias `AssetIcon::`. + /// A scan rather than a list, so a new icon cannot be added without + /// this test noticing. + fn icons_in_source() -> Vec { + fn walk(dir: &std::path::Path, found: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, found); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let text = std::fs::read_to_string(&path).unwrap(); + // `AssetIcon` is how the workspace names the full catalog. + let pieces = text + .split("IconName::") + .skip(1) + .chain(text.split("AssetIcon::").skip(1)); + for piece in pieces { + let name: String = piece + .chars() + .take_while(|c| c.is_ascii_alphanumeric()) + .collect(); + if name.starts_with(|c: char| c.is_ascii_uppercase()) + && name != "ALL" + && !found.contains(&name) + { + found.push(name); + } + } + } + } + } + let mut found = Vec::new(); + walk(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut found); + found + } + + #[test] + fn every_icon_the_app_draws_is_served() { + let names = icons_in_source(); + for expected in ["Save", "Pencil", "SquareTerminal"] { + assert!(names.contains(&expected.to_string()), "{names:?}"); + } + for name in names { + let icon = IconName::ALL + .iter() + .find(|icon| format!("{icon:?}") == name) + .unwrap_or_else(|| panic!("IconName::{name} is not in the catalog")); + let path = icon.path(); + assert!( + matches!(AppAssets.load(&path), Ok(Some(_))), + "IconName::{name} ({path}) is not served; add it to ExtraIcons" + ); + } + } +} diff --git a/src/db.rs b/src/db.rs index e4b0ec5..a3f6bc6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -47,15 +47,17 @@ pub enum DatabaseTarget { } impl DatabaseTarget { - pub fn display_label(&self) -> String { + /// The database's path, for the chrome to name — `None` in memory, where + /// `:memory:` is jargon for the default and says nothing worth a glance. + pub fn file_label(&self) -> Option { match self { - DatabaseTarget::File(path) => compact_home(path), - DatabaseTarget::Memory => ":memory:".to_string(), + DatabaseTarget::File(path) => Some(compact_home(path)), + DatabaseTarget::Memory => None, } } } -/// `$HOME`, read once. `display_label` runs on every frame of both the title +/// `$HOME`, read once. `file_label` runs on every frame of both the title /// bar and the status bar, so it should not go back to the environment each /// time. fn home() -> Option<&'static str> { @@ -64,11 +66,20 @@ fn home() -> Option<&'static str> { } /// Display `$HOME` as `~`. -fn compact_home(path: &str) -> String { - if let Some(rest) = home().and_then(|home| path.strip_prefix(home)) { - return format!("~{rest}"); +pub(crate) fn compact_home(path: &str) -> String { + match home() { + Some(home) => compact_home_under(path, home), + None => path.to_string(), + } +} + +/// `path` with a leading `home` shown as `~` — only at a path boundary, so a +/// home of `/Users/al` leaves `/Users/alice/x` alone. +fn compact_home_under(path: &str, home: &str) -> String { + match path.strip_prefix(home) { + Some(rest) if rest.is_empty() || rest.starts_with('/') => format!("~{rest}"), + _ => path.to_string(), } - path.to_string() } /// Replace a `~/` prefix with `$HOME` expanded. @@ -181,32 +192,15 @@ pub(crate) fn connection_guard() -> std::sync::MutexGuard<'static, ()> { LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } -/// Server metadata for the title bar and status bar. +/// Server metadata for the status bar. #[derive(Clone, Debug)] pub struct ServerInfo { pub version: String, - pub threads: String, - pub memory_limit: String, } pub fn server_info_of(conn: &Connection) -> Result { let version: String = conn.query_row("SELECT version()", [], |r| r.get(0))?; - let threads = setting_or(conn, "threads", "8"); - let memory_limit = setting_or(conn, "memory_limit", "-"); - Ok(ServerInfo { - version, - threads, - memory_limit, - }) -} - -fn setting_or(conn: &Connection, name: &str, default: &str) -> String { - conn.query_row( - "SELECT value FROM duckdb_settings() WHERE name = ?1", - [name], - |r| r.get::<_, String>(0), - ) - .unwrap_or_else(|_| default.to_string()) + Ok(ServerInfo { version }) } pub fn server_info() -> Result { @@ -444,6 +438,13 @@ mod tests { assert!(!is_connected()); } + #[test] + fn home_is_compacted_only_at_a_path_boundary() { + assert_eq!(compact_home_under("/Users/al/x.csv", "/Users/al"), "~/x.csv"); + assert_eq!(compact_home_under("/Users/al", "/Users/al"), "~"); + assert_eq!(compact_home_under("/Users/alice/x", "/Users/al"), "/Users/alice/x"); + } + #[test] fn tilde_expansion() { let expanded = expand_tilde("~/data/x.duckdb"); diff --git a/src/i18n.rs b/src/i18n.rs index 45e7d60..d3c2e93 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -139,6 +139,8 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("sidebar.tab.schema", "表结构", "Schema"), ("sidebar.tab.history", "查询历史", "History"), ("sidebar.refresh_schema", "刷新 Schema", "Refresh schema"), + ("sidebar.collapse", "收起侧栏", "Hide sidebar"), + ("sidebar.expand", "展开侧栏", "Show sidebar"), ( "sidebar.schema.empty", "当前没有表或视图。\n拖入数据文件、通过“打开数据…”导入,或运行 CREATE TABLE 后点击刷新。", @@ -162,6 +164,11 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("sidebar.table.generate_select", "生成 SELECT 查询", "Generate SELECT query"), ("sidebar.column.edit_type", "修改数据类型", "Change data type"), ("sidebar.file.remove", "从本地文件移除", "Remove from local files"), + ( + "sidebar.recent.remove", + "从列表移除(文件不受影响)", + "Remove from this list (the files stay)", + ), ( "sidebar.recents.gone", "{} 已不存在,已从最近列表移除", @@ -215,6 +222,15 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("title_bar.configure_s3", "配置 S3 数据源(httpfs)", "Configure S3 source (httpfs)"), ("title_bar.toggle_theme", "切换明暗主题", "Toggle light/dark theme"), ("title_bar.toggle_language", "切换语言", "Switch language"), + ( + "title_bar.ui_size", + "界面大小(⌘+ 放大 · ⌘− 缩小 · ⌘0 复原)", + "Interface size (⌘+ larger · ⌘− smaller · ⌘0 reset)", + ), + ("ui_size.small", "小", "Small"), + ("ui_size.default", "默认", "Default"), + ("ui_size.large", "大", "Large"), + ("ui_size.xlarge", "特大", "Extra large"), ("dialog.s3.title", "配置 S3 数据源", "Configure S3 source"), ( "dialog.s3.description", @@ -286,8 +302,7 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("workspace.format.tooltip", "格式化当前 SQL", "Format current SQL"), ("workspace.explain.tooltip", "查看查询计划", "View query plan"), ("workspace.rename", "重命名", "Rename"), - ("workspace.rename.tooltip", "重命名当前查询 Tab", "Rename current query tab"), - ("workspace.server_info", "线程 {} · 内存上限 {}", "Threads {} · memory limit {}"), + ("workspace.rename.tooltip", "重命名当前 Tab", "Rename this tab"), ("dialog.rename.title", "重命名查询", "Rename query"), ("dialog.rename.confirm", "重命名", "Rename"), ("workspace.empty.title", "把数据拖进来", "Drop your data in"), @@ -501,6 +516,13 @@ static STRINGS: &[(&str, &str, &str)] = &[ "打开分析应用…", "Open app…", ), + ("workspace.open_dashboard", "打开 Dashboard…", "Open dashboard…"), + ("dashboard.picker.prompt", "选择 .dash 文件", "Choose a .dash file"), + ( + "dashboard.picker.not_a_spec", + "{} 不是 .dash 文件", + "{} is not a .dash file", + ), ( "workspace.add_tab.tooltip", "新建查询,或打开一个分析应用", diff --git a/src/main.rs b/src/main.rs index c9d049a..511ce25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod analysis; mod app; mod app_export; +mod assets; mod cli; mod db; mod excel; @@ -42,6 +43,7 @@ fn main() { // Nothing is persisted until the user explicitly switches language. crate::history::init().ok(); i18n::set_current(i18n::initial_language()); + ui::scale::load(); // Paths named on the command line: data files, folders, patterns, or a // database to open instead of the in-memory connection. `-psn_…` is what @@ -55,16 +57,14 @@ fn main() { .collect(); gpui_kit::application() - .with_assets(gpui_kit::assets::Assets) + .with_assets(assets::AppAssets) .run(move |cx| { gpui_kit::init(cx); ui::init(cx); Theme::change(ThemeMode::Light, None, cx); - // Denser desktop density: 14px rem base (gpui-component ships 16). - // Must be re-applied after every Theme::change — it rebuilds the - // theme from stock defaults and resets font_size. - Theme::global_mut(cx).font_size = px(14.); - Theme::sync_base(cx); + // The interface size is the rem base; it must be re-applied after + // every Theme::change, which resets the theme to stock defaults. + ui::scale::apply(cx); let window_bounds = Bounds::centered(None, size(px(1440.), px(900.)), cx); cx.spawn(async move |cx| { diff --git a/src/spec/highlight.rs b/src/spec/highlight.rs new file mode 100644 index 0000000..9e231fe --- /dev/null +++ b/src/spec/highlight.rs @@ -0,0 +1,451 @@ +//! Syntax highlighting for `.dash` source in the dashboard's editor. +//! +//! The editor's highlighting is parser-independent: it asks an +//! [`InputHighlighter`] for styled ranges. There is no tree-sitter grammar for +//! `.dash`, and it does not need one — the language is small enough that a +//! scanner colours it, and the SQL inside heredocs goes to the same +//! tree-sitter SQL highlighter the SQL editor uses, so a query reads the same +//! in both places. +//! +//! The scanner is deliberately not [`super::syntax::lex`]. The lexer's job is +//! to refuse a malformed file at the first mistake; a highlighter runs on +//! every keystroke over text that is malformed half the time — an unclosed +//! string, a heredoc still missing its delimiter — and has to keep colouring +//! the rest. It follows the lexer's rules (`#` and `//` comments, strings that +//! end at their line, `<, &'static str)>, + sql: Vec>, +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +fn is_ident(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// The byte index where the line holding `at` ends (its `\n`, or the end). +fn line_end(bytes: &[u8], at: usize) -> usize { + bytes[at..] + .iter() + .position(|&b| b == b'\n') + .map_or(bytes.len(), |ix| at + ix) +} + +/// The next byte after `at` that is not a space or tab, on the same line. +fn next_on_line(bytes: &[u8], mut at: usize) -> Option { + while at < bytes.len() && matches!(bytes[at], b' ' | b'\t') { + at += 1; + } + bytes.get(at).copied().filter(|&b| b != b'\n') +} + +fn scan(source: &str) -> Scan { + let bytes = source.as_bytes(); + let mut scan = Scan::default(); + let mut depth = 0usize; + let mut ix = 0; + while ix < bytes.len() { + let b = bytes[ix]; + match b { + b'#' => { + let end = line_end(bytes, ix); + scan.runs.push((ix..end, "comment")); + ix = end; + } + b'/' if bytes.get(ix + 1) == Some(&b'/') => { + let end = line_end(bytes, ix); + scan.runs.push((ix..end, "comment")); + ix = end; + } + b'"' => { + // A string ends at its closing quote or, unclosed, at its line. + let mut end = ix + 1; + while end < bytes.len() && bytes[end] != b'\n' { + match bytes[end] { + b'\\' if end + 1 < bytes.len() && bytes[end + 1] != b'\n' => end += 2, + b'"' => { + end += 1; + break; + } + _ => end += 1, + } + } + let end = end.min(bytes.len()); + scan.runs.push((ix..end, "string")); + ix = end; + } + b'<' if bytes.get(ix + 1) == Some(&b'<') => { + let mut end = ix + 2; + while end < bytes.len() && is_ident(bytes[end]) { + end += 1; + } + let delimiter = &source[ix + 2..end]; + scan.runs.push((ix..end, "string.special")); + if delimiter.is_empty() { + ix = end; + continue; + } + // The body starts on the next line and runs to the first line + // that is exactly the delimiter — or, still being typed, to + // the end of the file. + let opening_end = line_end(bytes, end); + let body_start = (opening_end + 1).min(bytes.len()); + let mut line_start = body_start; + let mut closed = None; + while line_start < bytes.len() { + let end_of_line = line_end(bytes, line_start); + let line = &source[line_start..end_of_line]; + if line.trim() == delimiter { + let lead = line.len() - line.trim_start().len(); + closed = Some((line_start, line_start + lead)); + break; + } + line_start = end_of_line + 1; + } + match closed { + Some((closing_line, delimiter_start)) => { + if closing_line > body_start { + scan.sql.push(body_start..closing_line); + } + let delimiter_end = delimiter_start + delimiter.len(); + scan.runs.push((delimiter_start..delimiter_end, "string.special")); + ix = delimiter_end; + } + None => { + if bytes.len() > body_start { + scan.sql.push(body_start..bytes.len()); + } + ix = bytes.len(); + } + } + } + b'{' => { + depth += 1; + ix += 1; + } + b'}' => { + depth = depth.saturating_sub(1); + ix += 1; + } + b'-' | b'0'..=b'9' + if b.is_ascii_digit() + || bytes.get(ix + 1).is_some_and(|next| next.is_ascii_digit()) => + { + let mut end = ix + 1; + while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') { + end += 1; + } + scan.runs.push((ix..end, "number")); + ix = end; + } + _ if is_ident_start(b) => { + let mut end = ix + 1; + while end < bytes.len() && is_ident(bytes[end]) { + end += 1; + } + let word = &source[ix..end]; + let next = next_on_line(bytes, end); + let name = if depth == 0 && next == Some(b'"') { + // `query "name" {` — a block's type. + "keyword" + } else if next == Some(b'=') { + "property" + } else if matches!(word, "true" | "false") { + "boolean" + } else if bytes.get(end) == Some(&b'.') + && bytes.get(end + 1).is_some_and(|&b| is_ident_start(b)) + { + // `query.latency`: the block type, then the block's name. + let mut tail_end = end + 2; + while tail_end < bytes.len() && is_ident(bytes[tail_end]) { + tail_end += 1; + } + scan.runs.push((ix..end, "keyword")); + scan.runs.push((end + 1..tail_end, "variable.special")); + ix = tail_end; + continue; + } else if PLOT_TYPES.contains(&word) { + "constant" + } else { + // A bare column name, like `x = day`. + "variable.special" + }; + scan.runs.push((ix..end, name)); + ix = end; + } + _ => { + // Step a whole character, so a multi-byte one is never split. + ix += source[ix..].chars().next().map_or(1, char::len_utf8); + } + } + } + scan +} + +/// A dashboard source's highlighter: the scan of the whole text, plus one SQL +/// highlighter per heredoc body. Specs are small, so each edit rescans the +/// lot; a body whose text did not change keeps its parsed SQL. +struct DashHighlighter { + scan: Scan, + sql: Vec<(String, SyntaxHighlighter)>, +} + +impl DashHighlighter { + fn new() -> Self { + Self { + scan: Scan::default(), + sql: Vec::new(), + } + } +} + +impl DashHighlighter { + /// Rescan `source`, reusing the parsed SQL of every heredoc body whose + /// text did not change. + fn refresh(&mut self, source: &str) { + self.scan = scan(source); + + let mut previous = std::mem::take(&mut self.sql); + self.sql = self + .scan + .sql + .iter() + .map(|range| { + let body = source[range.clone()].to_string(); + match previous.iter().position(|(text, _)| *text == body) { + Some(ix) => previous.swap_remove(ix), + None => { + let mut highlighter = SyntaxHighlighter::new("sql"); + highlighter.update(None, &Rope::from(body.as_str()), None); + (body, highlighter) + } + } + }) + .collect(); + } +} + +impl InputHighlighter for DashHighlighter { + fn language(&self) -> SharedString { + LANGUAGE.into() + } + + fn update( + &mut self, + _edit: Option, + text: &Rope, + _folding: bool, + _window: &mut Window, + _cx: &mut Context, + ) { + self.refresh(&text.to_string()); + } + + fn styles( + &self, + range: &Range, + resolver: &dyn HighlightStyleResolver, + ) -> Vec<(Range, HighlightStyle)> { + // Styled pieces inside `range`, in order and disjoint: the scanner's + // runs never enter a heredoc body, and a body's own styles stay in it. + let mut pieces: Vec<(Range, HighlightStyle)> = Vec::new(); + for (run, name) in &self.scan.runs { + let start = run.start.max(range.start); + let end = run.end.min(range.end); + if start < end { + if let Some(style) = resolver.style(name) { + pieces.push((start..end, style)); + } + } + } + for (body, (_, highlighter)) in self.scan.sql.iter().zip(&self.sql) { + let start = body.start.max(range.start); + let end = body.end.min(range.end); + if start >= end { + continue; + } + let local = start - body.start..end - body.start; + for (piece, style) in highlighter.styles(&local, resolver) { + if style != HighlightStyle::default() && piece.start < piece.end { + pieces.push((piece.start + body.start..piece.end + body.start, style)); + } + } + } + pieces.sort_by_key(|(piece, _)| piece.start); + + // The editor wants the whole range covered: unstyled text in between. + let mut runs = Vec::with_capacity(pieces.len() * 2 + 1); + let mut at = range.start; + for (piece, style) in pieces { + if piece.start < at { + continue; + } + if piece.start > at { + runs.push((at..piece.start, HighlightStyle::default())); + } + at = piece.end; + runs.push((piece, style)); + } + if at < range.end { + runs.push((at..range.end, HighlightStyle::default())); + } + runs + } + + fn fold_ranges(&self, _text: &Rope) -> Vec { + Vec::new() + } +} + +/// The factory the dashboard's source editor installs: `.dash` gets this +/// highlighter, anything else none. +pub fn factory() -> InputHighlighterFactory { + Rc::new(|language| { + (language == LANGUAGE).then(|| Box::new(DashHighlighter::new()) as Box) + }) +} + +#[cfg(test)] +mod tests { + use super::scan; + + /// Each run as `(text, highlight name)`, for readable assertions. + fn runs(source: &str) -> Vec<(&str, &'static str)> { + scan(source) + .runs + .into_iter() + .map(|(range, name)| (&source[range], name)) + .collect() + } + + #[test] + fn a_spec_is_coloured_by_what_each_word_is() { + let source = "# latency\nplot \"p50\" {\n type = line\n query = query.latency\n x = day\n stacked = true\n width = 2\n}\n"; + assert_eq!( + runs(source), + vec![ + ("# latency", "comment"), + ("plot", "keyword"), + ("\"p50\"", "string"), + ("type", "property"), + ("line", "constant"), + ("query", "property"), + ("query", "keyword"), + ("latency", "variable.special"), + ("x", "property"), + ("day", "variable.special"), + ("stacked", "property"), + ("true", "boolean"), + ("width", "property"), + ("2", "number"), + ] + ); + } + + #[test] + fn a_heredoc_body_is_sql_and_its_delimiters_are_marked() { + let source = "query \"q\" {\n sql = < = found + .runs + .iter() + .filter(|(_, name)| *name == "string.special") + .map(|(range, _)| &source[range.clone()]) + .collect(); + assert_eq!(marks, vec!["<