From 6fb9a9abb49691de14d29b8c09126c3141736aeb Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 22:30:12 +0800 Subject: [PATCH 1/8] Rework the docs site around a new user's first ten minutes - getting started leads with the disk image, adds the missing step for putting `ducklocal` on PATH, and describes the first-run screen as it is - a guided tutorial over a generated sample file (docs/public/samples), every query checked against the real binary - a troubleshooting page, gathered from settings plus first-run problems: command not found, name clashes, the app trust prompt, read-only dashboards - the home page says what DuckLocal does and routes by task; the CLI and apps move out of "Development" into their own sidebar group - settings no longer claims open tabs are forgotten: app and dashboard tabs, recent documents and trusted app folders are remembered - explicit heading anchors where pages link to each other Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- README.zh-CN.md | 2 +- docs/.vitepress/config.mts | 27 ++-- docs/getting-started.md | 113 ++++++++++------- docs/index.md | 95 +++++++------- docs/public/samples/sales.csv | 225 ++++++++++++++++++++++++++++++++++ docs/schema-and-history.md | 2 +- docs/settings-and-data.md | 27 ++-- docs/troubleshooting.md | 77 ++++++++++++ docs/tutorial.md | 133 ++++++++++++++++++++ docs/zh/analysis-app.md | 2 +- docs/zh/getting-started.md | 74 +++++++---- docs/zh/index.md | 89 ++++++++------ docs/zh/s3.md | 2 +- docs/zh/schema-and-history.md | 2 +- docs/zh/settings-and-data.md | 23 ++-- docs/zh/troubleshooting.md | 73 +++++++++++ docs/zh/tutorial.md | 108 ++++++++++++++++ 18 files changed, 877 insertions(+), 199 deletions(-) create mode 100644 docs/public/samples/sales.csv create mode 100644 docs/troubleshooting.md create mode 100644 docs/tutorial.md create mode 100644 docs/zh/troubleshooting.md create mode 100644 docs/zh/tutorial.md diff --git a/README.md b/README.md index f17b656..c7d689d 100644 --- a/README.md +++ b/README.md @@ -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..25a133a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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/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..740f51a 100644 --- a/docs/schema-and-history.md +++ b/docs/schema-and-history.md @@ -2,7 +2,7 @@ **[中文](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). ## The schema tree diff --git a/docs/settings-and-data.md b/docs/settings-and-data.md index 544d6de..78e4f5a 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,13 @@ 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 | +| 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 | @@ -63,7 +68,8 @@ base font size is 14px. ## 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 +81,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/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..5decafe --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,77 @@ +# 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 | +| 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..fa12a21 100644 --- a/docs/zh/analysis-app.md +++ b/docs/zh/analysis-app.md @@ -220,7 +220,7 @@ 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-规格文件)。 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..13e0cfc 100644 --- a/docs/zh/schema-and-history.md +++ b/docs/zh/schema-and-history.md @@ -2,7 +2,7 @@ **[English](../schema-and-history.md)** · [文档](index.md) -侧栏有两个 Tab:**表结构**(schema)和**查询历史**(history)。 +侧栏有两个 Tab:**表结构** 和 **查询历史**(英文界面中为 **Schema** 和 **History**)。 ## Schema 树 diff --git a/docs/zh/settings-and-data.md b/docs/zh/settings-and-data.md index 341e97f..fdc01d2 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,13 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 | 已注册的数据文件 —— 路径、视图名、类型 | 应用数据文件 | | 查询历史,包括失败记录 | 应用数据文件 | | 界面语言 | 应用数据文件 | +| 打开的应用与 dashboard 标签页及其标题 | 应用数据文件 | +| 最近打开的应用与 dashboard(侧栏的 **应用** / **仪表盘**) | 应用数据文件 | +| 你选择信任的应用文件夹 | 应用数据文件 | | 不会记住 | 说明 | | --- | --- | -| 打开的 Tab 及其 SQL | 仅在内存中 | +| 打开的查询 Tab 及其 SQL | 仅在内存中——运行过的查询在 **查询历史** 里 | | 结果面板的内容 | 仅在内存中 | | 打开的是哪个数据库文件 | 每次启动都从内存模式开始,除非你指定一个文件 | | 窗口大小与位置 | 固定为 1440×900,最小 960×600 | @@ -50,7 +53,7 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 ## 重置 -要重新开始 —— 清空查询历史、已注册的文件和语言设置 —— 请退出 DuckLocal 并删除它的应用数据目录: +要重新开始 —— 清空查询历史、已注册的文件、记住的标签页、信任过的应用文件夹和语言设置 —— 请退出 DuckLocal 并删除它的应用数据目录: ```bash rm -rf ~/Library/Application\ Support/DuckLocal @@ -60,16 +63,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/troubleshooting.md b/docs/zh/troubleshooting.md new file mode 100644 index 0000000..2beb71a --- /dev/null +++ b/docs/zh/troubleshooting.md @@ -0,0 +1,73 @@ +# 常见问题排查 + +**[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)` | + +## 外观 + +| 症状 | 原因与解决 | +| --- | --- | +| 每次启动主题都回到浅色 | 主题不会被保存;语言会 | +| 界面语言不对 | 用标题栏的 `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) From d0b0d60d36f45319d9c168bbce2c683f055774b7 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 22:30:20 +0800 Subject: [PATCH 2/8] Serve the two icons the app draws from outside the default bundle `Save` (the dashboard's save button) and `AppWindow` (recent apps in the sidebar) are valid `IconName`s but not in the component library's default asset bundle, so they rendered blank and logged "could not find asset" on every frame. An app asset source now embeds exactly those two on top of the defaults, for the window and the headless export alike, and a test scans the source for every icon drawn and checks each is served. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app_export/run.rs | 2 +- src/assets.rs | 92 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/assets.rs 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..f9f0e0c --- /dev/null +++ b/src/assets.rs @@ -0,0 +1,92 @@ +//! 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]); + +/// 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::`. + /// 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(); + for piece in text.split("IconName::").skip(1) { + 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(); + assert!(names.contains(&"Save".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/main.rs b/src/main.rs index c9d049a..864afc5 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; @@ -55,7 +56,7 @@ 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); From 55d32fd0393ab123ff8df16a7249af7d4a0da6db Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 22:30:42 +0800 Subject: [PATCH 3/8] =?UTF-8?q?Add=20an=20adjustable=20interface=20size:?= =?UTF-8?q?=20four=20steps,=20=E2=8C=98+=20/=20=E2=8C=98=E2=88=92=20/=20?= =?UTF-8?q?=E2=8C=980?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component library sizes spacing, icons, controls and text in rems, and the rem is the theme's base font size, so one setting scales the whole interface. Small / Default / Large / Extra large set a 13 / 14 / 16 / 18px base (the editor's monospace one pixel under), chosen from a title-bar menu or with the usual Mac shortcuts, wherever focus is. The choice is persisted like the language and re-applied after every theme switch, which used to pin 14px in three places. Containers sized in raw pixels — dialog widths, the status bar, the filter box, sidebar row slots — now scale with it; dragged panel sizes and plot heights stay the user's. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- README.zh-CN.md | 2 +- docs/settings-and-data.md | 23 ++++- docs/sql-editor.md | 4 +- docs/troubleshooting.md | 1 + docs/zh/settings-and-data.md | 18 +++- docs/zh/sql-editor.md | 4 +- docs/zh/troubleshooting.md | 1 + src/i18n.rs | 9 ++ src/main.rs | 9 +- src/ui/mod.rs | 20 ++-- src/ui/results.rs | 4 +- src/ui/scale.rs | 175 +++++++++++++++++++++++++++++++++++ src/ui/sidebar/actions.rs | 2 +- src/ui/sidebar/mod.rs | 5 +- src/ui/status_bar.rs | 2 +- src/ui/title_bar.rs | 36 +++++-- src/ui/workspace.rs | 8 +- 18 files changed, 290 insertions(+), 35 deletions(-) create mode 100644 src/ui/scale.rs diff --git a/README.md b/README.md index c7d689d..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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 25a133a..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 diff --git a/docs/settings-and-data.md b/docs/settings-and-data.md index 78e4f5a..cbdc074 100644 --- a/docs/settings-and-data.md +++ b/docs/settings-and-data.md @@ -28,6 +28,7 @@ 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 | | 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 | @@ -63,8 +64,26 @@ 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 diff --git a/docs/sql-editor.md b/docs/sql-editor.md index 51282ce..fbf2783 100644 --- a/docs/sql-editor.md +++ b/docs/sql-editor.md @@ -69,13 +69,15 @@ 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) | | ⌘F / ⇧⌘F | Find / replace in the editor | | ⌘Z / ⇧⌘Z | Undo / redo | | ⌘A | Select all | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5decafe..5107b7e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -62,6 +62,7 @@ did and what you saw. | 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 diff --git a/docs/zh/settings-and-data.md b/docs/zh/settings-and-data.md index fdc01d2..dd3babb 100644 --- a/docs/zh/settings-and-data.md +++ b/docs/zh/settings-and-data.md @@ -22,6 +22,7 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 | 已注册的数据文件 —— 路径、视图名、类型 | 应用数据文件 | | 查询历史,包括失败记录 | 应用数据文件 | | 界面语言 | 应用数据文件 | +| 界面大小 | 应用数据文件 | | 打开的应用与 dashboard 标签页及其标题 | 应用数据文件 | | 最近打开的应用与 dashboard(侧栏的 **应用** / **仪表盘**) | 应用数据文件 | | 你选择信任的应用文件夹 | 应用数据文件 | @@ -49,7 +50,22 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 ## 主题 -太阳/月亮按钮在浅色与深色主题之间切换。没有主题选择器,也没有可编辑的主题文件 —— 你得到的就是这两种模式。基础字号为 14px。 +太阳/月亮按钮在浅色与深色主题之间切换。没有主题选择器,也没有可编辑的主题文件 —— 你得到的就是这两种模式。 + +## 界面大小 {#interface-size} + +文字、图标和控件一起缩放,共四档: + +| 大小 | 界面文字 | 编辑器文字 | +| --- | --- | --- | +| 小 | 13px | 12px | +| 默认 | 14px | 13px | +| 大 | 16px | 15px | +| 特大 | 18px | 17px | + +在标题栏的 **Aa** 按钮里选择,或用 **⌘+**(放大)、**⌘−**(缩小)、**⌘0**(复原)逐档切换——无论焦点在哪里都有效,包括 SQL 编辑器。选择会被记住,切换明暗主题后也保持不变。 + +你拖动过的面板尺寸,以及 dashboard 图表的高度,仍以像素计:它们由你调整,不随界面缩放。 ## 重置 diff --git a/docs/zh/sql-editor.md b/docs/zh/sql-editor.md index e2d7330..51afa2c 100644 --- a/docs/zh/sql-editor.md +++ b/docs/zh/sql-editor.md @@ -49,11 +49,13 @@ Tab 只存在于内存中。其中的 SQL 不会被保存,因此退出后会 ## 键盘快捷键 -⌘↵ 是 DuckLocal 自己定义的唯一快捷键。其余快捷键都来自编辑器和表格组件,行为与任何 macOS 文本框中一致: +DuckLocal 自己定义了少数几个快捷键,其余都来自编辑器和表格组件,行为与任何 macOS 文本框中一致: | 快捷键 | 操作 | | --- | --- | | ⌘↵ | 运行查询 | +| ⌘S | 在 dashboard 的源码视图中保存 | +| ⌘+ / ⌘− / ⌘0 | 放大 / 缩小 / 复原界面大小——见[界面大小](settings-and-data.md#interface-size) | | ⌘F / ⇧⌘F | 在编辑器中查找 / 替换 | | ⌘Z / ⇧⌘Z | 撤销 / 重做 | | ⌘A | 全选 | diff --git a/docs/zh/troubleshooting.md b/docs/zh/troubleshooting.md index 2beb71a..99a2232 100644 --- a/docs/zh/troubleshooting.md +++ b/docs/zh/troubleshooting.md @@ -60,6 +60,7 @@ | 症状 | 原因与解决 | | --- | --- | | 每次启动主题都回到浅色 | 主题不会被保存;语言会 | +| 文字或图标太小 / 太大 | 用标题栏的 **Aa** 按钮,或 ⌘+ / ⌘− / ⌘0。见[界面大小](settings-and-data.md#interface-size) | | 界面语言不对 | 用标题栏的 `EN` / `中` 按钮切换,选择会被记住 | ## 从头开始 diff --git a/src/i18n.rs b/src/i18n.rs index 45e7d60..8730c11 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -215,6 +215,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", diff --git a/src/main.rs b/src/main.rs index 864afc5..511ce25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,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 @@ -61,11 +62,9 @@ fn main() { 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/ui/mod.rs b/src/ui/mod.rs index a8b2884..6259f75 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,21 +3,21 @@ pub mod chart; pub mod completion; pub mod results; +pub mod scale; pub mod sidebar; pub mod status_bar; pub mod title_bar; pub mod workspace; use gpui_kit::component::notification::Notification; -use gpui_kit::component::Theme; use gpui_kit::component::WindowExt; -use gpui_kit::{px, App, Entity, KeyBinding, PathPromptOptions, Window}; +use gpui_kit::{App, Entity, KeyBinding, PathPromptOptions, Window}; use crate::i18n::trf; use crate::sources::MAX_FILES; use crate::state::{self, AppState, AttachOutcome, OpenOutcome, RequestReport}; -gpui_kit::actions!(ducklocal, [RunQuery, SaveSpec]); +gpui_kit::actions!(ducklocal, [RunQuery, SaveSpec, ZoomIn, ZoomOut, ZoomReset]); /// Key context that makes ⌘↵ reachable while the SQL editor is focused. pub const WORKSPACE_KEY_CONTEXT: &str = "DuckLocal"; @@ -26,13 +26,21 @@ pub const RUN_QUERY_KEYSTROKE: &str = "cmd-enter"; pub const SAVE_SPEC_KEYSTROKE: &str = "cmd-s"; pub fn init(cx: &mut App) { - // Denser desktop density: 14px rem base (gpui-component ships 16). - // Re-applied after every Theme::change (see main.rs, title_bar.rs). - Theme::global_mut(cx).font_size = px(14.); cx.bind_keys([ KeyBinding::new(RUN_QUERY_KEYSTROKE, RunQuery, Some(WORKSPACE_KEY_CONTEXT)), KeyBinding::new(SAVE_SPEC_KEYSTROKE, SaveSpec, Some(WORKSPACE_KEY_CONTEXT)), + // Interface size, with the keys every Mac app uses for it. Bound with + // no context so they work wherever focus is, the editor included; + // `cmd-+` is what a keyboard without a separate plus key sends as + // shift-equals. + KeyBinding::new("cmd-=", ZoomIn, None), + KeyBinding::new("cmd-+", ZoomIn, None), + KeyBinding::new("cmd--", ZoomOut, None), + KeyBinding::new("cmd-0", ZoomReset, None), ]); + cx.on_action(|_: &ZoomIn, cx| scale::set(scale::current().larger(), cx)); + cx.on_action(|_: &ZoomOut, cx| scale::set(scale::current().smaller(), cx)); + cx.on_action(|_: &ZoomReset, cx| scale::set(scale::UiSize::Default, cx)); } /// Run an "open these paths" request off the UI thread and apply the result. diff --git a/src/ui/results.rs b/src/ui/results.rs index 05f0e1f..7eb500a 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -450,7 +450,7 @@ impl ResultsPanel { .unwrap_or(false); dialog .title(trf("dialog.export.title", &[format_label])) - .w(px(440.)) + .w(crate::ui::scale::design(440.)) .close_button(!exporting) .overlay_closable(!exporting) .on_cancel({ @@ -782,7 +782,7 @@ impl ResultsPanel { .border_color(cx.theme().border) .child( Input::new(&self.filter_input) - .w(px(200.)) + .w(crate::ui::scale::design(200.)) .cleanable(true) .small(), ) diff --git a/src/ui/scale.rs b/src/ui/scale.rs new file mode 100644 index 0000000..1e551ed --- /dev/null +++ b/src/ui/scale.rs @@ -0,0 +1,175 @@ +//! Interface size: one setting that scales text, icons and controls together. +//! +//! The component library sizes almost everything in rems — spacing, icons, +//! button heights, text — and `Root` sets the window's rem to the theme's +//! `font_size` on every frame. So the whole interface scales from one number, +//! and this module owns it: a few named steps rather than a free slider, +//! because a 15.3px base only buys blurry text. +//! +//! `Theme::change` rebuilds the theme from stock defaults, which resets the +//! base to the library's 16px; every call site that changes theme follows it +//! with [`apply`]. The choice is persisted like the language — a blocking write +//! to the history store, fine from a click or a key press. + +use std::sync::RwLock; + +use gpui_kit::component::Theme; +use gpui_kit::*; + +/// A length designed in pixels at the default size, scaled to the current one, +/// for the few containers sized in raw pixels — a dialog's width, the status +/// bar's height — that would otherwise squeeze larger text or leave smaller +/// text adrift. Pixels rather than rems because a dialog's width takes pixels; +/// every render reads the current size, so a change reaches it on the next +/// frame like everything else. +pub fn design(pixels: f32) -> Pixels { + px(pixels * f32::from(current().font_size()) / f32::from(UiSize::Default.font_size())) +} + +/// The `settings` key the choice lives under. +const SETTING: &str = "ui_size"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UiSize { + Small, + Default, + Large, + ExtraLarge, +} + +impl UiSize { + pub const ALL: [UiSize; 4] = [ + UiSize::Small, + UiSize::Default, + UiSize::Large, + UiSize::ExtraLarge, + ]; + + fn code(self) -> &'static str { + match self { + UiSize::Small => "small", + UiSize::Default => "default", + UiSize::Large => "large", + UiSize::ExtraLarge => "xlarge", + } + } + + /// An unknown stored value is the default size rather than an error: the + /// setting is a preference, not data. + fn from_code(code: &str) -> UiSize { + UiSize::ALL + .into_iter() + .find(|size| size.code() == code) + .unwrap_or(UiSize::Default) + } + + /// The rem base: 14px is the app's long-standing density, a notch under + /// the library's 16px. + fn font_size(self) -> Pixels { + px(match self { + UiSize::Small => 13., + UiSize::Default => 14., + UiSize::Large => 16., + UiSize::ExtraLarge => 18., + }) + } + + /// The editor's monospace size, kept one pixel under the UI text as the + /// library's own defaults (16 / 13) keep it a little under. + fn mono_font_size(self) -> Pixels { + self.font_size() - px(1.) + } + + pub fn label_key(self) -> &'static str { + match self { + UiSize::Small => "ui_size.small", + UiSize::Default => "ui_size.default", + UiSize::Large => "ui_size.large", + UiSize::ExtraLarge => "ui_size.xlarge", + } + } + + /// One step larger, or the same when already the largest. + pub fn larger(self) -> UiSize { + let ix = UiSize::ALL.iter().position(|size| *size == self).unwrap_or(1); + UiSize::ALL[(ix + 1).min(UiSize::ALL.len() - 1)] + } + + /// One step smaller, or the same when already the smallest. + pub fn smaller(self) -> UiSize { + let ix = UiSize::ALL.iter().position(|size| *size == self).unwrap_or(1); + UiSize::ALL[ix.saturating_sub(1)] + } +} + +static CURRENT: RwLock = RwLock::new(UiSize::Default); + +pub fn current() -> UiSize { + *CURRENT.read().unwrap_or_else(|e| e.into_inner()) +} + +/// The size to start with: the stored choice when the history store is +/// reachable, the default otherwise. Never writes. +pub fn load() { + if let Ok(Some(code)) = crate::history::get_setting(SETTING) { + *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = UiSize::from_code(&code); + } +} + +/// Put the current size into the theme. Call after anything that rebuilt the +/// theme (`Theme::change`), and once at startup. +pub fn apply(cx: &mut App) { + let size = current(); + let theme = Theme::global_mut(cx); + theme.font_size = size.font_size(); + theme.mono_font_size = size.mono_font_size(); + Theme::sync_base(cx); +} + +/// Switch to `size`, remember it, and repaint every window in it. +pub fn set(size: UiSize, cx: &mut App) { + if size == current() { + return; + } + *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = size; + if let Err(e) = crate::history::set_setting(SETTING, size.code()) { + tracing::warn!("Failed to persist the interface size: {e}"); + } + apply(cx); + cx.refresh_windows(); +} + +#[cfg(test)] +mod tests { + use super::UiSize; + + #[test] + fn steps_stop_at_both_ends() { + assert_eq!(UiSize::Small.smaller(), UiSize::Small); + assert_eq!(UiSize::Small.larger(), UiSize::Default); + assert_eq!(UiSize::Default.larger(), UiSize::Large); + assert_eq!(UiSize::ExtraLarge.larger(), UiSize::ExtraLarge); + assert_eq!(UiSize::ExtraLarge.smaller(), UiSize::Large); + } + + #[test] + fn a_stored_value_round_trips_and_an_unknown_one_is_the_default() { + for size in UiSize::ALL { + assert_eq!(UiSize::from_code(size.code()), size); + } + assert_eq!(UiSize::from_code("enormous"), UiSize::Default); + } + + #[test] + fn a_design_length_is_unchanged_at_the_default_size() { + // `current()` is the default unless something set it; no test does. + assert_eq!(f32::from(super::design(440.)), 440.); + } + + #[test] + fn the_default_keeps_the_long_standing_density() { + assert_eq!(f32::from(UiSize::Default.font_size()), 14.); + assert!(UiSize::Small.font_size() < UiSize::Default.font_size()); + assert!(UiSize::ExtraLarge.mono_font_size() < UiSize::ExtraLarge.font_size()); + } +} diff --git a/src/ui/sidebar/actions.rs b/src/ui/sidebar/actions.rs index 4583f04..056db85 100644 --- a/src/ui/sidebar/actions.rs +++ b/src/ui/sidebar/actions.rs @@ -122,7 +122,7 @@ impl Sidebar { window.open_dialog(cx, move |dialog, _, _| { dialog .title(trf("dialog.alter_type.title", &[&column.name])) - .w(px(360.)) + .w(crate::ui::scale::design(360.)) .child( v_flex() .gap_2() diff --git a/src/ui/sidebar/mod.rs b/src/ui/sidebar/mod.rs index d4dda0b..0f7f298 100644 --- a/src/ui/sidebar/mod.rs +++ b/src/ui/sidebar/mod.rs @@ -301,7 +301,8 @@ impl Sidebar { .xsmall() .text_color(cx.theme().muted_foreground) .into_any_element(), - None => div().w(px(12.)).flex_shrink_0().into_any_element(), + // The width of an xsmall icon, so rows line up at every size. + None => div().w_3().flex_shrink_0().into_any_element(), }) .child( div() @@ -392,7 +393,7 @@ impl Sidebar { .child( h_flex() .id(("row-actions", ix)) - .w(px(44.)) + .w(crate::ui::scale::design(44.)) .flex_shrink_0() .justify_end() .gap_1() diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index 965f0c3..387fd54 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -43,7 +43,7 @@ impl Render for StatusBarView { let mono = cx.theme().mono_font_family.clone(); - let mut bar = StatusBar::new().h(px(32.)).left(if opening { + let mut bar = StatusBar::new().h(crate::ui::scale::design(32.)).left(if opening { h_flex() .gap_1p5() .items_center() diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 36c2fbe..2027542 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -4,6 +4,7 @@ use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::dialog::DialogFooter; use gpui_kit::component::input::{Input, InputContentType, InputState}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::notification::Notification; use gpui_kit::component::{ h_flex, v_flex, ActiveTheme, Disableable, IconName, Sizable, Theme, ThemeMode, TitleBar, @@ -15,7 +16,8 @@ use gpui_kit::*; use crate::i18n::{tr, trf, Language}; use crate::state::AppState; -const DIALOG_WIDTH: Pixels = px(440.); +/// Dialog width at the default interface size; see [`crate::ui::scale::design`]. +const DIALOG_WIDTH: f32 = 440.; pub struct TitleBarView { state: Entity, @@ -56,10 +58,8 @@ impl TitleBarView { ThemeMode::Dark }; Theme::change(next, Some(window), cx); - // Theme::change resets font_size to the stock 16; pin our 14px base - // again (see main.rs). - Theme::global_mut(cx).font_size = px(14.); - Theme::sync_base(cx); + // Theme::change resets the base size to stock; put the user's back. + crate::ui::scale::apply(cx); } fn toggle_language(_: &ClickEvent, _: &mut Window, cx: &mut App) { @@ -97,7 +97,7 @@ impl TitleBarView { let secret = secret.clone(); dialog .title(tr("dialog.s3.title")) - .w(DIALOG_WIDTH) + .w(crate::ui::scale::design(DIALOG_WIDTH)) .child( v_flex() .gap_3() @@ -240,7 +240,7 @@ impl TitleBarView { let open_state = state.clone(); dialog .title(tr("dialog.open_source.title")) - .w(DIALOG_WIDTH) + .w(crate::ui::scale::design(DIALOG_WIDTH)) .child( v_flex() .gap_3() @@ -412,6 +412,28 @@ impl Render for TitleBarView { .tooltip(tr("title_bar.toggle_language")) .on_click(Self::toggle_language), ) + .child( + Button::new("ui-size") + .ghost() + .xsmall() + .icon(IconName::ALargeSmall) + .tooltip(tr("title_bar.ui_size")) + .dropdown_menu(|menu, _, _| { + let current = crate::ui::scale::current(); + crate::ui::scale::UiSize::ALL.into_iter().fold( + menu, + |menu, size| { + menu.item( + PopupMenuItem::new(tr(size.label_key())) + .checked(size == current) + .on_click(move |_, _, cx| { + crate::ui::scale::set(size, cx) + }), + ) + }, + ) + }), + ) .child( Button::new("toggle-theme") .ghost() diff --git a/src/ui/workspace.rs b/src/ui/workspace.rs index ec523c2..11ff90f 100644 --- a/src/ui/workspace.rs +++ b/src/ui/workspace.rs @@ -46,7 +46,7 @@ const RESULTS_PANEL_MAX: f32 = 640.; /// Line length the first-run description wraps at. Wide enough for the /// sentence to read as one thought, narrow enough that the eye does not have /// to travel the whole window. -const FIRST_RUN_TEXT_WIDTH: Pixels = px(400.); +const FIRST_RUN_TEXT_WIDTH: f32 = 400.; pub struct QueryTab { pub id: u64, @@ -598,7 +598,7 @@ impl Workspace { window.open_dialog(cx, move |dialog, _, _| { dialog .title(tr("dashboard.close_unsaved.title")) - .w(px(420.)) + .w(crate::ui::scale::design(420.)) .child( div() .text_sm() @@ -713,7 +713,7 @@ impl Workspace { window.open_dialog(cx, move |dialog, _, _| { dialog .title(tr("dialog.rename.title")) - .w(px(360.)) + .w(crate::ui::scale::design(360.)) .child(Input::new(&input)) .footer( DialogFooter::new() @@ -1380,7 +1380,7 @@ impl Workspace { div() .text_sm() .text_color(cx.theme().muted_foreground) - .max_w(FIRST_RUN_TEXT_WIDTH) + .max_w(crate::ui::scale::design(FIRST_RUN_TEXT_WIDTH)) .text_center() .child(tr("workspace.empty.description")), ) From 2a9ef09dc7ae93dcacf06458240c54ccbde193c1 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 22:30:42 +0800 Subject: [PATCH 4/8] Keep a tab's glyph with its own label, and tell same-named tabs apart The app and dashboard glyphs went through the tab's `prefix`, which sits outside its padding: flush against the previous tab's close button and a gap away from its own label, so it read as the wrong tab's. The glyph now shares the padded content with the label. Two `dashboard.dash` files from different folders both showed as `dashboard`; a title more than one tab carries now shows its folder beside it, muted. The three per-kind tab renderers are one. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/workspace.rs | 198 ++++++++++++++++++++++++++++---------------- 1 file changed, 125 insertions(+), 73 deletions(-) diff --git a/src/ui/workspace.rs b/src/ui/workspace.rs index 11ff90f..6804d06 100644 --- a/src/ui/workspace.rs +++ b/src/ui/workspace.rs @@ -10,7 +10,7 @@ //! behind the strip, which is why every path that reaches for "the active //! editor" goes through `as_query` rather than assuming one. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use gpui_kit::component::button::{Button, ButtonVariants}; @@ -134,6 +134,32 @@ fn editor_target(kinds: &[TabKind], active: usize) -> EditorTarget { } } +/// The titles that more than one tab carries. Two `dashboard.dash` files from +/// different folders both default to `dashboard`, and a strip of identical +/// labels gives no way to tell which tab is which. +fn duplicate_titles<'a>(titles: impl IntoIterator) -> Vec<&'a str> { + let mut seen = Vec::new(); + let mut duplicates = Vec::new(); + for title in titles { + if seen.contains(&title) { + if !duplicates.contains(&title) { + duplicates.push(title); + } + } else { + seen.push(title); + } + } + duplicates +} + +/// The name of the folder `path` sits in: what tells two same-titled +/// documents apart. +fn parent_name(path: &Path) -> Option { + path.parent()? + .file_name() + .map(|name| name.to_string_lossy().to_string()) +} + impl WorkspaceTab { pub fn id(&self) -> u64 { match self { @@ -151,6 +177,17 @@ impl WorkspaceTab { } } + /// What to show after a title another tab shares: the folder a document + /// came from. A query tab is only ever named by the user, so it has none. + fn disambiguator(&self) -> Option { + match self { + WorkspaceTab::Query(_) => None, + // An app's title is its folder, so the folder above says where. + WorkspaceTab::App(tab) => parent_name(&tab.directory), + WorkspaceTab::Dashboard(tab) => parent_name(&tab.path), + } + } + /// The tab as a query, which is the only kind whose editor takes SQL and /// has a toolbar of SQL commands and a result set to run into. pub fn as_query(&self) -> Option<&QueryTab> { @@ -914,86 +951,82 @@ impl Workspace { fn render_tab_bar(&mut self, cx: &mut Context) -> impl IntoElement { let closable = self.tabs.len() > 1; + let duplicates = duplicate_titles(self.tabs.iter().map(|tab| tab.title().as_ref())); TabBar::new("query-tabs") .small() .selected_index(self.active) .on_click(cx.listener(|this, ix, window, cx| { this.activate(*ix, window, cx); })) - .children(self.tabs.iter().map(|tab| match tab { - WorkspaceTab::Query(tab) => { - let tab_id = tab.id; - Tab::new().label(tab.title.clone()).when(closable, |this| { + .children(self.tabs.iter().map(|tab| { + let tab_id = tab.id(); + let title = tab.title().clone(); + // Same-titled tabs say which folder they came from, muted, + // so the label still reads as the title first. + let hint = duplicates + .contains(&title.as_ref()) + .then(|| tab.disambiguator()) + .flatten(); + // The glyph and the label share the tab's padded content. + // `prefix` would sit outside that padding — flush against the + // previous tab's close button, and a gap away from its own + // label — so the glyph reads as the wrong tab's. + let glyph = match tab { + WorkspaceTab::Query(_) => None, + WorkspaceTab::App(_) => Some(IconName::ChartPie), + WorkspaceTab::Dashboard(_) => Some(IconName::LayoutDashboard), + }; + // A dashboard shows a dot while its source buffer holds edits + // the file does not. + let dirty = tab + .as_dashboard() + .is_some_and(|dashboard| dashboard.host.read(cx).is_dirty(cx)); + Tab::new() + .aria_label(title.clone()) + .child( + h_flex() + .gap_1p5() + .items_center() + .when_some(glyph, |this, glyph| { + this.child(Icon::new(glyph).small()) + }) + .child(title) + .when_some(hint, |this, hint| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(hint), + ) + }), + ) + .when(closable || dirty, |this| { this.suffix( - Button::new(("close-tab", tab_id as usize)) - .ghost() - .xsmall() - .icon(IconName::Close) - .on_click(cx.listener(move |this, _, window, cx| { - this.close_tab(tab_id, window, cx); - })), + h_flex() + .gap_1() + .pr_1() + .items_center() + .when(dirty, |this| { + this.child( + div() + .size_1p5() + .rounded_full() + .bg(cx.theme().warning), + ) + }) + .when(closable, |this| { + this.child( + Button::new(("close-tab", tab_id as usize)) + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click(cx.listener(move |this, _, window, cx| { + this.close_tab(tab_id, window, cx); + })), + ) + }), ) }) - } - // An app tab reads differently at a glance: a leading glyph - // marks it out, and the folder it came from is its label. The - // glyph goes through `prefix`, not `icon`: `icon` sizes the tab - // as a square around the glyph alone and drops the label, which - // is why an app used to read as a bare pie. - WorkspaceTab::App(tab) => { - let tab_id = tab.id; - Tab::new() - .prefix(Icon::new(IconName::ChartPie)) - .label(tab.title.clone()) - .when(closable, |this| { - this.suffix( - Button::new(("close-app-tab", tab_id as usize)) - .ghost() - .xsmall() - .icon(IconName::Close) - .on_click(cx.listener(move |this, _, window, cx| { - this.close_tab(tab_id, window, cx); - })), - ) - }) - } - // A dashboard reads as the app's sibling: its own glyph, the - // spec file's name as the label, and a dot when the source - // buffer holds edits the file does not. - WorkspaceTab::Dashboard(tab) => { - let tab_id = tab.id; - let dirty = tab.host.read(cx).is_dirty(cx); - Tab::new() - .prefix(Icon::new(IconName::LayoutDashboard)) - .label(tab.title.clone()) - .when(closable || dirty, |this| { - this.suffix( - h_flex() - .gap_1() - .items_center() - .when(dirty, |this| { - this.child( - div() - .w(px(6.)) - .h(px(6.)) - .rounded_full() - .bg(cx.theme().warning), - ) - }) - .when(closable, |this| { - this.child( - Button::new(("close-dashboard-tab", tab_id as usize)) - .ghost() - .xsmall() - .icon(IconName::Close) - .on_click(cx.listener(move |this, _, window, cx| { - this.close_tab(tab_id, window, cx); - })), - ) - }), - ) - }) - } })) .suffix({ let view = cx.entity().downgrade(); @@ -1447,12 +1480,31 @@ impl Workspace { mod tests { // Deliberately not `use super::*`: that pulls in `gpui_kit::*`, whose // `test` macro shadows the built-in `#[test]`. - use super::{active_after_close, editor_target, EditorTarget, TabKind}; + use super::{ + active_after_close, duplicate_titles, editor_target, parent_name, EditorTarget, TabKind, + }; + use std::path::Path; /// Three tabs with an app in the middle, the arrangement the branch /// mistakes would show up in. const MIXED: [TabKind; 3] = [TabKind::Query, TabKind::App, TabKind::Query]; + #[test] + fn only_titles_carried_twice_are_duplicates() { + let titles = ["Query 1", "dashboard", "sales", "dashboard", "dashboard"]; + assert_eq!(duplicate_titles(titles), vec!["dashboard"]); + assert!(duplicate_titles(["Query 1", "Query 2"]).is_empty()); + } + + #[test] + fn a_document_is_told_apart_by_its_folder() { + assert_eq!( + parent_name(Path::new("/x/examples/usage_panel/dashboard.dash")).as_deref(), + Some("usage_panel") + ); + assert_eq!(parent_name(Path::new("/")), None); + } + #[test] fn closing_the_active_tab_moves_to_the_one_before_it() { // The app in the middle closes: the tab that takes its place is the From 08de9dd5a3ca5dcc77d0d283cc90e6bb5e2a4dd1 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 22:30:50 +0800 Subject: [PATCH 5/8] Highlight .dash source, and link the SQL grammar the editors assumed The dashboard's source editor now colours block types, names, attributes, plot types, references, comments and heredoc markers through the editor's parser-independent highlighter seam; a scanner that follows the lexer's rules but never fails keeps half-typed text coloured. Heredoc bodies go to the tree-sitter SQL highlighter, so a query reads as it does in the SQL editor. That editor had no highlighting at all: the build linked only the json and rust grammars, so `.language("sql")` coloured nothing. The toolkit's `tree-sitter-sql` feature adds exactly that grammar. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 11 + Cargo.toml | 4 +- docs/analysis-app.md | 2 +- docs/zh/analysis-app.md | 2 +- src/spec/highlight.rs | 451 ++++++++++++++++++++++++++++++++++++++++ src/spec/mod.rs | 2 + src/spec/view.rs | 5 + 7 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 src/spec/highlight.rs 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/docs/analysis-app.md b/docs/analysis-app.md index c24feb2..d080ec5 100644 --- a/docs/analysis-app.md +++ b/docs/analysis-app.md @@ -256,7 +256,7 @@ A `.dash` file is a dashboard declared as data — query and plot blocks, no Jav 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. -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/zh/analysis-app.md b/docs/zh/analysis-app.md index fa12a21..60e8883 100644 --- a/docs/zh/analysis-app.md +++ b/docs/zh/analysis-app.md @@ -226,7 +226,7 @@ npx --yes -p typescript tsc -p <应用目录>/jsconfig.json --noImplicitAny fals 打开方式与应用相同:在命令行指定(`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/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!["< Date: Thu, 24 Sep 2026 00:01:12 +0800 Subject: [PATCH 6/8] Make the chrome consistent: one button style, one glyph per tab kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toolbar buttons are all bordered, small, glyph and label; Run alone is primary. Rename, which was borderless and bare, is one shared button. Format, EXPLAIN, Export, View source and Back each get a glyph that says what they do ("Back to dashboard" wore a file) - every tab kind has one glyph — query, app, dashboard — worn on the tab, in the `+` menu and in its toolbar; apps no longer wear a pie on the tab and a window in the sidebar - the `+` menu opens a dashboard: "Open dashboard…" picks a .dash file, which was only reachable by drag or command line - the chrome names a database only when it is a file: ":memory:" is gone from the title and status bars, and the toolbar's threads / memory-limit line with the settings reads that fed it Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/analysis-app.md | 2 +- docs/zh/analysis-app.md | 2 +- src/assets.rs | 19 ++++- src/db.rs | 31 ++------ src/i18n.rs | 10 ++- src/ui/results.rs | 2 + src/ui/status_bar.rs | 9 ++- src/ui/title_bar.rs | 2 +- src/ui/workspace.rs | 162 +++++++++++++++++++++++++--------------- 9 files changed, 144 insertions(+), 95 deletions(-) diff --git a/docs/analysis-app.md b/docs/analysis-app.md index d080ec5..b23bfdf 100644 --- a/docs/analysis-app.md +++ b/docs/analysis-app.md @@ -254,7 +254,7 @@ 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 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)). diff --git a/docs/zh/analysis-app.md b/docs/zh/analysis-app.md index 60e8883..f44c959 100644 --- a/docs/zh/analysis-app.md +++ b/docs/zh/analysis-app.md @@ -224,7 +224,7 @@ npx --yes -p typescript tsc -p <应用目录>/jsconfig.json --noImplicitAny fals `.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 会像应用一样被记住,下次启动自动恢复。 标签页同时也是编辑器:源码视图可以直接修改文件,带语法高亮(heredoc 里的 SQL 与 SQL 编辑器同样着色)、补全和诊断,用保存按钮或 ⌘S 写回——保存后的规格若不再通过校验,仍在工作的 dashboard 不会被替换,并会显示原因。在 GUI 之外,`ducklocal lsp` 把同样的补全、诊断、悬停和跳转定义提供给任何支持 LSP 的编辑器(见 [CLI 指南](cli.md#用-lsp-编辑-dashboard-规格文件))。 diff --git a/src/assets.rs b/src/assets.rs index f9f0e0c..42b5531 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -16,7 +16,10 @@ use std::borrow::Cow; use gpui_kit::assets::{icon_assets, Assets}; use gpui_kit::{AssetSource, Result, SharedString}; -icon_assets!(ExtraIcons, [Save, AppWindow]); +icon_assets!( + ExtraIcons, + [Save, AppWindow, WandSparkles, ListTree, Pencil, Download, Code] +); /// The default component bundle, plus [`ExtraIcons`]. pub struct AppAssets; @@ -42,7 +45,8 @@ mod tests { use gpui_kit::assets::IconName; use gpui_kit::AssetSource; - /// Every icon named in the source, found by scanning for `IconName::`. + /// 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 { @@ -53,7 +57,12 @@ mod tests { walk(&path, found); } else if path.extension().is_some_and(|ext| ext == "rs") { let text = std::fs::read_to_string(&path).unwrap(); - for piece in text.split("IconName::").skip(1) { + // `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()) @@ -76,7 +85,9 @@ mod tests { #[test] fn every_icon_the_app_draws_is_served() { let names = icons_in_source(); - assert!(names.contains(&"Save".to_string()), "{names:?}"); + for expected in ["Save", "Pencil", "SquareTerminal"] { + assert!(names.contains(&expected.to_string()), "{names:?}"); + } for name in names { let icon = IconName::ALL .iter() diff --git a/src/db.rs b/src/db.rs index e4b0ec5..3595d83 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> { @@ -181,32 +183,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 { diff --git a/src/i18n.rs b/src/i18n.rs index 8730c11..f091e2f 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -295,8 +295,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"), @@ -510,6 +509,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/ui/results.rs b/src/ui/results.rs index 7eb500a..dd4dfba 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -625,6 +625,7 @@ impl ResultsPanel { Button::new("export-csv") .outline() .xsmall() + .icon(gpui_kit::assets::IconName::Download) .label(tr("results.export_csv")) .disabled(!has_rows) .on_click(cx.listener(|this, _, window, cx| { @@ -635,6 +636,7 @@ impl ResultsPanel { Button::new("export-parquet") .outline() .xsmall() + .icon(gpui_kit::assets::IconName::Download) .label(tr("results.export_parquet")) .disabled(!has_rows) .on_click(cx.listener(|this, _, window, cx| { diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index 387fd54..aa2ee1c 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -31,10 +31,11 @@ impl StatusBarView { impl Render for StatusBarView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (target_label, version, last, opening) = { + let (connected, target_label, version, last, opening) = { let state = self.state.read(cx); ( - state.target.as_ref().map(|t| t.display_label()), + state.target.is_some(), + state.target.as_ref().and_then(|t| t.file_label()), state.server.as_ref().map(|s| s.version.clone()), state.last_query.clone(), state.is_opening(), @@ -50,7 +51,7 @@ impl Render for StatusBarView { .child(Spinner::new().xsmall()) .child(tr("status_bar.opening")) .into_any_element() - } else if let Some(label) = target_label { + } else if connected { h_flex() .gap_1p5() .items_center() @@ -60,7 +61,7 @@ impl Render for StatusBarView { .text_color(cx.theme().success), ) .child(tr("status_bar.connected")) - .child(label) + .children(target_label) .into_any_element() } else { h_flex() diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 2027542..8a07bff 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -338,7 +338,7 @@ impl Render for TitleBarView { let (target_label, opening) = { let state = self.state.read(cx); ( - state.target.as_ref().map(|t| t.display_label()), + state.target.as_ref().and_then(|t| t.file_label()), state.is_opening(), ) }; diff --git a/src/ui/workspace.rs b/src/ui/workspace.rs index 6804d06..a081d57 100644 --- a/src/ui/workspace.rs +++ b/src/ui/workspace.rs @@ -27,6 +27,7 @@ use gpui_kit::*; use gpui_shell::ShellRuntime; use crate::analysis::apps::{self, OpenApp}; +use gpui_kit::assets::IconName as AssetIcon; use crate::analysis::runtime; use crate::analysis::view::AnalysisHost; use crate::i18n::{tr, trf}; @@ -92,6 +93,18 @@ pub enum TabKind { Dashboard, } +impl TabKind { + /// The one glyph a kind of tab wears — on the tab, in the `+` menu, and + /// for apps in the sidebar too — so a kind reads the same everywhere. + pub fn glyph(self) -> AssetIcon { + match self { + TabKind::Query => AssetIcon::SquareTerminal, + TabKind::App => AssetIcon::AppWindow, + TabKind::Dashboard => AssetIcon::LayoutDashboard, + } + } +} + /// Where content aimed at "the active editor" lands. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum EditorTarget { @@ -588,6 +601,37 @@ impl Workspace { .detach(); } + /// The `+` menu's "Open dashboard…": a `.dash` file, from the system + /// picker. Anything else is refused with a note, not opened as data. + fn pick_dashboard_file(&mut self, window: &mut Window, cx: &mut Context) { + let rx = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(tr("dashboard.picker.prompt").into()), + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = rx.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + this.update_in(cx, |this, window, cx| { + if crate::spec::tabs::is_spec(&path) { + this.open_dashboard(path, window, cx); + } else { + window.push_notification( + trf("dashboard.picker.not_a_spec", &[&path.to_string_lossy()]), + cx, + ); + } + }) + .ok(); + }) + .detach(); + } + fn close_tab(&mut self, tab_id: u64, window: &mut Window, cx: &mut Context) { if self.tabs.len() <= 1 { return; @@ -971,11 +1015,7 @@ impl Workspace { // `prefix` would sit outside that padding — flush against the // previous tab's close button, and a gap away from its own // label — so the glyph reads as the wrong tab's. - let glyph = match tab { - WorkspaceTab::Query(_) => None, - WorkspaceTab::App(_) => Some(IconName::ChartPie), - WorkspaceTab::Dashboard(_) => Some(IconName::LayoutDashboard), - }; + let glyph = tab.kind().glyph(); // A dashboard shows a dot while its source buffer holds edits // the file does not. let dirty = tab @@ -987,9 +1027,7 @@ impl Workspace { h_flex() .gap_1p5() .items_center() - .when_some(glyph, |this, glyph| { - this.child(Icon::new(glyph).small()) - }) + .child(Icon::new(glyph).small()) .child(title) .when_some(hint, |this, hint| { this.child( @@ -1038,23 +1076,38 @@ impl Workspace { .dropdown_menu(move |menu, _window, _cx| { let new_query = view.clone(); let open_app = view.clone(); - menu.item(PopupMenuItem::new(tr("workspace.new_query")).on_click( - move |_, window, cx| { - if let Some(view) = new_query.upgrade() { - view.update(cx, |this, cx| this.add_query_tab(window, cx)); - } - }, - )) + let open_dashboard = view.clone(); + // Each entry wears the glyph its tab will wear. + menu.item( + PopupMenuItem::new(tr("workspace.new_query")) + .icon(TabKind::Query.glyph()) + .on_click(move |_, window, cx| { + if let Some(view) = new_query.upgrade() { + view.update(cx, |this, cx| this.add_query_tab(window, cx)); + } + }), + ) .item( - PopupMenuItem::new(tr("workspace.open_panel")).on_click( - move |_, window, cx| { + PopupMenuItem::new(tr("workspace.open_panel")) + .icon(TabKind::App.glyph()) + .on_click(move |_, window, cx| { if let Some(view) = open_app.upgrade() { view.update(cx, |this, cx| { this.pick_app_directory(window, cx) }); } - }, - ), + }), + ) + .item( + PopupMenuItem::new(tr("workspace.open_dashboard")) + .icon(TabKind::Dashboard.glyph()) + .on_click(move |_, window, cx| { + if let Some(view) = open_dashboard.upgrade() { + view.update(cx, |this, cx| { + this.pick_dashboard_file(window, cx) + }); + } + }), ) }) }) @@ -1069,7 +1122,6 @@ impl Workspace { } fn render_query_toolbar(&mut self, cx: &mut Context) -> impl IntoElement { - let server = self.state.read(cx).server.clone(); let run_keystroke = Keystroke::parse(RUN_QUERY_KEYSTROKE).ok(); h_flex() @@ -1099,6 +1151,7 @@ impl Workspace { Button::new("format-sql") .outline() .small() + .icon(AssetIcon::WandSparkles) .label(tr("workspace.format")) .tooltip(tr("workspace.format.tooltip")) .on_click(cx.listener(Self::format_active)), @@ -1107,31 +1160,26 @@ impl Workspace { Button::new("explain-sql") .outline() .small() + .icon(AssetIcon::ListTree) .label("EXPLAIN") .loading(self.explaining) .tooltip(tr("workspace.explain.tooltip")) .on_click(cx.listener(Self::explain_active)), ) .child(div().flex_1()) - .child( - Button::new("rename-tab") - .ghost() - .small() - .label(tr("workspace.rename")) - .tooltip(tr("workspace.rename.tooltip")) - .on_click(cx.listener(Self::open_rename_dialog)), - ) - .when_some(server, |this, server| { - this.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(trf( - "workspace.server_info", - &[&server.threads, &server.memory_limit], - )), - ) - }) + .child(Self::rename_button("rename-tab", cx)) + } + + /// Every toolbar ends with the same Rename: bordered, with its glyph, + /// like the buttons beside it. + fn rename_button(id: &'static str, cx: &mut Context) -> Button { + Button::new(id) + .outline() + .small() + .icon(AssetIcon::Pencil) + .label(tr("workspace.rename")) + .tooltip(tr("workspace.rename.tooltip")) + .on_click(cx.listener(Self::open_rename_dialog)) } /// What an app tab's toolbar says instead of Run/Format/EXPLAIN: where the @@ -1158,7 +1206,7 @@ impl Workspace { .border_b_1() .border_color(cx.theme().border) .child( - Icon::new(IconName::ChartPie) + Icon::new(AssetIcon::AppWindow) .small() .text_color(cx.theme().muted_foreground), ) @@ -1175,7 +1223,13 @@ impl Workspace { Button::new("app-definition") .outline() .small() - .icon(IconName::File) + // The glyph of where the button goes: the source, or back + // to the app. + .icon(if showing_definition { + AssetIcon::AppWindow + } else { + AssetIcon::Code + }) .label(if showing_definition { tr("analysis.definition.back") } else { @@ -1201,14 +1255,7 @@ impl Workspace { })) }), ) - .child( - Button::new("app-rename") - .ghost() - .small() - .label(tr("workspace.rename")) - .tooltip(tr("workspace.rename.tooltip")) - .on_click(cx.listener(Self::open_rename_dialog)), - ) + .child(Self::rename_button("app-rename", cx)) } /// What a dashboard tab's toolbar says instead: which spec this is, a @@ -1252,7 +1299,7 @@ impl Workspace { Button::new("dashboard-save") .outline() .small() - .icon(gpui_kit::assets::IconName::Save) + .icon(AssetIcon::Save) .label(tr("dashboard.save")) .tooltip(tr("dashboard.save.tooltip")) .disabled(!dirty) @@ -1271,7 +1318,11 @@ impl Workspace { Button::new("dashboard-source") .outline() .small() - .icon(IconName::File) + .icon(if showing_source { + AssetIcon::LayoutDashboard + } else { + AssetIcon::Code + }) .label(if showing_source { tr("dashboard.source.back") } else { @@ -1298,14 +1349,7 @@ impl Workspace { })) }), ) - .child( - Button::new("dashboard-rename") - .ghost() - .small() - .label(tr("workspace.rename")) - .tooltip(tr("workspace.rename.tooltip")) - .on_click(cx.listener(Self::open_rename_dialog)), - ) + .child(Self::rename_button("dashboard-rename", cx)) } } From 1aa579e2569660be589ea56072d7b89da13360a4 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Thu, 24 Sep 2026 00:01:24 +0800 Subject: [PATCH 7/8] =?UTF-8?q?Tidy=20the=20sidebar=20and=20let=20it=20be?= =?UTF-8?q?=20put=20away=20(=E2=8C=98B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh shares the tabs' row instead of standing on one of its own, next to a new collapse button; hidden, the sidebar leaves a button at the left of the title bar, ⌘B toggles it, and the state is remembered - Local files / Apps / Dashboards / S3 are headings with a disclosure arrow, no longer folders indistinguishable from a schema - a file row is its view name; the file name shows, muted, only when it differs, the folder when two files share a view name, the full path on hover — no more truncated "amount · amou…" - same-titled recent documents show their folder, as the tabs do - `~` replaces $HOME only at a path boundary: a home of /Users/al no longer turns /Users/alice/x into ~ice/x Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/schema-and-history.md | 9 ++-- docs/settings-and-data.md | 1 + docs/sql-editor.md | 1 + docs/zh/schema-and-history.md | 4 +- docs/zh/settings-and-data.md | 1 + docs/zh/sql-editor.md | 1 + src/app.rs | 37 ++++++++------ src/db.rs | 24 +++++++-- src/i18n.rs | 2 + src/state.rs | 33 +++++++++++++ src/ui/mod.rs | 8 ++- src/ui/sidebar/mod.rs | 91 +++++++++++++++++++++++++++-------- src/ui/sidebar/model.rs | 22 +++++++++ src/ui/sidebar/tree.rs | 82 +++++++++++++++++++++++++++---- src/ui/title_bar.rs | 25 +++++++++- src/ui/workspace.rs | 4 +- 16 files changed, 287 insertions(+), 58 deletions(-) diff --git a/docs/schema-and-history.md b/docs/schema-and-history.md index 740f51a..bd1d668 100644 --- a/docs/schema-and-history.md +++ b/docs/schema-and-history.md @@ -2,14 +2,17 @@ **[中文](zh/schema-and-history.md)** · [Docs](index.md) -The sidebar has two tabs: **Schema** and **History** (**表结构** and **查询历史** in Chinese). +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. diff --git a/docs/settings-and-data.md b/docs/settings-and-data.md index cbdc074..9cbbbf1 100644 --- a/docs/settings-and-data.md +++ b/docs/settings-and-data.md @@ -29,6 +29,7 @@ re-attached on launch. | 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 | diff --git a/docs/sql-editor.md b/docs/sql-editor.md index fbf2783..aaf5e23 100644 --- a/docs/sql-editor.md +++ b/docs/sql-editor.md @@ -78,6 +78,7 @@ field: | ⌘↵ | 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/zh/schema-and-history.md b/docs/zh/schema-and-history.md index 13e0cfc..357a7fc 100644 --- a/docs/zh/schema-and-history.md +++ b/docs/zh/schema-and-history.md @@ -2,13 +2,13 @@ **[English](../schema-and-history.md)** · [文档](index.md) -侧栏有两个 Tab:**表结构** 和 **查询历史**(英文界面中为 **Schema** 和 **History**)。 +侧栏有两个 Tab:**表结构** 和 **查询历史**(英文界面中为 **Schema** 和 **History**)。旁边的收起按钮(或 **⌘B**)可以收起侧栏;点标题栏最左侧的按钮,或再按一次 ⌘B,即可展开。收起状态会被记住。 ## Schema 树 树从上到下依次显示: -- **本地文件**——你已注册的数据文件,每个都带有视图名、文件名和行数。它的子节点是该视图的列。 +- **本地文件**——你已注册的数据文件,以视图名显示,并带有行数。只有文件名与视图名不同(如 `sales.csv` 对应 `sales_2`)时才在旁边显示文件名,两个文件视图名相同时显示所在文件夹;鼠标悬停可看到完整路径。它的子节点是该视图的列。 - **应用** / **仪表盘**——你最近打开过的 analysis app 和 `.dash` 仪表盘。点击即可像打开文件一样重新打开对应的 Tab;路径已不存在的条目会从列表中移除。 - **S3**——在[配置好 S3](s3.md) 之后出现。延迟加载。 - 每个数据库,然后是它的 schema,接着是表和视图,最后是列。 diff --git a/docs/zh/settings-and-data.md b/docs/zh/settings-and-data.md index dd3babb..084dc90 100644 --- a/docs/zh/settings-and-data.md +++ b/docs/zh/settings-and-data.md @@ -23,6 +23,7 @@ DuckLocal 在磁盘上只保留一个文件。其余一切都在会话期间存 | 查询历史,包括失败记录 | 应用数据文件 | | 界面语言 | 应用数据文件 | | 界面大小 | 应用数据文件 | +| 侧栏是否收起 | 应用数据文件 | | 打开的应用与 dashboard 标签页及其标题 | 应用数据文件 | | 最近打开的应用与 dashboard(侧栏的 **应用** / **仪表盘**) | 应用数据文件 | | 你选择信任的应用文件夹 | 应用数据文件 | diff --git a/docs/zh/sql-editor.md b/docs/zh/sql-editor.md index 51afa2c..e7ec6d3 100644 --- a/docs/zh/sql-editor.md +++ b/docs/zh/sql-editor.md @@ -56,6 +56,7 @@ DuckLocal 自己定义了少数几个快捷键,其余都来自编辑器和表 | ⌘↵ | 运行查询 | | ⌘S | 在 dashboard 的源码视图中保存 | | ⌘+ / ⌘− / ⌘0 | 放大 / 缩小 / 复原界面大小——见[界面大小](settings-and-data.md#interface-size) | +| ⌘B | 收起或展开侧栏 | | ⌘F / ⇧⌘F | 在编辑器中查找 / 替换 | | ⌘Z / ⇧⌘Z | 撤销 / 重做 | | ⌘A | 全选 | 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/db.rs b/src/db.rs index 3595d83..a3f6bc6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -66,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. @@ -429,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 f091e2f..3ebdd1b 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 后点击刷新。", diff --git a/src/state.rs b/src/state.rs index 3d924a1..249ef9f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -28,6 +28,11 @@ pub struct S3ConfigChanged; #[derive(Clone, Debug)] pub struct OpenStateChanged; +/// The sidebar was hidden or shown. The window re-lays itself out, and the +/// title bar offers the way back. +#[derive(Clone, Debug)] +pub struct SidebarToggled; + /// The recent-documents list gained an entry (an app or dashboard tab /// opened). The sidebar rebuilds its document groups on this. #[derive(Clone, Debug)] @@ -69,8 +74,13 @@ pub struct AppState { /// Set once startup's open request has landed. Until then the panels have /// no opinion: their emptiness is "not loaded yet", not "nothing here". is_ready: bool, + /// Whether the sidebar is hidden. Remembered between launches. + sidebar_collapsed: bool, } +/// The `settings` key the sidebar's collapsed state lives under. +const SIDEBAR_COLLAPSED: &str = "sidebar_collapsed"; + impl EventEmitter for AppState {} impl EventEmitter for AppState {} impl EventEmitter for AppState {} @@ -78,6 +88,7 @@ impl EventEmitter for AppState {} impl EventEmitter for AppState {} impl EventEmitter for AppState {} impl EventEmitter for AppState {} +impl EventEmitter for AppState {} impl AppState { pub fn new(_cx: &mut Context) -> Self { @@ -92,9 +103,31 @@ impl AppState { s3_config: None, open_requests: 0, is_ready: false, + // Read once, at construction: the history store is opened before + // the window, and a store that cannot be read means "shown". + sidebar_collapsed: crate::history::get_setting(SIDEBAR_COLLAPSED) + .ok() + .flatten() + .is_some_and(|value| value == "true"), } } + pub fn is_sidebar_collapsed(&self) -> bool { + self.sidebar_collapsed + } + + /// Hide the sidebar if it is shown, show it if it is hidden, and remember + /// which. + pub fn toggle_sidebar(&mut self, cx: &mut Context) { + self.sidebar_collapsed = !self.sidebar_collapsed; + let value = if self.sidebar_collapsed { "true" } else { "false" }; + if let Err(e) = crate::history::set_setting(SIDEBAR_COLLAPSED, value) { + tracing::warn!("Failed to persist the sidebar state: {e}"); + } + cx.emit(SidebarToggled); + cx.notify(); + } + /// Whether startup's open request has landed. A view that renders "there /// is nothing here" has to wait for it, or it shows that state while the /// data is still being attached. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6259f75..11db049 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,13 +17,17 @@ use crate::i18n::trf; use crate::sources::MAX_FILES; use crate::state::{self, AppState, AttachOutcome, OpenOutcome, RequestReport}; -gpui_kit::actions!(ducklocal, [RunQuery, SaveSpec, ZoomIn, ZoomOut, ZoomReset]); +gpui_kit::actions!( + ducklocal, + [RunQuery, SaveSpec, ZoomIn, ZoomOut, ZoomReset, ToggleSidebar] +); /// Key context that makes ⌘↵ reachable while the SQL editor is focused. pub const WORKSPACE_KEY_CONTEXT: &str = "DuckLocal"; pub const RUN_QUERY_KEYSTROKE: &str = "cmd-enter"; pub const SAVE_SPEC_KEYSTROKE: &str = "cmd-s"; +pub const TOGGLE_SIDEBAR_KEYSTROKE: &str = "cmd-b"; pub fn init(cx: &mut App) { cx.bind_keys([ @@ -37,6 +41,8 @@ pub fn init(cx: &mut App) { KeyBinding::new("cmd-+", ZoomIn, None), KeyBinding::new("cmd--", ZoomOut, None), KeyBinding::new("cmd-0", ZoomReset, None), + // Handled by the root view, which owns the layout the sidebar is in. + KeyBinding::new(TOGGLE_SIDEBAR_KEYSTROKE, ToggleSidebar, None), ]); cx.on_action(|_: &ZoomIn, cx| scale::set(scale::current().larger(), cx)); cx.on_action(|_: &ZoomOut, cx| scale::set(scale::current().smaller(), cx)); diff --git a/src/ui/sidebar/mod.rs b/src/ui/sidebar/mod.rs index 0f7f298..be7862b 100644 --- a/src/ui/sidebar/mod.rs +++ b/src/ui/sidebar/mod.rs @@ -250,10 +250,17 @@ impl Sidebar { let node_meta = meta.get(&item.id); let icon: Option = node_meta.and_then(|m| match m.kind { SchemaNodeKind::Database => Some(IconName::HardDrive.into()), - SchemaNodeKind::Schema - | SchemaNodeKind::LocalFilesGroup + // A section is a heading, not a folder: its glyph says it + // folds, nothing more. + SchemaNodeKind::LocalFilesGroup | SchemaNodeKind::AppsGroup - | SchemaNodeKind::DashboardsGroup => Some(if entry.is_expanded() { + | SchemaNodeKind::DashboardsGroup + | SchemaNodeKind::S3Status => Some(if entry.is_expanded() { + IconName::ChevronDown.into() + } else { + IconName::ChevronRight.into() + }), + SchemaNodeKind::Schema => Some(if entry.is_expanded() { IconName::FolderOpen.into() } else { IconName::Folder.into() @@ -265,7 +272,6 @@ impl Sidebar { SchemaNodeKind::Table => Some(IconName::GalleryVerticalEnd.into()), SchemaNodeKind::View => Some(IconName::Eye.into()), SchemaNodeKind::File | SchemaNodeKind::S3File => Some(IconName::File.into()), - SchemaNodeKind::S3Status => Some(IconName::Globe.into()), SchemaNodeKind::S3Bucket => Some(gpui_kit::assets::IconName::Inbox), SchemaNodeKind::S3Prefix => Some(if entry.is_expanded() { IconName::FolderOpen.into() @@ -276,9 +282,11 @@ impl Sidebar { }); let group_name = item.id.clone(); - let s3_tooltip = match node_meta.map(|m| m.kind) { - Some(SchemaNodeKind::S3Status) => s3_endpoint.clone(), - _ => None, + let is_section = node_meta.is_some_and(|m| m.kind.is_section()); + let hint = node_meta.and_then(|m| m.hint.clone()); + let tooltip: Option = match node_meta.map(|m| m.kind) { + Some(SchemaNodeKind::S3Status) => s3_endpoint.clone().map(Into::into), + _ => node_meta.and_then(|m| m.tooltip.clone()), }; let file = node_meta.and_then(|m| m.file.clone()); let table = node_meta.and_then(|m| m.table.clone()); @@ -305,16 +313,41 @@ impl Sidebar { None => div().w_3().flex_shrink_0().into_any_element(), }) .child( - div() + h_flex() .id(("row-label", ix)) .flex_1() .min_w_0() - .truncate() - .text_sm() - .child(item.label.clone()) - .when_some(s3_tooltip, |this, endpoint| { + .gap_1p5() + .items_baseline() + .map(|this| { + if is_section { + this.text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) + } else { + this.text_sm() + } + }) + .child( + div() + .min_w_0() + .truncate() + .child(item.label.clone()), + ) + .when_some(hint, |this, hint| { + this.child( + div() + .flex_shrink_0() + .max_w_1_2() + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(hint), + ) + }) + .when_some(tooltip, |this, tooltip| { this.tooltip(move |window, cx| { - Tooltip::new(endpoint.clone()).build(window, cx) + Tooltip::new(tooltip.clone()).build(window, cx) }) }) .when_some(column, |this, column| { @@ -518,11 +551,13 @@ impl Render for Sidebar { })) .child(Tab::new().label(tr("sidebar.tab.schema"))) .child(Tab::new().label(tr("sidebar.tab.history"))), - ), - ) - .when(self.tab == SidebarTab::Schema, |this| { - this.child( - h_flex().px_2().pb_1().justify_end().child( + ) + // The sidebar's own actions share the tabs' row, as borderless + // icon buttons: refresh while the schema is shown, and the + // button that puts the whole sidebar away. + .child(div().flex_1()) + .when(self.tab == SidebarTab::Schema, |this| { + this.child( Button::new("refresh-schema") .ghost() .xsmall() @@ -531,9 +566,23 @@ impl Render for Sidebar { .loading(self.refreshing_schema) .disabled(self.refreshing_schema) .on_click(cx.listener(Self::refresh_schema)), - ), - ) - }) + ) + }) + .child( + Button::new("collapse-sidebar") + .ghost() + .xsmall() + .icon(IconName::PanelLeftClose) + .tooltip_with_action( + tr("sidebar.collapse"), + &crate::ui::ToggleSidebar, + None, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.state.update(cx, |state, cx| state.toggle_sidebar(cx)); + })), + ), + ) .child(div().flex_1().min_h_0().child(match self.tab { SidebarTab::Schema => self.render_schema(window, cx), SidebarTab::History => { diff --git a/src/ui/sidebar/model.rs b/src/ui/sidebar/model.rs index 702a228..1d9faef 100644 --- a/src/ui/sidebar/model.rs +++ b/src/ui/sidebar/model.rs @@ -60,6 +60,11 @@ pub(super) struct SchemaNodeMeta { pub(super) s3_uri: Option, /// The document a recent-document row reopens on click. pub(super) doc: Option, + /// Muted text after the label: what tells a row apart from a same-named + /// one, or a file name that differs from its view's. + pub(super) hint: Option, + /// Shown on hover over the label: a file's or document's full path. + pub(super) tooltip: Option, } impl SchemaNodeMeta { @@ -72,6 +77,23 @@ impl SchemaNodeMeta { column: None, s3_uri: None, doc: None, + hint: None, + tooltip: None, } } } + +impl SchemaNodeKind { + /// The tree's top-level groupings, which are headings rather than things: + /// drawn as a small muted title with a disclosure arrow, so they never + /// read as a folder the way a schema does. + pub(super) fn is_section(self) -> bool { + matches!( + self, + SchemaNodeKind::LocalFilesGroup + | SchemaNodeKind::AppsGroup + | SchemaNodeKind::DashboardsGroup + | SchemaNodeKind::S3Status + ) + } +} diff --git a/src/ui/sidebar/tree.rs b/src/ui/sidebar/tree.rs index 299584c..fe4b7ad 100644 --- a/src/ui/sidebar/tree.rs +++ b/src/ui/sidebar/tree.rs @@ -32,9 +32,15 @@ pub(super) fn build_tree_items( Some(attached_files.len().to_string().into()), ), ); + let duplicated = crate::ui::workspace::duplicate_titles( + attached_files.iter().map(|file| file.view_name.as_str()), + ); let file_items: Vec = attached_files .iter() - .map(|file| file_tree_item(file, catalog, &mut meta)) + .map(|file| { + let duplicate = duplicated.contains(&file.view_name.as_str()); + file_tree_item(file, duplicate, catalog, &mut meta) + }) .collect(); items.push( TreeItem::new(group_id, tr("sidebar.group.local_files")) @@ -146,14 +152,25 @@ fn recents_group( Some(documents.len().to_string().into()), ), ); + let duplicated = + crate::ui::workspace::duplicate_titles(documents.iter().map(|doc| doc.title.as_str())); let rows: Vec = documents - .into_iter() + .iter() .map(|doc| { let row_id: SharedString = format!("{id}:{}", doc.path).into(); + // Two `dashboard.dash` files both title `dashboard`; the folder + // each came from is what tells them apart, as on the tabs. + let hint = duplicated + .contains(&doc.title.as_str()) + .then(|| crate::ui::workspace::parent_name(std::path::Path::new(&doc.path))) + .flatten() + .map(SharedString::from); meta.insert( row_id.clone(), SchemaNodeMeta { - doc: Some(doc.clone()), + doc: Some((*doc).clone()), + hint, + tooltip: Some(crate::db::compact_home(&doc.path).into()), ..SchemaNodeMeta::new(SchemaNodeKind::RecentDocument, None) }, ); @@ -167,10 +184,13 @@ fn recents_group( ) } -/// One registered data file: label is `视图名 · 文件名`, children are the -/// view's columns looked up from the catalog. +/// One registered data file: labelled by its view name — what a query names — +/// with the file's own name beside it only when that says something the view +/// name does not, and its folder when another file has the same view name. +/// Children are the view's columns looked up from the catalog. fn file_tree_item( file: &AttachedFileView, + duplicate: bool, catalog: &[DatabaseInfo], meta: &mut HashMap, ) -> TreeItem { @@ -202,6 +222,8 @@ fn file_tree_item( column: None, s3_uri: None, doc: None, + hint: file_hint(&file.path, &file.view_name, duplicate), + tooltip: Some(crate::db::compact_home(&file.path).into()), }, ); @@ -233,11 +255,7 @@ fn file_tree_item( }) .unwrap_or_default(); - let basename = std::path::Path::new(&file.path) - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| file.path.clone()); - TreeItem::new(file_id, format!("{} · {}", file.view_name, basename)).children(columns) + TreeItem::new(file_id, file.view_name.clone()).children(columns) } fn table_tree_item( @@ -302,3 +320,47 @@ fn table_tree_item( TreeItem::new(table_id, table.name.clone()).children(columns) } + +/// The muted text after a file row's view name: the file name when its stem is +/// not the view name (`sales_2` from `sales.csv`, a sheet's table from its +/// workbook), and the folder when another file shares the view name. +fn file_hint(path: &str, view_name: &str, duplicate: bool) -> Option { + let path = std::path::Path::new(path); + let mut parts = Vec::new(); + let stem = path.file_stem().map(|stem| stem.to_string_lossy()); + if stem.as_deref() != Some(view_name) { + if let Some(name) = path.file_name() { + parts.push(name.to_string_lossy().to_string()); + } + } + if duplicate { + parts.extend(crate::ui::workspace::parent_name(path)); + } + (!parts.is_empty()).then(|| parts.join(" · ").into()) +} + +#[cfg(test)] +mod file_hint_tests { + use super::file_hint; + + #[test] + fn a_file_named_like_its_view_needs_no_hint() { + assert_eq!(file_hint("/data/sales.csv", "sales", false), None); + } + + #[test] + fn a_renamed_view_shows_its_file_and_a_duplicate_its_folder() { + assert_eq!( + file_hint("/data/sales.csv", "sales_2", false).as_deref(), + Some("sales.csv") + ); + assert_eq!( + file_hint("/logs/samples/sales.csv", "sales", true).as_deref(), + Some("samples") + ); + assert_eq!( + file_hint("/x/book.xlsx", "book_Extra", true).as_deref(), + Some("book.xlsx · x") + ); + } +} diff --git a/src/ui/title_bar.rs b/src/ui/title_bar.rs index 8a07bff..5b71f12 100644 --- a/src/ui/title_bar.rs +++ b/src/ui/title_bar.rs @@ -42,6 +42,9 @@ impl TitleBarView { cx.subscribe(&state, |_, _, _: &crate::state::OpenStateChanged, cx| { cx.notify(); }), + cx.subscribe(&state, |_, _, _: &crate::state::SidebarToggled, cx| { + cx.notify(); + }), ]; Self { state, @@ -335,11 +338,12 @@ impl TitleBarView { impl Render for TitleBarView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (target_label, opening) = { + let (target_label, opening, sidebar_collapsed) = { let state = self.state.read(cx); ( state.target.as_ref().and_then(|t| t.file_label()), state.is_opening(), + state.is_sidebar_collapsed(), ) }; let dark = cx.theme().mode.is_dark(); @@ -355,6 +359,25 @@ impl Render for TitleBarView { .min_w_0() .justify_start() .gap_2() + // With the sidebar put away, the way back sits where + // the sidebar would begin. + .when(sidebar_collapsed, |this| { + this.child( + Button::new("expand-sidebar") + .ghost() + .xsmall() + .icon(IconName::PanelLeftOpen) + .tooltip_with_action( + tr("sidebar.expand"), + &crate::ui::ToggleSidebar, + None, + ) + .on_click(cx.listener(|this, _, _, cx| { + this.state + .update(cx, |state, cx| state.toggle_sidebar(cx)); + })), + ) + }) .child( Button::new("open-data") .ghost() diff --git a/src/ui/workspace.rs b/src/ui/workspace.rs index a081d57..2b95147 100644 --- a/src/ui/workspace.rs +++ b/src/ui/workspace.rs @@ -150,7 +150,7 @@ fn editor_target(kinds: &[TabKind], active: usize) -> EditorTarget { /// The titles that more than one tab carries. Two `dashboard.dash` files from /// different folders both default to `dashboard`, and a strip of identical /// labels gives no way to tell which tab is which. -fn duplicate_titles<'a>(titles: impl IntoIterator) -> Vec<&'a str> { +pub(crate) fn duplicate_titles<'a>(titles: impl IntoIterator) -> Vec<&'a str> { let mut seen = Vec::new(); let mut duplicates = Vec::new(); for title in titles { @@ -167,7 +167,7 @@ fn duplicate_titles<'a>(titles: impl IntoIterator) -> Vec<&'a st /// The name of the folder `path` sits in: what tells two same-titled /// documents apart. -fn parent_name(path: &Path) -> Option { +pub(crate) fn parent_name(path: &Path) -> Option { path.parent()? .file_name() .map(|name| name.to_string_lossy().to_string()) From baa8b1201f173e6ae3b32eaf2818849bc78574a8 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Thu, 24 Sep 2026 00:04:12 +0800 Subject: [PATCH 8/8] Keep recent documents in place when clicked, and let them be removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar listed apps and dashboards most recent first, so opening one moved it to the top — the list reshuffled under the pointer. It now sorts by title, then path; recency still decides which documents the list keeps. A recent app or dashboard gets a remove button on hover, like a file row's. It only drops the entry from the list: the files and any open tab stay, so there is nothing to confirm. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/schema-and-history.md | 6 ++-- docs/zh/schema-and-history.md | 2 +- src/i18n.rs | 5 ++++ src/ui/sidebar/mod.rs | 20 +++++++++++++ src/ui/sidebar/tree.rs | 53 +++++++++++++++++++++++++++++++++-- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/docs/schema-and-history.md b/docs/schema-and-history.md index bd1d668..e759342 100644 --- a/docs/schema-and-history.md +++ b/docs/schema-and-history.md @@ -14,8 +14,10 @@ The tree shows, from the top: 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/zh/schema-and-history.md b/docs/zh/schema-and-history.md index 357a7fc..c11172d 100644 --- a/docs/zh/schema-and-history.md +++ b/docs/zh/schema-and-history.md @@ -9,7 +9,7 @@ 树从上到下依次显示: - **本地文件**——你已注册的数据文件,以视图名显示,并带有行数。只有文件名与视图名不同(如 `sales.csv` 对应 `sales_2`)时才在旁边显示文件名,两个文件视图名相同时显示所在文件夹;鼠标悬停可看到完整路径。它的子节点是该视图的列。 -- **应用** / **仪表盘**——你最近打开过的 analysis app 和 `.dash` 仪表盘。点击即可像打开文件一样重新打开对应的 Tab;路径已不存在的条目会从列表中移除。 +- **应用** / **仪表盘**——你最近打开过的 analysis app 和 `.dash` 仪表盘,按名称排序,点击后位置不会变。点击即可像打开文件一样重新打开对应的 Tab;悬停时的 × 会把它从列表移除,文件和已打开的 Tab 都不受影响;路径已不存在的条目会从列表中移除。 - **S3**——在[配置好 S3](s3.md) 之后出现。延迟加载。 - 每个数据库,然后是它的 schema,接着是表和视图,最后是列。 diff --git a/src/i18n.rs b/src/i18n.rs index 3ebdd1b..d3c2e93 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -164,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", "{} 已不存在,已从最近列表移除", diff --git a/src/ui/sidebar/mod.rs b/src/ui/sidebar/mod.rs index be7862b..6f24965 100644 --- a/src/ui/sidebar/mod.rs +++ b/src/ui/sidebar/mod.rs @@ -293,6 +293,7 @@ impl Sidebar { let column = node_meta.and_then(|m| m.column.clone()); let s3_uri = node_meta.and_then(|m| m.s3_uri.clone()); let doc = node_meta.and_then(|m| m.doc.clone()); + let removable_doc = doc.clone(); let is_s3_root = node_meta.map(|m| m.kind) == Some(SchemaNodeKind::S3Status); let editable_column = column.clone().filter(|column| !column.table.is_view); ListItem::new(ix) @@ -492,6 +493,25 @@ impl Sidebar { }), ) }) + // A recent app or dashboard leaves the list — + // only the list: its files and any open tab + // stay, so there is nothing to confirm. + .when_some(removable_doc, |this, doc| { + let state = state.clone(); + this.child( + Button::new(("remove-recent", ix)) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(tr("sidebar.recent.remove")) + .on_click(move |_, _, cx| { + crate::recents::remove(&doc.path); + state.update(cx, |_, cx| { + cx.emit(RecentsChanged); + }); + }), + ) + }) .when_some(file, |this, file| { this.child( Button::new(("remove-file", file.id as usize)) diff --git a/src/ui/sidebar/tree.rs b/src/ui/sidebar/tree.rs index fe4b7ad..6825183 100644 --- a/src/ui/sidebar/tree.rs +++ b/src/ui/sidebar/tree.rs @@ -129,7 +129,7 @@ pub(super) fn build_tree_items( } /// One recents group ("Apps" or "Dashboards"): a row per remembered document, -/// most recent first, like files the user can click back open. +/// in a stable order, like files the user can click back open. fn recents_group( id: &str, label: &str, @@ -137,7 +137,13 @@ fn recents_group( kind: RecentKind, meta: &mut HashMap, ) -> Option { - let documents: Vec<&RecentDocument> = recents.iter().filter(|doc| doc.kind == kind).collect(); + let mut documents: Vec<&RecentDocument> = + recents.iter().filter(|doc| doc.kind == kind).collect(); + // By name, then path — not by recency. Opening a document bumps it to the + // front of the stored list, and a list ordered that way reshuffles under + // the pointer the moment one of its rows is clicked. Recency still decides + // which documents the list keeps. + sort_documents(&mut documents); if documents.is_empty() { return None; } @@ -321,6 +327,17 @@ fn table_tree_item( TreeItem::new(table_id, table.name.clone()).children(columns) } +/// The sidebar's order for recent documents: by title, case-insensitively, +/// then by path, so same-titled documents keep their places too. +fn sort_documents(documents: &mut [&RecentDocument]) { + documents.sort_by(|a, b| { + a.title + .to_lowercase() + .cmp(&b.title.to_lowercase()) + .then_with(|| a.path.cmp(&b.path)) + }); +} + /// The muted text after a file row's view name: the file name when its stem is /// not the view name (`sales_2` from `sales.csv`, a sheet's table from its /// workbook), and the folder when another file shares the view name. @@ -341,7 +358,37 @@ fn file_hint(path: &str, view_name: &str, duplicate: bool) -> Option = shown.iter().map(|doc| doc.path.as_str()).collect(); + assert_eq!( + paths, + [ + "/ex/analysis_app/dashboard.dash", + "/ex/usage_panel/dashboard.dash", + "/ex/sales.dash" + ] + ); + } + } #[test] fn a_file_named_like_its_view_needs_no_hint() {