Getting Started
gpui-shell 首先是给一个 Rust GPUI 应用加上 JavaScript 扩展点的办法:由 Host 构建运行时、决定脚本能碰到什么,并把脚本 View 挂在它想挂的位置。直接运行一个脚本目录——也就是下面那个 gpui-shell 二进制——是随之而来的开发便利,而不是它的定位。
#把运行时接进 Rust 应用
gpui-shell 二进制本身是一个很薄的 Host:解析命令行、装上日志 sink、建一个运行时、开一个窗口。任何嵌入这个库的 Host 做的也是同样四件事。
use gpui_kit::shell::{Capabilities, ShellRuntime};
gpui_platform::application()
.with_assets(gpui_kit::shell::AppAssets::new(root.clone()))
.run(move |cx| {
// 初始化 gpui-base、shell 的默认 token 调色板,以及样式反射表。
gpui_kit::shell::init(cx);
let runtime = ShellRuntime::new(cx).expect("script runtime");
// 在 Host 开口之前,什么都不允许。
gpui_kit::shell::set_store_path(store_directory.join("store.json"));
gpui_kit::shell::set_capabilities(
Capabilities::new()
.read_roots([root.clone()])
.write_roots([store_directory.clone()])
.store(true),
);
cx.open_window(Default::default(), move |window, cx| {
runtime.load(&root, window, cx)
})
.expect("window");
});
其中两行承载的是规则而不是机制。
runtime.load(...) 返回窗口的 ShellRoot,作用与 gpui-component 窗口中的 Root 相同。它持有 dialog 栈、sheet、toast 栈、焦点恢复与 Tab 导航。manifest 负责选择应用入口并记录能力请求,但不会自行批准这些请求。带 manifest 与不带 manifest 的目录都使用 Host 当前的默认 policy;后者采用 main.js。
能力默认为空。 Capabilities::default() 什么都不授予——没有文件、没有存储、没有剪贴板、没有进程。由 Host 决定,因为只有 Host 知道它对即将运行的这段代码信任到什么程度。见 Capabilities。
同时也要装上 tracing subscriber。运行时通过 tracing 报告脚本错误、未处理的 promise rejection 以及 phase 非法的调用;没有 subscriber 时,这些全部被丢弃,症状是一个安静地不再响应的界面。
#它加载的那个脚本
一个文件就够了。新建一个目录,放一个 main.js:
// hello/main.js
import { View } from "gpui-kit";
import { v_flex, Button } from "gpui-base";
export default class Hello extends View {
init() {
this.clicks = 0;
}
render(cx) {
return v_flex()
.size_full()
.items_center()
.justify_center()
.gap(12)
.bg(cx.theme().colors.background)
.child(
div()
.text_color(cx.theme().colors.foreground)
.child(`Clicked ${this.clicks} times`),
)
.child(
Button.new("click")
.h(28)
.px(12)
.items_center()
.justify_center()
.border(1)
.border_color(cx.theme().colors.border)
.bg(cx.theme().colors.surface)
.text_color(cx.theme().colors.foreground)
.on_click((_event, cx) => {
this.clicks += 1;
cx.notify();
})
.child("Click me"),
);
}
}
cargo run -p gpui-shell -- run ./hello
这个文件里有四件事值得现在就点明,因为后面所有内容都建立在它们之上。
能力由哪个包提供,就从哪个模块导入。 "gpui-kit" 是 GPUI 自身的元素和运行时补上的部分——View、div、text、存储、调度。"gpui-base" 是 gpui-base 的布局辅助、组件和主题——v_flex、Button、InputState。"gpui-fps" 是它的性能浮层。一个名字只属于其中一个模块,所以一行 import 就说清了脚本依赖的是哪一层。运行时还提供一层刻意收窄的 JavaScript 标准能力:buffer、path、url、crypto、zlib、console、process、os、fs/promises、net、websocket,以及全局 fetch。应用相对导入仍被限制在应用目录内。node:fs 这类 node: 别名、包查找和 CommonJS require 不属于契约。
main.js 必须 export default 一个继承 View 的类。 init 在 View 创建时只执行一次;render 返回一个元素、留存的 Entity 或字符串,并且是在 View 失效时执行,而不是每帧执行——见 render 什么时候执行。
样式方法是 snake_case,你自己写的代码是 camelCase。 items_center、on_click、text_color、gap_2 保留了 Rust 的拼写,因为无参样式接口是从 GPUI 的反射表生成的,而不是手写的。应用自己声明的一切——变量、方法、对象的键——都是普通的 JavaScript camelCase。这个对比是刻意的:snake_case 的调用是 Host 接口,camelCase 的是你的代码。
没有任何东西会自动重绘。 没有 signal,没有 useState,也没有依赖数组。改完状态,自己调用 cx.notify()。
#单独运行一个脚本
一个脚本目录也可以直接跑起来,不必先写 Host 。自带的示例就是这么运行的;一段脚本在被它将来所属的那个应用加载之前,通常也是这样开发的。gpui-shell 没有发布到 crates.io,所以先克隆仓库,再在仓库根目录运行:
cargo run -p gpui-shell -- run examples/js_todolist
窗口里会出现一个可用的 todo list:带留存状态的输入框、受控 checkbox、一个确认 dialog、一个 toast、从应用自身目录加载的图标,以及在未获授权时退化为内存存储的持久化。它的目的是把整个运行时都跑一遍,而不是做到最小——哪里坏了,通常先在这里露出来。
参数是一个目录,不是文件。运行时解析该目录,默认读取其中的 main.js,取出该模块 default 导出的类、构造一个实例,并把它挂载为窗口的根 View。如果目录中存在 gpui-shell.json,二进制会先验证它,并采用其中声明的 entry 与 capabilities。
包含路径分隔符的参数——./hello、examples/js_todolist——是一个目录;不含分隔符的裸名字则表示一个已安装的应用,见下文。-d / --dir <directory> 永远表示目录。
#安装运行时
每个 GitHub Release 都附带预编译的 gpui-shell,覆盖 macOS(Apple 芯片与 Intel)、Linux x86-64 和 Windows x86-64。它内置了 gpui-component 组件目录,所以应用除了 "gpui-base",也可以从 "gpui-component" 导入带样式的组件。
curl -fsSL https://gpui-kit.com/shell/install.sh | sh
每个 release 也附带同一个脚本,所以 https://github.com/longbridge/gpui-kit/releases/latest/download/install.sh 同样可用。
脚本会把二进制装到 ~/.local/bin,并创建 ~/.gpui-shell/apps。GPUI_SHELL_VERSION=v0.7.1 可以固定某个版本。GPUI_SHELL_HOME 会替换 ~/.gpui-shell 目录——已安装的应用和 Git 依赖缓存都随之移动;gpui-shell 运行时也读取这个变量,所以要保持导出。产物名不带版本号(gpui-shell-aarch64-apple-darwin.tar.gz,旁边附 .sha256),所以 Homebrew formula 或 mise 的 ubi backend 也可以直接下载 releases/latest/download/<asset>。
#已安装与随包的应用
gpui-shell 一个进程只运行一个应用,自身不带任何界面。运行哪个应用由命令行决定:
| 命令 | 运行的应用 |
|---|---|
gpui-shell run clock | 已安装的应用 ~/.gpui-shell/apps/clock/ |
gpui-shell run -d ~/dev/clock | 指定的那个目录 |
gpui-shell | 与可执行文件一起打包的应用;没有则显示 help |
安装应用就是把它的目录复制或链接到 ~/.gpui-shell/apps/,目录名就是运行它时用的名字。gpui-shell list 列出所有已安装的应用及其 manifest 申请的 capabilities,一个都不会执行。已安装的应用按 manifest 的 id 保存数据,移动或重装都不会丢;用 --dir 运行的目录则按路径区分,这正适合正在编辑的源码。
不带参数时,可执行文件会在自己旁边找应用:如果它是 macOS bundle 里的 Contents/MacOS/gpui-shell,就找 Contents/Resources/app;否则找可执行文件所在目录下的 app。找不到时就显示 help,所以刚安装好的 gpui-shell 在终端里直接运行会先介绍自己的用法。于是一个桌面应用就是 gpui-shell 二进制加一个脚本目录,不需要额外的启动脚本来传路径:
Clock.app/Contents/
├── Info.plist # CFBundleExecutable = gpui-shell
├── MacOS/gpui-shell
└── Resources/app/
├── gpui-shell.json
└── main.js
Packaging 完整介绍了 .app、Linux 压缩包与 desktop entry、Windows 文件夹的做法,包括签名与公证。
#新建应用
gpui-shell new clock
gpui-shell run -d clock --watch
new 和 cargo new 一样,在命令运行的位置创建目录——这里的 clock 指 ./clock,不是已安装的应用。它会写入 gpui-shell.json(id 由目录名推出)、一个带标题栏和按钮的 main.js、.gitignore、jsconfig.json、gpui-kit.d.ts 和 AGENTS.md;目标目录里已有文件时会拒绝创建。
#给 coding agent 的开发说明
gpui-shell agents # 打印出来
gpui-shell agents -d ./hello # 写入 ./hello/AGENTS.md
agents 会打印 coding agent 为这个运行时编写应用所需的规则,以及用来验证结果的 check 流程:没有 DOM、状态在 init 中赋值、改完状态调用 cx.notify()、元素只能用一次、capabilities 默认拒绝。其中的模块列表和组件目录都来自打印它的这个二进制。指定应用时,它会在那里写入 AGENTS.md,并且只替换自己生成过的文件(首行有标记),手写的说明不会被覆盖。
#不运行也能检查脚本
JavaScript 没有编译器,这个运行时也不打算造一个。它补上的是编译器本该替你做的那件事:
cargo run -p gpui-shell -- check ./hello
check 会加载应用,并向一个永远不显示的窗口渲染一帧,成功退出 0,失败退出 1。因为脚本接口是动态的——未知的样式方法、类型不对的参数、被重复使用的元素,都是运行期事实——所以“构建并渲染一次”是唯一诚实的检查方式。它能报出:
- 语法错误,并带上脚本自身的调用栈;
- 无法解析的 import,以及越出应用目录的 import;
- 缺失或形态不对的 default 导出;
- 未知的样式方法,并给出
did you mean建议; - 类型不对的样式参数,例如
.p("auto"); - 被使用了两次的元素。
它不开窗口,因此可以放进编辑器、CI,或者一个 agent 的循环里。
加上 --print-spec 可以顺带打印构建出的元素描述:
cargo run -p gpui-shell -- check ./hello --print-spec
这份输出是 arena 自己的调试输出——在它变成真实元素之前,由组件与记录操作构成的那棵树。当问题是“我这条链到底记录了什么”时,它很有用。
#生成 TypeScript 声明
cargo run -p gpui-shell -- types ./hello
它会在应用旁边写出 gpui-kit.d.ts。在脚本顶部加上 // @ts-check,编辑器就会补全整套 API,并在运行之前、在调用点上直接拒绝拼错的样式方法、不存在的颜色 token,或者 .p("auto")。
它同时会把编辑器需要的其余部分一并配好:manifest 声明的每个 Git 依赖都会被抓取并按声明的名字链接进 node_modules,于是 import { style } from "omarchy-ui" 解析到的正是运行时将要执行的那批文件,连同该 package 自己的类型、参数与 JSDoc;若目录里既没有 jsconfig.json 也没有 tsconfig.json,还会生成一份 jsconfig.json。详见依赖。
这份声明可信,是因为它从运行时实际派发所依据的那几张表生成,而不是照着文档抄的:
- 样式方法名来自 JavaScript prelude 构建元素原型时遍历的同一份列表;
- 每个有参方法的参数类型是探测出来的——生成器逐一询问运行时该方法接受哪些字面量,所以 length、definite length、absolute length、颜色与裸数字之间的区别,由真正做校验的那段代码决定;
- 颜色的联合类型来自已安装调色板的 token 名。
有三件事声明刻意不表达,因为没有类型能表达:能力是否被授权(被拒绝的 fs.readFile 一样能通过类型检查);元素与 cx 的生命周期(TypeScript 没有仿射类型,重复使用元素照样能通过类型检查,也照样会抛异常);以及某个方法适用于哪个组件(所有元素共用一个原型,所以 .checked(true) 声明在全部元素上,在 div 上只是不起作用)。
升级运行时之后重新生成即可;输出是确定性的,所以 diff 是可以审阅的。
#Hot-reload
cargo run -p gpui-shell -- run ./hello --watch
cargo run -p gpui-shell -- run ./hello --dev # 隐含 --watch
--watch 每秒轮询应用目录四次,对一串连续写入去抖 200 ms,然后重载。一次重载会重新读取每一个模块,入口也在内——一个悄悄用了旧 import 的 hot-reload 比没有更糟,因为它看起来是成功的。
重载会在碰到实时 View 之前,先把所有可能失败的工作做完。如果新代码加载失败,之前的 View 继续运行,错误输出到 stderr,同时窗口里出现一个带固定 id 的 toast;下一次成功重载会把它撤回。存了一份坏代码,不会因此丢掉窗口。
--dev 隐含 --watch,并在构造运行时之前开启 development mode。它恢复动态代码构造器并让内建原型保持可写,但 capability 检查完全不变。见 Capabilities。
#命令一览
gpui-shell run [<name> | -d <directory>] [--watch] [--dev]
gpui-shell
gpui-shell new <directory>
gpui-shell list
gpui-shell check <name> | -d <directory> [--print-spec]
gpui-shell types <name> | -d <directory>
gpui-shell agents [<name> | -d <directory>]
gpui-shell --help | --version
| 参数 | 含义 |
|---|---|
<name> | 已安装的应用,~/.gpui-shell/apps/<name> |
-d, --dir <dir> | 应用根目录,或其中的 main.js |
| (无) | 与可执行文件一起打包的应用,没有则显示 help |
run | 在窗口中运行一个应用 |
new | 在新目录中创建一个空白应用 |
list | 列出已安装的应用及其申请的 capabilities |
check | 不开窗口地加载并渲染一次,退出码 0 或 1 |
types | 写出 gpui-kit.d.ts、链接 manifest 依赖、生成配置 |
agents | 打印给 coding agent 的开发说明,或写成 AGENTS.md |
--watch | 源码变化时重载 |
--dev | 开发模式,隐含 --watch |
--print-spec | 配合 check,额外打印构建出的元素描述 |