diff --git a/README.md b/README.md index a384cbd..56d8c7e 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,118 @@ # Zed portable configuration -This directory is the portable source for Zed settings, keybindings, tasks, extension installation preferences, and the file-exclusion toggle. - ## 一行安装 -前置依赖:已安装 Node.js 和 GitHub CLI `gh`。 - Windows PowerShell: ```powershell -irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.ps1 | iex +irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.ps1 | iex ``` -macOS/Linux: +macOS / Linux: ```sh -curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh +curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.sh | sh ``` -脚本会处理 GitHub 登录并询问 Gist URL/ID。输入已有 Gist 会下载其配置;直接留空会从本仓库种子配置自动创建新的 Secret Gist。随后脚本会备份当前 Zed 配置并完成安装,安装后重启 Zed。 +安装只需要 Node.js,会先备份并安装本地 Zed 配置,不要求 GitHub 登录,也不要求预先创建 Gist。安装完成后可以立即进入同步向导,也可以稍后在 Zed Task Picker 中运行 `Zed Settings: Set Up / Reconfigure Sync...`。 -For first-machine setup, new-machine onboarding, daily Gist synchronization, and conflict handling, see [`SYNC-MANUAL.zh-CN.md`](./SYNC-MANUAL.zh-CN.md). +GitHub 同步额外需要 [GitHub CLI](https://cli.github.com/) `gh`。向导使用浏览器完成 GitHub 授权,不需要手工创建或粘贴 Personal Access Token。 -## Files +完整的新机器、日常同步、自动同步和冲突处理说明见 [SYNC-MANUAL.zh-CN.md](./SYNC-MANUAL.zh-CN.md)。 -- `settings.json`: editor settings and extension installation list. Provider secrets are not stored here. -- `keymap.json`: cross-platform bindings using `secondary-` for Command/Ctrl. -- `tasks.json`: task template. Script-path placeholders are rendered during installation. -- `scripts/file-exclusions.json`: the only file to edit when changing permanent or toggleable exclusion rules. -- `scripts/toggle-file-scan-exclusions.mjs`: updates `file_scan_exclusions` in the active Zed settings. -- `scripts/settings-sync.json`: machine-local Gist ID and last synchronized hash. Export and import never overwrite it. -- `scripts/zed-settings-sync.mjs`: single-JSON import, export, hashing, and Gist synchronization. -- `bootstrap-from-gist.ps1` / `bootstrap-from-gist.sh`: standalone one-line installers for a fresh machine. They download the single JSON directly from Gist and do not need the rest of this directory. +## 同步体验 -## Zed Task Picker commands +- 云端始终只有一个扁平的 `zed-settings-sync.json`。 +- 首次设置会自动发现当前 GitHub 账号中已有的 Zed 设置 Gist。 +- 没有可用 Gist 时,可直接创建新的 Secret Gist 并上传本机设置。 +- Gist ID、同步基线和自动同步状态只保存在当前机器,不会上传。 +- `GH_TOKEN` 有效时会正常使用;无效时自动回退到 GitHub CLI 凭据存储。 +- Push、Pull、Setup 和 Status 都在可见终端中运行,不会再静默失败。 +- 自动同步默认关闭,可选择每 15 分钟检测一次。 -Open the Task Picker and search for `Zed Settings`: +Secret Gist 不会列在公开资料和搜索中,但它不是严格私有存储:任何拿到链接的人都可以读取。同步包不会包含 API Key,但仍应避免在 Zed 配置和代码片段中保存秘密。 -- `Export JSON...` and `Import JSON...` show a native file picker. -- `Configure GitHub Gist...` accepts an existing Gist ID or complete URL and remembers it on this machine. -- `Show GitHub Gist Status` compares the current, remote, and last synchronized SHA-256 hashes. -- `Push to GitHub Gist` creates a new Secret Gist when none is configured; later Push/Pull operations use the remembered Gist and ask before overwriting divergent settings. -- `Authenticate GitHub...` runs `gh auth login` in the terminal. +## Zed Tasks -GitHub CLI (`gh`) must be installed and authenticated. An existing Gist may contain other files; synchronization only creates or replaces `zed-settings-sync.json` inside it. +打开 Task Picker,搜索 `Zed Settings`: -The synchronized JSON is deliberately flat. Its top-level keys are paths such as `settings.json`, `keymap.json`, and `scripts/file-exclusions.json`, whose values are the complete file contents. No version, metadata, `files` wrapper, or hash is embedded. SHA-256 values are calculated at runtime and only the last synchronized value is retained locally. +| Task | 用途 | +|---|---| +| `Set Up / Reconfigure Sync...` | 浏览器登录 GitHub,发现、创建或更换 Gist | +| `Show Sync Status` | 查看本地、远端、同步基线和自动同步状态 | +| `Push Now` | 手动上传本机设置;覆盖远端前确认 | +| `Pull Now` | 手动下载远端设置;覆盖本机前确认并备份 | +| `Enable Automatic Sync` | 安装用户级 15 分钟定时任务 | +| `Disable Automatic Sync` | 删除定时任务并关闭自动同步 | +| `Export JSON...` | 用系统文件选择器导出单个 JSON | +| `Import JSON...` | 从单个 JSON 导入并先备份当前配置 | -A secret Gist is unlisted, not access-controlled: anyone who obtains its URL can read it. This bundle deliberately excludes API keys, but a synchronized folder or private Git repository is the safer choice for configuration that contains sensitive paths or identifiers. +快捷键: -## Installed keybindings +- `Mod+R`:打开 Task Picker。 +- `Mod+; U`:运行 `Push Now`。 +- `Mod+; D`:运行 `Pull Now`。 +- `Mod+9` / `Mod+0`:当前编辑器区域的上一个 / 下一个 Tab。 +- `Alt+1` 至 `Alt+5`:切换编辑器区域。 +- `Mod+Shift+R`:切换最近项目。 +- `Mod+; K`:重载 Zed。 +- `Mod+; C`:打开 Git Panel;再次按下聚焦 Commit Message。 -- `Mod+R`: open the Task Picker; search for `Zed Settings` to import, export, push, or pull. -- `Mod+; U`: upload the current single JSON to the configured Gist. -- `Mod+; D`: download and import the single JSON from the configured Gist. -- `Mod+9` / `Mod+0`: activate the previous / next tab in the current pane. -- `Alt+1` through `Alt+5`: activate editor pane 1 through 5. -- `Mod+Shift+R`: switch to a recent project. -- `Mod+; K`: reload Zed. -- `Mod+; C`: focus the Git panel; press it again to focus the commit-message editor. +`Mod` 在 macOS 是 Command,在 Windows/Linux 是 Ctrl;配置通过 Zed 的 `secondary-` 语法跨平台复用。 -`Mod` is Command on macOS and Ctrl on Windows/Linux because the keymap uses Zed's `secondary-` syntax. `Alt` maps to Option on macOS. +## 自动同步规则 -## Windows +自动同步使用当前用户级系统调度器: -Install this copy into Zed: +- Windows:Task Scheduler 的 `ZedSettingsSync`。 +- macOS:`~/Library/LaunchAgents/cool.okk.zed-settings-sync.plist`。 +- Linux:优先使用 systemd user timer,不可用时回退到带标记的 crontab。 + +状态处理: + +| 状态 | 自动行为 | +|---|---| +| 本地和远端一致 | 不修改 | +| 只有本地变化 | Push | +| 只有远端变化 | 备份后 Pull | +| 两边都变化 | 停止并通知,不覆盖 | +| 没有同步基线 | 停止并通知,不猜测方向 | +| 网络或认证失败 | 记录错误,不改变配置或基线 | + +日志位于 Zed 配置目录的 `logs/settings-sync.log`,超过 1 MB 后自动轮换。自动同步要求 GitHub CLI 凭据保存在系统凭据存储中;仅存在于某个进程内的 `GH_TOKEN` 不足以供后台任务长期使用。 + +## 仓库文件 + +- `settings.json`:Zed 设置和扩展安装清单,不包含 Provider 密钥。 +- `keymap.json`:跨平台键位。 +- `tasks.json`:Zed Tasks 模板,安装时替换脚本路径占位符。 +- `scripts/file-exclusions.json`:修改常驻和可切换的文件排除规则。 +- `scripts/toggle-file-scan-exclusions.mjs`:切换 Project Panel 文件显隐。 +- `scripts/settings-sync.json`:机器本地状态,不进入同步包。 +- `scripts/zed-settings-sync.mjs`:导入、导出、Gist 和自动同步核心。 +- `bootstrap.ps1` / `bootstrap.sh`:新机器非阻塞安装器。 +- `zed-settings-sync.json`:可以上传到 Gist 的单个扁平同步文件。 + +AI / Codex ACP 设置见 [AI-SETUP.md](./AI-SETUP.md)。 + +## 本地开发 + +从仓库安装到当前机器: ```powershell .\install.ps1 ``` -Export later changes from Zed back into this directory: +从当前 Zed 配置重新生成仓库配置和单文件: ```powershell .\export.ps1 ``` -## macOS and Linux - -Node.js is required for the exclusion toggle and task-template rendering. +运行隔离测试: ```sh -sh ./install.sh -sh ./export.sh +node --test tests/settings-sync.test.mjs ``` -## Private Git synchronization - -Initialize this directory once and push it to a private remote: - -```sh -git init -git add . -git commit -m "Add portable Zed configuration" -git remote add origin -git push -u origin main -``` - -On another machine, clone the repository and run the platform installer. After changing Zed settings, run the export script, review `git diff`, then commit and push. On other machines, pull and run the installer again. - -Zed account login does not currently synchronize these files. API keys remain in each machine's system keychain or environment variables and must not be committed. +测试使用模拟 `gh` 和临时 Zed 目录,不会访问或创建真实 Gist。 diff --git a/SYNC-MANUAL.zh-CN.md b/SYNC-MANUAL.zh-CN.md index 2e75add..bc99287 100644 --- a/SYNC-MANUAL.zh-CN.md +++ b/SYNC-MANUAL.zh-CN.md @@ -1,255 +1,222 @@ -# Zed 设置同步使用手册 +# Zed 设置同步手册 -本方案使用 GitHub Gist 中的单个 `zed-settings-sync.json` 同步 Zed 设置。JSON 顶层直接保存各配置文件的路径和完整内容;SHA-256 只在运行时计算,不写入同步文件。 +本方案把 Zed 的设置、键位、Tasks、文件排除规则、代码片段和主题整理为一个扁平的 `zed-settings-sync.json`,通过 GitHub Gist 在机器间同步。同步文件内部不保存哈希、Gist ID、账号或 token。 -## 1. 同步内容 +## 准备工作 -会同步: +本地安装需要: -- `settings.json`:界面、字体、主题、编辑器行为及扩展自动安装清单。 -- `keymap.json`:自定义键位。 -- `tasks.json`:文件显隐和设置同步任务。 -- `scripts/` 中的文件显隐及同步脚本。 -- 非空的 `snippets/` 和 `themes/` 目录。 +- Node.js +- Windows PowerShell,或 macOS/Linux 的 `curl` -不会同步: - -- GitHub 登录凭据、API Key 和系统环境变量。 -- 本机保存的 Gist ID 和上次同步哈希。 -- Zed 数据库、缓存、聊天记录、项目文件及扩展二进制。 - -扩展 ID 位于 `settings.json` 的 `auto_install_extensions` 中。导入后由 Zed 在目标平台重新安装扩展。 - -## 2. 准备 Gist - -不需要预先创建 Gist:第一次执行 Push 时,如果本机尚未配置 Gist,系统会询问是否自动创建新的 Secret Gist。 - -已有 Gist 时,也可以登录 GitHub、打开 ,复制完整 Gist URL 或末尾的 Gist ID,再通过 `Configure GitHub Gist...` 关联。 - -Gist ID 不是访问令牌。身份认证由 GitHub CLI `gh` 管理。 - -Secret Gist 只是不出现在公开搜索中,获得链接的人仍然可以读取其内容。同步 JSON 不包含 API Key,但仍应妥善保管 Gist URL。 - -## 3. 主机器首次上传 - -### 3.1 安装和认证 GitHub CLI - -当前 Windows 机器已经安装 `gh`。其他机器可以使用系统包管理器安装: +启用 GitHub Gist 同步还需要 GitHub CLI `gh`: ```powershell +# Windows winget install GitHub.cli ``` -macOS: - ```sh +# macOS brew install gh ``` -安装后,在 Zed 中按 `Mod+R` 打开 Task Picker,运行: +Linux 安装方式见 。 -```text -Zed Settings: Authenticate GitHub... -``` +无需提前创建 Gist,也无需手工生成 Personal Access Token。设置向导会调用 GitHub CLI 的浏览器登录流程,并请求 Gist 权限。 -也可以在终端执行: +## 第一台机器 -```sh -gh auth login -``` - -### 3.2 创建或关联 Gist - -1. 按 `Mod+R`。 -2. 没有 Gist 时直接运行 `Zed Settings: Push to GitHub Gist`,确认自动创建。 -3. 已有 Gist 时先运行 `Zed Settings: Configure GitHub Gist...` 并粘贴 URL/ID,再执行 Push。 -4. 运行 `Zed Settings: Show GitHub Gist Status`,确认状态为 `Synchronized`。 - -Push 只会在该 Gist 中创建或更新 `zed-settings-sync.json`,不会删除 Gist 中的其他文件。 - -Gist ID 和上次同步哈希保存在本机: - -```text -Windows: %APPDATA%\Zed\scripts\settings-sync.json -macOS/Linux: ~/.config/zed/scripts/settings-sync.json -``` - -此文件不参与导入导出,所以其他机器不会覆盖当前机器记录。 - -## 4. 新机器首次接入 - -第一次接入只需要运行仓库里的独立 bootstrap 脚本。它会询问 Gist URL/ID,直接读取 Gist 单 JSON,再安装全部设置和同步工具;不需要下载或解压便携目录。 - -### 4.1 安装前置程序 - -新机器需要: - -- Zed 或 ZedG。 -- Node.js。 -- GitHub CLI `gh`。 - -bootstrap 仓库本身不保存设置或凭据,只需要包含: - -```text -bootstrap-from-gist.ps1 -bootstrap-from-gist.sh -``` - -因此这个仓库可以公开;真正的设置仍只存放在指定 Gist 中。 - -### 4.2 一行安装 +### 1. 安装配置 Windows PowerShell: ```powershell -irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.ps1 | iex +irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.ps1 | iex ``` -macOS/Linux: +macOS / Linux: ```sh -curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh +curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap.sh | sh ``` -迁移到其他 Gitea 仓库时,Raw URL 的常见形式是: +安装器先完成以下工作: -```powershell -irm https:///<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.ps1 | iex -``` +1. 从公开 Gitea 仓库下载默认 `zed-settings-sync.json`。 +2. 把当前 Zed 配置备份到 `backups/bootstrap-<时间>`。 +3. 安装设置、键位、Tasks 和同步脚本。 +4. 创建空的机器本地同步状态。 -```sh -curl -fsSL https:///<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.sh | sh -``` +这四步不访问 GitHub。没有 `gh`、没有登录或没有 Gist 都不会造成安装失败。 -执行过程中脚本会: +### 2. 连接 GitHub -1. 检查 Node.js 和 `gh`。 -2. 在尚未登录时运行 `gh auth login`。 -3. 询问 Gist URL 或 ID;留空时自动创建新的 Secret Gist。 -4. 输入已有 Gist 时从 GitHub API 读取配置;留空时使用本仓库的 `zed-settings-sync.json` 种子配置。 -5. 验证顶层文件路径,防止写出 Zed 配置目录。 -6. 备份新机器已有配置。 -7. 安装设置、键位、Tasks、snippets、themes 和辅助脚本。 -8. 保存 Gist ID 和远端整文件 SHA-256 作为首次同步基线。 +安装结束时可以选择立即运行向导。稍后操作时: -安装完成后重启 Zed,运行 `Zed Settings: Show GitHub Gist Status`,状态应为 `Synchronized`。 +1. 重启或 Reload Zed。 +2. 按 `Mod+R` 打开 Task Picker。 +3. 运行 `Zed Settings: Set Up / Reconfigure Sync...`。 +4. 如果尚未登录,终端会显示一次性代码并打开 GitHub 浏览器授权页面。 -为了降低执行远程脚本的供应链风险,可以把 URL 中的 `main` 换成审核过的 commit SHA。bootstrap 仓库不应加入访问令牌或其他秘密。 +脚本按以下顺序选择凭据: -### 4.3 便携目录备用安装 +1. 验证当前 `GH_TOKEN` / `GITHUB_TOKEN`,包括 Gist 权限。 +2. 环境 token 无效时,移除它们后验证 GitHub CLI 凭据存储。 +3. 都不可用时启动浏览器登录。 -不能访问 Raw URL 时,仍可复制完整 `zed-config-portable` 目录并运行: +同步脚本本身不会读取、打印或保存 token。 -```powershell -.\install.ps1 -``` +### 3. 创建首个 Gist -```sh -sh ./install.sh -``` +登录后向导会搜索当前账号下包含 `zed-settings-sync.json` 的 Gist。第一台机器通常没有结果: -然后在 Task Picker 中认证 GitHub、配置 Gist 并执行首次 Pull。 +1. 选择 `N. Create a new Secret Gist from this machine`。 +2. 向导创建 Secret Gist 并上传当前 Zed 设置。 +3. Gist ID 和当前 SHA-256 被记录到本机 `scripts/settings-sync.json`。 +4. 自动同步保持默认关闭,除非明确选择开启。 -## 5. 之后的日常使用 +Secret Gist 不会出现在公开列表或搜索中,但知道链接的人仍可读取它。 -开始在一台机器工作前: +## 新机器 -1. 按 `Mod+R`。 -2. 运行 `Zed Settings: Show GitHub Gist Status`。 -3. 如果显示 `Remote changes available`,执行 Pull。 +1. 安装 Node.js;需要同步时再安装 `gh`。 +2. 执行与第一台机器相同的一行安装命令。 +3. 运行 `Set Up / Reconfigure Sync...`。 +4. 使用同一个 GitHub 账号完成浏览器登录。 +5. 向导会自动列出已有的 Zed 设置 Gist;通常只有一个,直接选择即可。 +6. 初始同步方向选择 `P. Pull remote settings onto this machine`。 +7. Pull 前默认安装产生的本地配置会被再次备份,然后替换为云端设置。 +8. Reload Zed。 -修改设置、键位、Tasks、排除规则或 snippets 后: +只有在使用其他账号、别人共享的 Gist 或自动发现失败时,才需要选择 `M` 并粘贴 Gist URL/ID。 -1. 执行 Push。 -2. 确认状态变为 `Synchronized`。 -3. 再到其他机器执行 Pull。 +## 日常手动同步 -常用快捷键: +推荐默认使用手动同步,方向明确: -| 快捷键 | 操作 | +- 修改设置、键位、Tasks、代码片段或主题后,在当前机器运行 `Push Now`。 +- 切换到另一台机器后运行 `Pull Now`。 +- 不确定状态时先运行 `Show Sync Status`。 + +快捷键: + +| 键位 | 操作 | |---|---| +| `Mod+; U` | Push Now | +| `Mod+; D` | Pull Now | | `Mod+R` | 打开 Task Picker | -| `Mod+; U` | Push 到 Gist | -| `Mod+; D` | 从 Gist Pull | -| `Mod+; K` | Reload Zed | -`Mod` 在 Windows/Linux 是 Ctrl,在 macOS 是 Command。 +Push 发现远端已经变化时会显示本地、远端和同步基线哈希,并在覆盖前确认。Pull 发现本地已经变化时同样确认,并始终先创建备份。 -## 6. 哈希和冲突处理 +## 自动同步 -系统比较三个 SHA-256: +运行 `Zed Settings: Enable Automatic Sync`: -- 当前机器实时生成的单 JSON 哈希。 -- Gist 中单 JSON 的哈希。 -- 当前机器上次成功 Push/Pull 时保存的哈希。 +1. 验证已经配置 Gist。 +2. 验证 GitHub CLI 的系统凭据存储,而不是依赖临时环境 token。 +3. 安装当前用户级的 15 分钟定时任务。 +4. 把 `auto_sync` 写为 `true`。 -Status 可能显示: +关闭时运行 `Disable Automatic Sync`,它会删除对应的系统调度项并保留 Gist 关联和同步基线。 -| 状态 | 含义 | 建议操作 | +系统实现: + +- Windows:Task Scheduler,任务名 `ZedSettingsSync`。 +- macOS:LaunchAgent `cool.okk.zed-settings-sync.plist`。 +- Linux:systemd user timer;没有 systemd 时使用带 `# zed-settings-sync` 标记的 crontab。 + +自动同步只处理方向明确的单边变化: + +| 本地 | 远端 | 基线 | 结果 | +|---|---|---|---| +| 相同 | 相同 | 任意 | 无操作 | +| 已变 | 等于基线 | 存在 | 自动 Push | +| 等于基线 | 已变 | 存在 | 备份后自动 Pull | +| 已变 | 已变 | 存在 | 冲突,停止 | +| 不同 | 不同 | 不存在 | 无基线,停止 | + +冲突、无基线、认证失效和网络错误不会触发覆盖。相同错误只通知一次,详细记录写入: + +```text +Windows: %APPDATA%\Zed\logs\settings-sync.log +macOS/Linux: ~/.config/zed/logs/settings-sync.log +``` + +日志超过 1 MB 后保留最多三份历史文件。 + +## 冲突恢复 + +`Show Sync Status` 可能显示: + +| 状态 | 含义 | 建议 | |---|---|---| -| `Synchronized` | 本地和 Gist 一致 | 无需操作 | -| `Local changes pending upload` | 只有本机有变化 | Push | -| `Remote changes available` | 只有 Gist 有变化 | Pull | -| `Conflict` | 本机和 Gist 都有变化 | 先导出本地备份,再决定 Push 或 Pull | -| `Not synchronized on this machine` | 这台机器尚无同步基线 | 首次接入通常选择 Pull | +| `synchronized` | 本地和远端一致 | 无需操作 | +| `local-changed` | 只有本地变化 | Push Now | +| `remote-changed` | 只有远端变化 | Pull Now | +| `conflict` | 两边都变化 | 先 Export JSON,再决定保留哪一边 | +| `no-baseline` | 机器没有可靠同步基线 | 检查内容后手动 Push 或 Pull | +| `remote-missing` | Gist 中缺少同步文件 | 重新配置或手动 Push | -Push 覆盖远端前、Pull 覆盖本地前都会显示哈希并要求确认。Pull 会自动备份本地配置;系统不会尝试逐字段自动合并。 +发生冲突时不要连续试 Push/Pull。先运行 `Export JSON...` 保存本机单文件;需要保留远端时执行 Pull,需要保留本机时执行 Push。两个方向在覆盖前都会再次确认。 -## 7. 离线 JSON 导入导出 +## 导入、导出与备份 -不使用 Gist 时,也可以使用完全相同的单 JSON 格式: +`Export JSON...` 和 `Import JSON...` 会打开系统文件选择器。导出的 JSON 与 Gist 中的格式完全相同,顶层键直接是文件路径,例如: -```text -Zed Settings: Export JSON... -Zed Settings: Import JSON... +```json +{ + "settings.json": "...", + "keymap.json": "...", + "tasks.json": "..." +} ``` -两个命令都会弹出系统文件选择窗口。直接 Import 不会修改已保存的 Gist ID 和上次同步哈希;导入内容会被视为新的本地变化,可以随后 Push 到 Gist。 +没有 `files` 外壳,也没有内置哈希。Import 不会覆盖本机保存的 Gist ID、自动同步设置或上次同步哈希,但导入后的内容会被识别为新的本地变化。 -## 8. 修改文件排除规则 - -只编辑本机配置中的 `custom` 数组: - -```text -Windows: %APPDATA%\Zed\scripts\file-exclusions.json -macOS/Linux: ~/.config/zed/scripts/file-exclusions.json -``` - -修改后按 `Alt+Shift+-` 测试显隐,再 Push 到 Gist。其他机器 Pull 后会取得相同规则。 - -## 9. 备份位置 - -每次 Import 或 Pull 前的备份位于: +Import 和 Pull 的备份目录: ```text Windows: %APPDATA%\Zed\backups\sync-import-<时间> macOS/Linux: ~/.config/zed/backups/sync-import-<时间> ``` -便携安装器产生的备份使用 `portable-install-<时间>` 名称。 +## 修改文件排除规则 -## 10. 常见问题 +编辑: -### Task Picker 中没有 Zed Settings +```text +Windows: %APPDATA%\Zed\scripts\file-exclusions.json +macOS/Linux: ~/.config/zed/scripts/file-exclusions.json +``` -重新运行一行 bootstrap 命令,或运行便携目录中的 `install.ps1` / `install.sh`,然后 Reload Zed。 +- `defaults`:始终排除的 Zed 默认规则。 +- `custom`:按 `Alt+Shift+-` 切换的自定义规则。 -### 提示找不到 gh +修改后先测试显隐,再运行 Push Now。其他机器 Pull 后会取得相同规则。 -安装 GitHub CLI 后完全退出并重启 Zed,使编辑器读取更新后的 PATH。 +## 故障排查 -### Gist 返回 404 +### Task 没有反应 -检查 Gist URL/ID、当前 GitHub 账号以及 Gist 是否仍然存在。重新运行 `Configure GitHub Gist...` 可以更换记录。 +新版 Setup、Status、Push、Pull、启用和禁用自动同步都会打开并保留终端。先查看终端中的完整错误;确认 Zed 已 Reload,且 Task 名称不是旧版的 `Configure GitHub Gist`。 -### Gist 中没有 zed-settings-sync.json +### 环境 token 导致认证异常 -先在已经配置好的主机器上执行一次 Push。 +脚本会先验证环境 token。无效时会自动回退到凭据存储,并在终端说明。若希望清理环境变量,可检查系统、Shell Profile 和 Zed 的 terminal env 设置。 -### Pull 后键位或界面没有立即变化 +### GitHub 已登录但不能访问 Gist -执行 `Mod+; K` Reload Zed;如果扩展仍在安装,等待完成后再重启一次。 +运行 `Set Up / Reconfigure Sync...`。向导会调用 `gh auth refresh --scopes gist` 补充权限。 -### 换了另一个 Gist +### 自动同步一直认证失败 -运行 `Configure GitHub Gist...` 并输入新 ID。系统会保留新 ID,同时清空旧 Gist 对应的上次同步哈希,下一次 Push/Pull 会重新确认覆盖方向。 +后台任务不能依赖只存在于某个终端进程中的 `GH_TOKEN`。重新运行设置向导并完成浏览器登录,让 `gh` 把凭据保存到系统凭据存储,然后重新启用自动同步。 + +### Pull 后没有立即生效 + +先运行 `Show Sync Status` 确认哈希一致,再执行 `Mod+; K` Reload Zed。某些扩展和 UI 状态仍可能需要完整重启。 + +### 更换或删除 Gist + +运行 `Set Up / Reconfigure Sync...` 重新选择、输入或创建 Gist。切换成功后会建立新的同步基线。删除远端 Gist 前建议先 Export JSON。 diff --git a/bootstrap-from-gist.ps1 b/bootstrap-from-gist.ps1 deleted file mode 100644 index c54b40e..0000000 --- a/bootstrap-from-gist.ps1 +++ /dev/null @@ -1,150 +0,0 @@ -[CmdletBinding()] -param() - -$ErrorActionPreference = "Stop" -$bundleName = "zed-settings-sync.json" -$utf8 = New-Object System.Text.UTF8Encoding($false) - -function Require-Command([string]$Name, [string]$InstallHint) { - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "$Name is required. Install it first: $InstallHint" - } -} - -function Get-GistId([string]$Value) { - $matches = [regex]::Matches($Value.Trim(), "[a-fA-F0-9]{8,}") - if ($matches.Count -eq 0) { - throw "Enter a valid GitHub Gist ID or URL." - } - return $matches[$matches.Count - 1].Value.ToLowerInvariant() -} - -function Test-AllowedPath([string]$Path) { - $fixed = @( - "settings.json", - "keymap.json", - "tasks.json", - "debug.json", - "scripts/toggle-file-scan-exclusions.mjs", - "scripts/file-exclusions.json", - "scripts/zed-settings-sync.mjs" - ) - if ($Path.StartsWith("/") -or $Path.Contains("\") -or $Path.Split("/") -contains "..") { - return $false - } - return $fixed -contains $Path -or $Path.StartsWith("snippets/") -or $Path.StartsWith("themes/") -} - -function Write-Atomic([string]$Path, [string]$Contents) { - $directory = Split-Path $Path -Parent - New-Item -ItemType Directory -Force -Path $directory | Out-Null - $temporary = Join-Path $directory ".$([IO.Path]::GetFileName($Path)).$PID.tmp" - try { - [IO.File]::WriteAllText($temporary, $Contents, $utf8) - Move-Item -LiteralPath $temporary -Destination $Path -Force - } finally { - Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue - } -} - -Require-Command "node" "winget install OpenJS.NodeJS.LTS" -if (-not $env:ZED_SETTINGS_BUNDLE_PATH) { - Require-Command "gh" "winget install GitHub.cli" - Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue - & gh auth status *> $null - if ($LASTEXITCODE -ne 0) { - Write-Host "GitHub authentication is required." - & gh auth login - if ($LASTEXITCODE -ne 0) { throw "GitHub authentication failed." } - } -} - -$entered = if ($env:ZED_SETTINGS_GIST_ID) { $env:ZED_SETTINGS_GIST_ID } else { Read-Host "GitHub Gist ID or URL (leave blank to create a new Secret Gist)" } -if ($env:ZED_SETTINGS_BUNDLE_PATH) { - $gistId = Get-GistId $entered - $bundleSource = [IO.File]::ReadAllText($env:ZED_SETTINGS_BUNDLE_PATH, [Text.Encoding]::UTF8) -} else { - $token = (& gh auth token).Trim() - if ($LASTEXITCODE -ne 0 -or -not $token) { throw "Could not obtain the GitHub authentication token from gh." } - $headers = @{ - Accept = "application/vnd.github+json" - Authorization = "Bearer $token" - "User-Agent" = "zed-settings-bootstrap" - "X-GitHub-Api-Version" = "2022-11-28" - } - if ([string]::IsNullOrWhiteSpace($entered)) { - $seedUrl = "https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json" - $bundleSource = (Invoke-WebRequest -UseBasicParsing -Uri $seedUrl).Content - $payload = @{ - description = "Zed settings sync" - public = $false - files = @{ $bundleName = @{ content = $bundleSource } } - } | ConvertTo-Json -Depth 5 - $created = Invoke-RestMethod -Method Post -Uri "https://api.github.com/gists" -Headers $headers -ContentType "application/json" -Body ([Text.Encoding]::UTF8.GetBytes($payload)) - $gistId = $created.id - Write-Host "Created Secret Gist: $($created.html_url)" - } else { - $gistId = Get-GistId $entered - $gist = Invoke-RestMethod -Uri "https://api.github.com/gists/$gistId" -Headers $headers - $remoteFile = $gist.files.$bundleName - if (-not $remoteFile) { throw "Gist $gistId does not contain $bundleName." } - if ($remoteFile.truncated) { throw "$bundleName is too large for the Gist API response." } - $bundleSource = [string]$remoteFile.content - } -} - -try { - $bundle = $bundleSource | ConvertFrom-Json -} catch { - throw "The Gist contains invalid JSON: $($_.Exception.Message)" -} - -$properties = @($bundle.PSObject.Properties) -foreach ($required in @("settings.json", "keymap.json", "tasks.json")) { - if (-not ($properties.Name -contains $required)) { throw "The bundle is missing $required." } -} -foreach ($property in $properties) { - if (-not (Test-AllowedPath $property.Name) -or $property.Value -isnot [string]) { - throw "Invalid bundle file: $($property.Name)" - } -} - -$target = Join-Path $env:APPDATA "Zed" -$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" -$backup = Join-Path $target "backups\gist-bootstrap-$timestamp" -New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null - -foreach ($property in $properties) { - $existing = Join-Path $target ($property.Name.Replace("/", "\")) - if (Test-Path -LiteralPath $existing) { - $backupPath = Join-Path $backup ($property.Name.Replace("/", "\")) - New-Item -ItemType Directory -Force -Path (Split-Path $backupPath -Parent) | Out-Null - Copy-Item -LiteralPath $existing -Destination $backupPath -Force - } -} - -$toggleScript = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") -$syncScript = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/") -foreach ($property in $properties) { - $destination = Join-Path $target ($property.Name.Replace("/", "\")) - $contents = [string]$property.Value - if ($property.Name -eq "tasks.json") { - $contents = $contents.Replace("__TOGGLE_SCRIPT__", $toggleScript).Replace("__SYNC_SCRIPT__", $syncScript) - } - Write-Atomic $destination $contents -} - -$bytes = $utf8.GetBytes($bundleSource) -$sha = [Security.Cryptography.SHA256]::Create() -try { - $hash = -join ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -} finally { - $sha.Dispose() -} -$syncState = [ordered]@{ gist_id = $gistId; last_synced_hash = $hash } | ConvertTo-Json -Write-Atomic (Join-Path $target "scripts\settings-sync.json") "$syncState`n" - -Write-Host "Installed Zed settings from Gist $gistId" -Write-Host "SHA-256: $hash" -Write-Host "Backup: $backup" -Write-Host "Restart Zed to load the synchronized configuration." diff --git a/bootstrap-from-gist.sh b/bootstrap-from-gist.sh deleted file mode 100644 index bfe5bd4..0000000 --- a/bootstrap-from-gist.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env sh -set -eu - -command -v node >/dev/null 2>&1 || { - printf '%s\n' 'Node.js is required. Install it with your system package manager.' >&2 - exit 1 -} -if [ -z "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then - command -v gh >/dev/null 2>&1 || { - printf '%s\n' 'GitHub CLI (gh) is required: https://cli.github.com/' >&2 - exit 1 - } - unset GH_TOKEN GITHUB_TOKEN - if ! gh auth status >/dev/null 2>&1; then - printf '%s\n' 'GitHub authentication is required.' - gh auth login /dev/tty - fi -fi - -if [ -n "${ZED_SETTINGS_GIST_ID:-}" ]; then - entered=$ZED_SETTINGS_GIST_ID -else - printf 'GitHub Gist ID or URL (leave blank to create a new Secret Gist): ' >/dev/tty - IFS= read -r entered "$response" - gist_id=$(node -e 'const g=require(process.argv[1]); process.stdout.write(g.id)' "$response") - gist_url=$(node -e 'const g=require(process.argv[1]); process.stdout.write(g.html_url)' "$response") - printf 'Created Secret Gist: %s\n' "$gist_url" -else - gist_id=$(printf '%s' "$entered" | sed -nE 's#.*[^a-fA-F0-9]([a-fA-F0-9]{8,})/?$#\1#p') - if [ -z "$gist_id" ]; then - gist_id=$(printf '%s' "$entered" | sed -nE 's#^([a-fA-F0-9]{8,})$#\1#p') - fi - if [ -z "$gist_id" ]; then - printf '%s\n' 'Enter a valid GitHub Gist ID or URL.' >&2 - exit 1 - fi - gh api "gists/$gist_id" > "$response" -fi - -if [ -z "${gist_id:-}" ]; then - gist_id=$(printf '%s' "$entered" | sed -nE 's#^([a-fA-F0-9]{8,})$#\1#p') -fi -if [ -z "$gist_id" ]; then - printf '%s\n' 'Enter a valid GitHub Gist ID or URL.' >&2 - exit 1 -fi - -node - "$response" "$gist_id" "$bundle_path" <<'NODE' -const { createHash } = require("node:crypto"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); - -const [responsePath, gistId, localBundlePath] = process.argv.slice(2); -const bundleName = "zed-settings-sync.json"; -let bundleSource; -if (localBundlePath) { - bundleSource = fs.readFileSync(localBundlePath, "utf8"); -} else { - const gist = JSON.parse(fs.readFileSync(responsePath, "utf8")); - const remoteFile = gist.files?.[bundleName]; - if (!remoteFile) throw new Error(`Gist ${gistId} does not contain ${bundleName}.`); - if (remoteFile.truncated) throw new Error(`${bundleName} is too large for the Gist API response.`); - bundleSource = remoteFile.content; -} -const bundle = JSON.parse(bundleSource); - -const fixed = new Set([ - "settings.json", - "keymap.json", - "tasks.json", - "debug.json", - "scripts/toggle-file-scan-exclusions.mjs", - "scripts/file-exclusions.json", - "scripts/zed-settings-sync.mjs", -]); -function allowed(relativePath) { - const parts = relativePath.split("/"); - return typeof relativePath === "string" - && !relativePath.startsWith("/") - && !relativePath.includes("\\") - && !parts.some((part) => !part || part === "." || part === "..") - && (fixed.has(relativePath) || relativePath.startsWith("snippets/") || relativePath.startsWith("themes/")); -} - -for (const required of ["settings.json", "keymap.json", "tasks.json"]) { - if (typeof bundle[required] !== "string") throw new Error(`The bundle is missing ${required}.`); -} -for (const [relativePath, contents] of Object.entries(bundle)) { - if (!allowed(relativePath) || typeof contents !== "string") throw new Error(`Invalid bundle file: ${relativePath}`); -} - -const target = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "zed"); -const stamp = new Date().toISOString().replace(/[:.]/g, "-"); -const backup = path.join(target, "backups", `gist-bootstrap-${stamp}`); -fs.mkdirSync(backup, { recursive: true }); - -for (const relativePath of Object.keys(bundle)) { - const existing = path.join(target, ...relativePath.split("/")); - if (!fs.existsSync(existing)) continue; - const backupPath = path.join(backup, ...relativePath.split("/")); - fs.mkdirSync(path.dirname(backupPath), { recursive: true }); - fs.copyFileSync(existing, backupPath); -} - -const toggleScript = path.join(target, "scripts", "toggle-file-scan-exclusions.mjs").replaceAll("\\", "/"); -const syncScript = path.join(target, "scripts", "zed-settings-sync.mjs").replaceAll("\\", "/"); -for (const [relativePath, sourceContents] of Object.entries(bundle)) { - const destination = path.join(target, ...relativePath.split("/")); - let contents = sourceContents; - if (relativePath === "tasks.json") { - contents = contents.replaceAll("__TOGGLE_SCRIPT__", toggleScript).replaceAll("__SYNC_SCRIPT__", syncScript); - } - fs.mkdirSync(path.dirname(destination), { recursive: true }); - const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.tmp`); - fs.writeFileSync(temporary, contents, "utf8"); - fs.renameSync(temporary, destination); -} - -const hash = createHash("sha256").update(bundleSource).digest("hex"); -const syncStatePath = path.join(target, "scripts", "settings-sync.json"); -fs.mkdirSync(path.dirname(syncStatePath), { recursive: true }); -fs.writeFileSync(syncStatePath, `${JSON.stringify({ gist_id: gistId.toLowerCase(), last_synced_hash: hash }, null, 2)}\n`, "utf8"); - -console.log(`Installed Zed settings from Gist ${gistId}`); -console.log(`SHA-256: ${hash}`); -console.log(`Backup: ${backup}`); -console.log("Restart Zed to load the synchronized configuration."); -NODE diff --git a/bootstrap.ps1 b/bootstrap.ps1 new file mode 100644 index 0000000..7e978ea --- /dev/null +++ b/bootstrap.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$bundleUrl = "https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json" +$utf8 = New-Object System.Text.UTF8Encoding($false) + +function Require-Command([string]$Name, [string]$InstallHint) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "$Name is required. Install it first: $InstallHint" + } +} + +function Test-AllowedPath([string]$Path) { + $fixed = @( + "settings.json", + "keymap.json", + "tasks.json", + "debug.json", + "scripts/toggle-file-scan-exclusions.mjs", + "scripts/file-exclusions.json", + "scripts/zed-settings-sync.mjs" + ) + $segments = $Path.Split("/") + if ( + $Path.StartsWith("/") -or + $Path.Contains("\") -or + $segments -contains "" -or + $segments -contains "." -or + $segments -contains ".." + ) { + return $false + } + return $fixed -contains $Path -or $Path.StartsWith("snippets/") -or $Path.StartsWith("themes/") +} + +function Write-Atomic([string]$Path, [string]$Contents) { + $directory = Split-Path $Path -Parent + New-Item -ItemType Directory -Force -Path $directory | Out-Null + $temporary = Join-Path $directory ".$([IO.Path]::GetFileName($Path)).$PID.tmp" + try { + [IO.File]::WriteAllText($temporary, $Contents, $utf8) + Move-Item -LiteralPath $temporary -Destination $Path -Force + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } +} + +Require-Command "node" "winget install OpenJS.NodeJS.LTS" +if ($env:ZED_SETTINGS_BUNDLE_PATH) { + $bundleSource = [IO.File]::ReadAllText($env:ZED_SETTINGS_BUNDLE_PATH, [Text.Encoding]::UTF8) +} else { + Write-Host "Downloading the public Zed configuration bundle..." + $bundleSource = (Invoke-WebRequest -UseBasicParsing -Uri $bundleUrl).Content +} +try { + $bundle = $bundleSource | ConvertFrom-Json +} catch { + throw "The downloaded bundle contains invalid JSON: $($_.Exception.Message)" +} + +$properties = @($bundle.PSObject.Properties) +foreach ($required in @("settings.json", "keymap.json", "tasks.json")) { + if (-not ($properties.Name -contains $required)) { throw "The bundle is missing $required." } +} +foreach ($property in $properties) { + if (-not (Test-AllowedPath $property.Name) -or $property.Value -isnot [string]) { + throw "Invalid bundle file: $($property.Name)" + } +} + +$target = Join-Path $env:APPDATA "Zed" +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$backup = Join-Path $target "backups\bootstrap-$timestamp" +New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null + +foreach ($property in $properties) { + $existing = Join-Path $target ($property.Name.Replace("/", "\")) + if (Test-Path -LiteralPath $existing) { + $backupPath = Join-Path $backup ($property.Name.Replace("/", "\")) + New-Item -ItemType Directory -Force -Path (Split-Path $backupPath -Parent) | Out-Null + Copy-Item -LiteralPath $existing -Destination $backupPath -Force + } +} + +$toggleScript = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") +$syncScript = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/") +foreach ($property in $properties) { + $destination = Join-Path $target ($property.Name.Replace("/", "\")) + $contents = [string]$property.Value + if ($property.Name -eq "tasks.json") { + $contents = $contents.Replace("__TOGGLE_SCRIPT__", $toggleScript).Replace("__SYNC_SCRIPT__", $syncScript) + } + Write-Atomic $destination $contents +} + +$statePath = Join-Path $target "scripts\settings-sync.json" +if (-not (Test-Path -LiteralPath $statePath)) { + $state = [ordered]@{ + gist_id = "" + last_synced_hash = "" + auto_sync = $false + interval_minutes = 15 + last_run_at = "" + last_result = "" + } | ConvertTo-Json + Write-Atomic $statePath "$state`n" +} else { + $existingState = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json + $state = [ordered]@{ + gist_id = if ($existingState.gist_id -is [string]) { $existingState.gist_id } else { "" } + last_synced_hash = if ($existingState.last_synced_hash -is [string]) { $existingState.last_synced_hash } else { "" } + auto_sync = $existingState.auto_sync -eq $true + interval_minutes = if ($existingState.interval_minutes -is [int] -and $existingState.interval_minutes -ge 5) { $existingState.interval_minutes } else { 15 } + last_run_at = if ($existingState.last_run_at -is [string]) { $existingState.last_run_at } else { "" } + last_result = if ($existingState.last_result -is [string]) { $existingState.last_result } else { "" } + } | ConvertTo-Json + Write-Atomic $statePath "$state`n" +} + +Write-Host "" +Write-Host "Installed Zed settings to $target" +Write-Host "Previous settings were backed up to $backup" +Write-Host "GitHub and Gist setup are optional and did not block installation." + +if ($env:ZED_SYNC_SKIP_SETUP -ne "1") { + try { + $answer = Read-Host "Run the GitHub sync setup now? [Y/n]" + if ([string]::IsNullOrWhiteSpace($answer) -or $answer -match "^[Yy]") { + & node $syncScript setup + if ($LASTEXITCODE -ne 0) { + Write-Warning "Sync setup did not complete. Local installation is still valid; run the setup Task from Zed later." + } + } + } catch { + Write-Warning "Could not start interactive sync setup. Run 'Zed Settings: Set Up / Reconfigure Sync...' from Zed later." + } +} + +Write-Host "Restart or reload Zed to use the installed configuration." diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100644 index 0000000..08dcdbd --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh +set -eu + +bundle_url='https://git.okk.cool/purp1e/zed-sync/raw/branch/master/zed-settings-sync.json' +command -v node >/dev/null 2>&1 || { + printf '%s\n' 'Node.js is required. Install it with your system package manager.' >&2 + exit 1 +} +command -v curl >/dev/null 2>&1 || { + printf '%s\n' 'curl is required. Install it with your system package manager.' >&2 + exit 1 +} + +temporary_dir=$(mktemp -d) +trap 'rm -rf "$temporary_dir"' EXIT INT TERM +bundle_path="$temporary_dir/zed-settings-sync.json" +if [ -n "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then + bundle_path=$ZED_SETTINGS_BUNDLE_PATH +else + printf '%s\n' 'Downloading the public Zed configuration bundle...' + curl -fsSL "$bundle_url" -o "$bundle_path" +fi + +node - "$bundle_path" <<'NODE' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const bundlePath = process.argv[2]; +const source = fs.readFileSync(bundlePath, "utf8"); +const bundle = JSON.parse(source); +const fixed = new Set([ + "settings.json", + "keymap.json", + "tasks.json", + "debug.json", + "scripts/toggle-file-scan-exclusions.mjs", + "scripts/file-exclusions.json", + "scripts/zed-settings-sync.mjs", +]); +function allowed(relativePath) { + const parts = relativePath.split("/"); + return typeof relativePath === "string" + && !relativePath.startsWith("/") + && !relativePath.includes("\\") + && !parts.some((part) => !part || part === "." || part === "..") + && (fixed.has(relativePath) || relativePath.startsWith("snippets/") || relativePath.startsWith("themes/")); +} +for (const required of ["settings.json", "keymap.json", "tasks.json"]) { + if (typeof bundle[required] !== "string") throw new Error(`The bundle is missing ${required}.`); +} +for (const [relativePath, contents] of Object.entries(bundle)) { + if (!allowed(relativePath) || typeof contents !== "string") throw new Error(`Invalid bundle file: ${relativePath}`); +} + +const target = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "zed"); +const stamp = new Date().toISOString().replace(/[:.]/g, "-"); +const backup = path.join(target, "backups", `bootstrap-${stamp}`); +fs.mkdirSync(backup, { recursive: true }); +for (const relativePath of Object.keys(bundle)) { + const existing = path.join(target, ...relativePath.split("/")); + if (!fs.existsSync(existing)) continue; + const backupPath = path.join(backup, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(existing, backupPath); +} + +const toggleScript = path.join(target, "scripts", "toggle-file-scan-exclusions.mjs").replaceAll("\\", "/"); +const syncScript = path.join(target, "scripts", "zed-settings-sync.mjs").replaceAll("\\", "/"); +for (const [relativePath, sourceContents] of Object.entries(bundle)) { + const destination = path.join(target, ...relativePath.split("/")); + const contents = relativePath === "tasks.json" + ? sourceContents.replaceAll("__TOGGLE_SCRIPT__", toggleScript).replaceAll("__SYNC_SCRIPT__", syncScript) + : sourceContents; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.tmp`); + fs.writeFileSync(temporary, contents, "utf8"); + fs.renameSync(temporary, destination); +} + +const statePath = path.join(target, "scripts", "settings-sync.json"); +const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, "utf8")) : {}; +const state = { + gist_id: typeof previousState.gist_id === "string" ? previousState.gist_id : "", + last_synced_hash: typeof previousState.last_synced_hash === "string" ? previousState.last_synced_hash : "", + auto_sync: previousState.auto_sync === true, + interval_minutes: Number.isInteger(previousState.interval_minutes) && previousState.interval_minutes >= 5 ? previousState.interval_minutes : 15, + last_run_at: typeof previousState.last_run_at === "string" ? previousState.last_run_at : "", + last_result: typeof previousState.last_result === "string" ? previousState.last_result : "", +}; +fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +console.log(`Installed Zed settings to ${target}`); +console.log(`Previous settings were backed up to ${backup}`); +console.log("GitHub and Gist setup are optional and did not block installation."); +NODE + +target="${XDG_CONFIG_HOME:-$HOME/.config}/zed" +if [ "${ZED_SYNC_SKIP_SETUP:-0}" != "1" ] && [ -r /dev/tty ]; then + printf 'Run the GitHub sync setup now? [Y/n] ' >/dev/tty + IFS= read -r answer /dev/tty; then + printf '%s\n' 'Sync setup did not complete. Local installation is still valid; run the setup Task from Zed later.' >&2 + fi + ;; + esac +fi + +printf '%s\n' 'Restart or reload Zed to use the installed configuration.' diff --git a/install.ps1 b/install.ps1 index 5507515..2829d21 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,6 +25,17 @@ Copy-Item -LiteralPath (Join-Path $source "scripts\zed-settings-sync.mjs") -Dest $syncConfig = Join-Path $target "scripts\settings-sync.json" if (-not (Test-Path -LiteralPath $syncConfig)) { Copy-Item -LiteralPath (Join-Path $source "scripts\settings-sync.json") -Destination $syncConfig +} else { + $existingState = Get-Content -LiteralPath $syncConfig -Raw | ConvertFrom-Json + $migratedState = [ordered]@{ + gist_id = if ($existingState.gist_id -is [string]) { $existingState.gist_id } else { "" } + last_synced_hash = if ($existingState.last_synced_hash -is [string]) { $existingState.last_synced_hash } else { "" } + auto_sync = $existingState.auto_sync -eq $true + interval_minutes = if ($existingState.interval_minutes -is [int] -and $existingState.interval_minutes -ge 5) { $existingState.interval_minutes } else { 15 } + last_run_at = if ($existingState.last_run_at -is [string]) { $existingState.last_run_at } else { "" } + last_result = if ($existingState.last_result -is [string]) { $existingState.last_result } else { "" } + } | ConvertTo-Json + [IO.File]::WriteAllText($syncConfig, "$migratedState`n", $utf8) } $toggleScriptPath = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") diff --git a/install.sh b/install.sh index de0a0d5..0a71f59 100644 --- a/install.sh +++ b/install.sh @@ -22,6 +22,21 @@ if [ ! -f "$target/scripts/settings-sync.json" ]; then cp "$source_dir/scripts/settings-sync.json" "$target/scripts/settings-sync.json" fi +node - "$target/scripts/settings-sync.json" <<'NODE_STATE' +const fs = require("node:fs"); +const statePath = process.argv[2]; +const source = JSON.parse(fs.readFileSync(statePath, "utf8")); +const state = { + gist_id: typeof source.gist_id === "string" ? source.gist_id : "", + last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", + auto_sync: source.auto_sync === true, + interval_minutes: Number.isInteger(source.interval_minutes) && source.interval_minutes >= 5 ? source.interval_minutes : 15, + last_run_at: typeof source.last_run_at === "string" ? source.last_run_at : "", + last_result: typeof source.last_result === "string" ? source.last_result : "", +}; +fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +NODE_STATE + node - "$source_dir/tasks.json" "$target/tasks.json" "$target/scripts/toggle-file-scan-exclusions.mjs" "$target/scripts/zed-settings-sync.mjs" <<'NODE' const fs = require("node:fs"); const [source, target, toggleScript, syncScript] = process.argv.slice(2); diff --git a/keymap.json b/keymap.json index f922309..4248a52 100644 --- a/keymap.json +++ b/keymap.json @@ -32,11 +32,11 @@ "secondary-; a": "git::StageAll", "secondary-; u": [ "task::Spawn", - { "task_name": "Zed Settings: Push to GitHub Gist" } + { "task_name": "Zed Settings: Push Now" } ], "secondary-; d": [ "task::Spawn", - { "task_name": "Zed Settings: Pull from GitHub Gist" } + { "task_name": "Zed Settings: Pull Now" } ], "shift-alt--": [ "task::Spawn", diff --git a/scripts/settings-sync.json b/scripts/settings-sync.json index 75925cb..604eb14 100644 --- a/scripts/settings-sync.json +++ b/scripts/settings-sync.json @@ -1,4 +1,8 @@ { "gist_id": "", - "last_synced_hash": "" + "last_synced_hash": "", + "auto_sync": false, + "interval_minutes": 15, + "last_run_at": "", + "last_result": "" } diff --git a/scripts/zed-settings-sync.mjs b/scripts/zed-settings-sync.mjs index 36d57ab..9a1ffb4 100644 --- a/scripts/zed-settings-sync.mjs +++ b/scripts/zed-settings-sync.mjs @@ -1,11 +1,23 @@ import { createHash } from "node:crypto"; -import { copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { + appendFile, + copyFile, + mkdir, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; const BUNDLE_NAME = "zed-settings-sync.json"; +const GIST_DESCRIPTION = "Zed settings sync"; const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"]; const FIXED_FILES = [ ...REQUIRED_FILES, @@ -16,8 +28,20 @@ const FIXED_FILES = [ ]; const OPTIONAL_DIRECTORIES = ["snippets", "themes"]; const LOCAL_SYNC_CONFIG = "scripts/settings-sync.json"; +const DEFAULT_SYNC_CONFIG = { + gist_id: "", + last_synced_hash: "", + auto_sync: false, + interval_minutes: 15, + last_run_at: "", + last_result: "", +}; +const LOCK_MAX_AGE_MS = 10 * 60 * 1000; +const LOG_MAX_BYTES = 1024 * 1024; +const SCHEDULER_NAME = "ZedSettingsSync"; function zedConfigDirectory() { + if (process.env.ZED_CONFIG_DIR) return resolve(process.env.ZED_CONFIG_DIR); if (process.platform === "win32") { if (!process.env.APPDATA) throw new Error("APPDATA is not set"); return join(process.env.APPDATA, "Zed"); @@ -29,6 +53,10 @@ function timestamp() { return new Date().toISOString().replace(/[:.]/g, "-"); } +function now() { + return new Date().toISOString(); +} + async function writeAtomically(path, contents) { await mkdir(dirname(path), { recursive: true }); const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); @@ -133,9 +161,7 @@ function parseBundle(contents, sourceName) { throw new Error(`Invalid Zed settings bundle: ${sourceName}`); } for (const required of REQUIRED_FILES) { - if (typeof files[required] !== "string") { - throw new Error(`Invalid bundle: ${required} is missing`); - } + if (typeof files[required] !== "string") throw new Error(`Invalid bundle: ${required} is missing`); } for (const [path, value] of Object.entries(files)) { if (!isAllowedBundlePath(path) || typeof value !== "string") { @@ -170,7 +196,6 @@ async function installBundleContents(contents, sourceName) { const configDirectory = zedConfigDirectory(); const backupDirectory = await backupCurrentConfiguration(configDirectory); for (const [relativePath, sourceContents] of Object.entries(parsed.files)) { - // Gist identity and sync history belong to each machine and are never imported. if (relativePath === LOCAL_SYNC_CONFIG) continue; const destination = join(configDirectory, ...relativePath.split("/")); const installedContents = relativePath === "tasks.json" @@ -198,6 +223,7 @@ function runPowerShell(command, extraEnv = {}) { } function nativeFileDialog(mode) { + if (process.env.ZED_SYNC_NO_DIALOGS === "1") return ""; if (process.platform === "win32") { const common = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); "; const command = mode === "save" @@ -218,91 +244,6 @@ function nativeFileDialog(mode) { return result.status === 0 ? result.stdout.trim() : ""; } -function nativeInput(prompt, title, defaultValue = "") { - if (process.platform === "win32") { - const command = ` - [Console]::OutputEncoding=[Text.Encoding]::UTF8 - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - $f=New-Object System.Windows.Forms.Form - $f.Text=$env:ZED_SYNC_TITLE - $f.Size=New-Object System.Drawing.Size(620,190) - $f.StartPosition='CenterScreen' - $f.TopMost=$true - $f.ShowInTaskbar=$true - $f.FormBorderStyle='FixedDialog' - $f.MaximizeBox=$false - $f.MinimizeBox=$false - $l=New-Object System.Windows.Forms.Label - $l.Text=$env:ZED_SYNC_PROMPT - $l.AutoSize=$false - $l.Location=New-Object System.Drawing.Point(14,14) - $l.Size=New-Object System.Drawing.Size(575,42) - $t=New-Object System.Windows.Forms.TextBox - $t.Text=$env:ZED_SYNC_DEFAULT - $t.Location=New-Object System.Drawing.Point(14,62) - $t.Size=New-Object System.Drawing.Size(575,24) - $ok=New-Object System.Windows.Forms.Button - $ok.Text='OK' - $ok.DialogResult=[System.Windows.Forms.DialogResult]::OK - $ok.Location=New-Object System.Drawing.Point(427,105) - $cancel=New-Object System.Windows.Forms.Button - $cancel.Text='Cancel' - $cancel.DialogResult=[System.Windows.Forms.DialogResult]::Cancel - $cancel.Location=New-Object System.Drawing.Point(514,105) - $f.Controls.AddRange(@($l,$t,$ok,$cancel)) - $f.AcceptButton=$ok - $f.CancelButton=$cancel - $f.Add_Shown({$f.Activate();$t.Focus();$t.SelectAll()}) - if($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){[Console]::Write($t.Text)} - `; - return runPowerShell(command, { - ZED_SYNC_PROMPT: prompt, - ZED_SYNC_TITLE: title, - ZED_SYNC_DEFAULT: defaultValue, - }); - } - if (process.platform === "darwin") { - const escaped = (value) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); - const script = `text returned of (display dialog "${escaped(prompt)}" with title "${escaped(title)}" default answer "${escaped(defaultValue)}")`; - const result = spawnSync("osascript", ["-e", script], { encoding: "utf8" }); - return result.status === 0 ? result.stdout.trim() : ""; - } - const result = spawnSync("zenity", ["--entry", `--title=${title}`, `--text=${prompt}`, `--entry-text=${defaultValue}`], { encoding: "utf8" }); - return result.status === 0 ? result.stdout.trim() : ""; -} - -function nativeMessage(message, title = "Zed Settings Sync", error = false) { - if (process.platform === "win32") { - const command = "Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [void][System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON); $o.Close()"; - runPowerShell(command, { - ZED_SYNC_MESSAGE: message, - ZED_SYNC_TITLE: title, - ZED_SYNC_ICON: error ? "Error" : "Information", - }); - return; - } - if (process.platform === "darwin") { - const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); - spawnSync("osascript", ["-e", `display dialog "${escaped}" with title "${title}" buttons {"OK"}`]); - return; - } - spawnSync("zenity", [error ? "--error" : "--info", `--title=${title}`, `--text=${message}`]); -} - -function nativeConfirm(message, title = "Zed Settings Sync") { - if (process.platform === "win32") { - const command = "Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [Console]::Write([System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning')); $o.Close()"; - return runPowerShell(command, { ZED_SYNC_MESSAGE: message, ZED_SYNC_TITLE: title }) === "Yes"; - } - if (process.platform === "darwin") { - const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); - const result = spawnSync("osascript", ["-e", `button returned of (display dialog "${escaped}" with title "${title}" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel")`], { encoding: "utf8" }); - return result.status === 0 && result.stdout.trim() === "Continue"; - } - return spawnSync("zenity", ["--question", `--title=${title}`, `--text=${message}`]).status === 0; -} - async function readSyncConfiguration() { const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split("/")); let source = {}; @@ -314,8 +255,15 @@ async function readSyncConfiguration() { return { path, config: { + ...DEFAULT_SYNC_CONFIG, gist_id: typeof source.gist_id === "string" ? source.gist_id : "", last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", + auto_sync: source.auto_sync === true, + interval_minutes: Number.isInteger(source.interval_minutes) && source.interval_minutes >= 5 + ? source.interval_minutes + : DEFAULT_SYNC_CONFIG.interval_minutes, + last_run_at: typeof source.last_run_at === "string" ? source.last_run_at : "", + last_result: typeof source.last_result === "string" ? source.last_result : "", }, }; } @@ -330,140 +278,526 @@ function normalizeGistId(value) { return matches.at(-1).toLowerCase(); } -async function promptForGist() { - const state = await readSyncConfiguration(); - console.log("Opening Gist configuration dialog..."); - const entered = nativeInput( - "Paste the GitHub Gist ID or full Gist URL. The previous value is remembered on this machine.", - "Zed Settings Gist", - state.config.gist_id, - ); - if (!entered) return null; - const gistId = normalizeGistId(entered); - if (gistId !== state.config.gist_id) { - state.config.gist_id = gistId; - state.config.last_synced_hash = ""; - await saveSyncConfiguration(state.path, state.config); - } - return state; -} - -function runGh(args, input) { +function sanitizedEnvironment() { const env = { ...process.env }; delete env.GH_TOKEN; delete env.GITHUB_TOKEN; - const result = spawnSync("gh", args, { encoding: "utf8", env, input, windowsHide: true }); - if (result.error?.code === "ENOENT") { - throw new Error("GitHub CLI (gh) is not installed. Install it, then run: gh auth login"); + return env; +} + +function execute(command, args, options = {}) { + return spawnSync(command, args, { + encoding: options.stdio === "inherit" ? undefined : "utf8", + env: options.env || process.env, + input: options.input, + stdio: options.stdio || "pipe", + windowsHide: options.windowsHide ?? true, + }); +} + +function ghMissingMessage() { + if (process.platform === "win32") return "GitHub CLI is required. Install it with: winget install GitHub.cli"; + if (process.platform === "darwin") return "GitHub CLI is required. Install it with: brew install gh"; + return "GitHub CLI is required. Install it from https://cli.github.com/"; +} + +function executeGh(args, options = {}) { + if (process.env.ZED_SYNC_GH_SCRIPT) { + return execute(process.execPath, [process.env.ZED_SYNC_GH_SCRIPT, ...args], options); } - if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`); + return execute("gh", args, options); +} + +function testGhAuthentication(env) { + const user = executeGh(["api", "user", "--jq", ".login"], { env }); + if (user.error?.code === "ENOENT") return { ok: false, missing: true, message: ghMissingMessage() }; + if (user.status !== 0) { + return { ok: false, login: "", message: (user.stderr || user.stdout || "GitHub authentication failed").trim() }; + } + const gists = executeGh(["api", "gists?per_page=1", "--silent"], { env }); + return { + ok: gists.status === 0, + login: user.stdout.trim(), + message: gists.status === 0 ? "" : (gists.stderr || gists.stdout || "The GitHub credential cannot access Gists").trim(), + }; +} + +async function resolveGhAuthentication({ interactive = false, requireStored = false } = {}) { + const hasEnvironmentToken = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN); + if (!requireStored) { + const current = testGhAuthentication(process.env); + if (current.missing) throw new Error(current.message); + if (current.ok) { + return { env: process.env, login: current.login, source: hasEnvironmentToken ? "environment" : "credential-store" }; + } + if (hasEnvironmentToken) console.log("The inherited GitHub token is invalid; trying stored GitHub CLI credentials."); + } + + const cleanEnv = sanitizedEnvironment(); + const stored = testGhAuthentication(cleanEnv); + if (stored.missing) throw new Error(stored.message); + if (stored.ok) return { env: cleanEnv, login: stored.login, source: "credential-store" }; + if (!interactive) throw new Error("GitHub is not authenticated. Run 'Zed Settings: Set Up / Reconfigure Sync...'."); + + console.log("Opening GitHub browser authentication. No personal access token needs to be pasted."); + const authenticationArgs = stored.login + ? ["auth", "refresh", "--hostname", "github.com", "--scopes", "gist"] + : ["auth", "login", "--hostname", "github.com", "--web", "--clipboard", "--scopes", "gist"]; + const login = executeGh( + authenticationArgs, + { env: cleanEnv, stdio: "inherit", windowsHide: false }, + ); + if (login.status !== 0) throw new Error("GitHub browser authentication was cancelled or failed."); + const verified = testGhAuthentication(cleanEnv); + if (!verified.ok) throw new Error(verified.message || "GitHub authentication could not be verified."); + return { env: cleanEnv, login: verified.login, source: "credential-store" }; +} + +function runGh(args, auth, input) { + const result = executeGh(args, { env: auth.env, input }); + if (result.error?.code === "ENOENT") throw new Error(ghMissingMessage()); + if (result.status !== 0) throw new Error((result.stderr || result.stdout || `gh ${args[0]} failed`).trim()); return result.stdout.trim(); } -function readRemoteGist(gistId) { - const gist = JSON.parse(runGh(["api", `gists/${gistId}`])); +function readRemoteGist(gistId, auth) { + const gist = JSON.parse(runGh(["api", `gists/${gistId}`], auth)); const file = gist.files?.[BUNDLE_NAME]; if (!file) return { gist, parsed: null, contents: null }; if (file.truncated) throw new Error(`${BUNDLE_NAME} is too large to read through the Gist API`); return { gist, parsed: parseBundle(file.content, `Gist ${gistId}`), contents: file.content }; } -function updateRemoteGist(gistId, contents) { +function updateRemoteGist(gistId, contents, auth) { const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } }); - return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload)); + return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], auth, payload)); } -function createRemoteGist(contents) { +function createRemoteGist(contents, auth) { const payload = JSON.stringify({ - description: "Zed settings sync", + description: GIST_DESCRIPTION, public: false, files: { [BUNDLE_NAME]: { content: contents } }, }); - return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], payload)); + return JSON.parse(runGh(["api", "--method", "POST", "gists", "--input", "-"], auth, payload)); +} + +function discoverGists(auth) { + const raw = runGh(["api", "--paginate", "--slurp", "gists?per_page=100"], auth); + const pages = JSON.parse(raw); + const gists = (Array.isArray(pages[0]) ? pages.flat() : pages) + .filter((gist) => gist.files?.[BUNDLE_NAME]) + .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))); + return gists; } function shortHash(hash) { return hash ? hash.slice(0, 12) : "none"; } -async function configureGist() { - const state = await promptForGist(); - if (!state) return; - nativeMessage(`Saved Gist ID:\n${state.config.gist_id}\n\nAuthentication is managed by GitHub CLI (gh).`); +function classifySync(localHash, remoteHash, lastHash) { + if (!remoteHash) return "remote-missing"; + if (localHash === remoteHash) return "synchronized"; + if (!lastHash) return "no-baseline"; + if (localHash === lastHash) return "remote-changed"; + if (remoteHash === lastHash) return "local-changed"; + return "conflict"; +} + +let scriptedAnswers; +async function ask(question, defaultValue = "") { + if (scriptedAnswers === undefined) { + try { + scriptedAnswers = JSON.parse(process.env.ZED_SYNC_ANSWERS || "[]"); + } catch { + scriptedAnswers = []; + } + } + if (scriptedAnswers.length) { + const answer = String(scriptedAnswers.shift()); + console.log(`${question}${answer}`); + return answer; + } + if (!process.stdin.isTTY) throw new Error("Interactive input is required. Run this command from the Zed Task Picker."); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await rl.question(question)).trim() || defaultValue; + } finally { + rl.close(); + } +} + +async function askYesNo(question, defaultYes = false) { + const answer = (await ask(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `, defaultYes ? "y" : "n")).toLowerCase(); + return answer === "y" || answer === "yes"; +} + +async function appendLog(message) { + const logDirectory = join(zedConfigDirectory(), "logs"); + const logPath = join(logDirectory, "settings-sync.log"); + await mkdir(logDirectory, { recursive: true }); + try { + if ((await stat(logPath)).size > LOG_MAX_BYTES) { + await rm(`${logPath}.3`, { force: true }); + if (existsSync(`${logPath}.2`)) await rename(`${logPath}.2`, `${logPath}.3`); + if (existsSync(`${logPath}.1`)) await rename(`${logPath}.1`, `${logPath}.2`); + await rename(logPath, `${logPath}.1`); + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + await appendFile(logPath, `${now()} ${message}\n`, "utf8"); +} + +async function recordResult(state, result) { + state.config.last_run_at = now(); + state.config.last_result = result; + await saveSyncConfiguration(state.path, state.config); + await appendLog(result); +} + +function notify(message) { + if (process.env.ZED_SYNC_NO_NOTIFICATIONS === "1") return; + if (process.platform === "win32") { + execute("msg.exe", [process.env.USERNAME || "*", message]); + } else if (process.platform === "darwin") { + const escaped = message.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + execute("osascript", ["-e", `display notification "${escaped}" with title "Zed Settings Sync"`]); + } else { + execute("notify-send", ["Zed Settings Sync", message]); + } +} + +async function withSyncLock(action) { + const lockPath = join(zedConfigDirectory(), "scripts", ".settings-sync.lock"); + try { + await mkdir(lockPath, { recursive: false }); + } catch (error) { + if (error.code !== "EEXIST") throw error; + const age = Date.now() - (await stat(lockPath)).mtimeMs; + if (age <= LOCK_MAX_AGE_MS) { + console.log("Another settings sync is already running."); + return null; + } + await rm(lockPath, { recursive: true, force: true }); + await mkdir(lockPath); + } + try { + await writeFile(join(lockPath, "owner.json"), JSON.stringify({ pid: process.pid, started_at: now() })); + return await action(); + } finally { + await rm(lockPath, { recursive: true, force: true }); + } +} + +async function selectSetupGist(gists, state) { + console.log("\nAvailable Zed settings Gists:"); + gists.forEach((gist, index) => { + const current = gist.id === state.config.gist_id ? " (current)" : ""; + console.log(` ${index + 1}. ${gist.description || "Untitled"} - ${gist.id}${current} - ${gist.updated_at}`); + }); + console.log(" N. Create a new Secret Gist from this machine"); + console.log(" M. Enter another Gist URL or ID"); + console.log(" Q. Cancel"); + const defaultChoice = gists.length === 1 ? "1" : (gists.length === 0 ? "n" : "q"); + const choice = (await ask(`Choose [${defaultChoice.toUpperCase()}]: `, defaultChoice)).toLowerCase(); + if (choice === "q") return { action: "cancel" }; + if (choice === "n") return { action: "create" }; + if (choice === "m") return { action: "existing", gistId: normalizeGistId(await ask("Gist URL or ID: ")) }; + const index = Number.parseInt(choice, 10) - 1; + if (!Number.isInteger(index) || !gists[index]) throw new Error("Invalid Gist selection"); + return { action: "existing", gistId: gists[index].id }; +} + +async function setupSync({ offerAutomaticSync = true } = {}) { + console.log("Zed Settings Sync Setup\n"); + const auth = await resolveGhAuthentication({ interactive: true }); + console.log(`Connected to GitHub as ${auth.login} (${auth.source}).`); + const state = await readSyncConfiguration(); + const gists = discoverGists(auth); + if (state.config.gist_id && !gists.some((gist) => gist.id === state.config.gist_id)) { + try { + gists.unshift(readRemoteGist(state.config.gist_id, auth).gist); + } catch (error) { + console.log(`The currently configured Gist could not be loaded: ${error.message}`); + } + } + const selection = await selectSetupGist(gists, state); + if (selection.action === "cancel") { + console.log("Setup cancelled. Local Zed settings remain installed and unchanged."); + return; + } + + const local = await createBundle(); + if (selection.action === "create") { + const created = createRemoteGist(local.contents, auth); + state.config.gist_id = created.id; + state.config.last_synced_hash = local.hash; + await recordResult(state, `Created and uploaded Secret Gist ${created.id}`); + console.log(`\nCreated Secret Gist: ${created.html_url || created.id}`); + console.log(`SHA-256: ${local.hash}`); + } else { + const remote = readRemoteGist(selection.gistId, auth); + console.log(`\nRemote: ${remote.gist.html_url || selection.gistId}`); + state.config.gist_id = selection.gistId; + if (!remote.parsed) { + console.log(`This Gist does not contain ${BUNDLE_NAME}.`); + if (!await askYesNo("Upload this machine's current Zed settings to it?", true)) return; + updateRemoteGist(selection.gistId, local.contents, auth); + state.config.last_synced_hash = local.hash; + await recordResult(state, `Initialized Gist ${selection.gistId} with this machine's settings`); + } else { + console.log(`Local SHA-256: ${shortHash(local.hash)}`); + console.log(`Remote SHA-256: ${shortHash(remote.parsed.hash)}`); + console.log(" P. Pull remote settings onto this machine (backup first)"); + console.log(" U. Upload this machine's settings to the Gist"); + console.log(" Q. Cancel"); + const direction = (await ask("Initial sync direction [P]: ", "p")).toLowerCase(); + if (direction === "q") return; + if (direction === "p") { + if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${selection.gistId}`); + state.config.last_synced_hash = remote.parsed.hash; + await recordResult(state, `Pulled settings from Gist ${selection.gistId}`); + } else if (direction === "u") { + updateRemoteGist(selection.gistId, local.contents, auth); + state.config.last_synced_hash = local.hash; + await recordResult(state, `Uploaded settings to Gist ${selection.gistId}`); + } else { + throw new Error("Invalid initial sync direction"); + } + } + await saveSyncConfiguration(state.path, state.config); + console.log(`Configured Gist ${selection.gistId}.`); + } + + if (offerAutomaticSync) { + if (await askYesNo("Enable automatic synchronization every 15 minutes?", false)) { + await enableAutomaticSync(); + } else { + console.log("Automatic synchronization remains disabled. Use Push Now and Pull Now from the Task Picker."); + } + } +} + +async function requireConfiguredState() { + const state = await readSyncConfiguration(); + if (state.config.gist_id) return state; + console.log("No Gist is configured. Starting setup...\n"); + await setupSync(); + const configured = await readSyncConfiguration(); + return configured.config.gist_id ? configured : null; } async function pushGist() { - const state = await readSyncConfiguration(); - const localBundle = await createBundle(); - if (!state.config.gist_id) { - console.log("No Gist is configured. Asking to create one..."); - const proceed = nativeConfirm( - "No GitHub Gist is configured. Create a new Secret Gist from this machine's current Zed settings?", - ); - if (!proceed) return; - const created = createRemoteGist(localBundle.contents); - state.config.gist_id = created.id; - state.config.last_synced_hash = localBundle.hash; - await saveSyncConfiguration(state.path, state.config); - nativeMessage(`Created and uploaded Secret Gist:\n${created.html_url || created.id}\n\nSHA-256: ${localBundle.hash}`); - return; - } - const remote = readRemoteGist(state.config.gist_id); - const remoteHash = remote.parsed?.hash || ""; - const lastHash = state.config.last_synced_hash; - if (remoteHash && remoteHash !== localBundle.hash) { - const remoteChanged = !lastHash || remoteHash !== lastHash; - if (remoteChanged) { - const proceed = nativeConfirm( - `The Gist contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\n\nOverwrite the Gist with this machine's settings?`, - ); - if (!proceed) return; + return withSyncLock(async () => { + const state = await requireConfiguredState(); + if (!state) return; + const auth = await resolveGhAuthentication({ interactive: true }); + const local = await createBundle(); + const remote = readRemoteGist(state.config.gist_id, auth); + const remoteHash = remote.parsed?.hash || ""; + const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash); + if (["remote-changed", "conflict", "no-baseline"].includes(status)) { + console.log(`Remote settings differ (${status}).`); + console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remoteHash)} Last: ${shortHash(state.config.last_synced_hash)}`); + if (!await askYesNo("Overwrite the remote Gist with this machine's settings?", false)) return; } - } - const updated = updateRemoteGist(state.config.gist_id, localBundle.contents); - state.config.last_synced_hash = localBundle.hash; - await saveSyncConfiguration(state.path, state.config); - nativeMessage(`Uploaded to Gist ${state.config.gist_id}\n\nSHA-256: ${localBundle.hash}\nUpdated: ${updated.updated_at || "unknown"}`); + updateRemoteGist(state.config.gist_id, local.contents, auth); + state.config.last_synced_hash = local.hash; + await recordResult(state, `Uploaded settings to Gist ${state.config.gist_id}`); + console.log(`Uploaded to ${remote.gist.html_url || state.config.gist_id}`); + console.log(`SHA-256: ${local.hash}`); + }); } async function pullGist() { - const state = await promptForGist(); - if (!state) return; - const localBundle = await createBundle(); - const remote = readRemoteGist(state.config.gist_id); - if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`); - if (localBundle.hash !== remote.parsed.hash) { - const localChanged = !state.config.last_synced_hash || localBundle.hash !== state.config.last_synced_hash; - if (localChanged) { - const proceed = nativeConfirm( - `This machine contains different settings.\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remote.parsed.hash)}\nLast sync: ${shortHash(state.config.last_synced_hash)}\n\nReplace local settings with the Gist version? A backup will be created first.`, - ); - if (!proceed) return; + return withSyncLock(async () => { + const state = await requireConfiguredState(); + if (!state) return; + const auth = await resolveGhAuthentication({ interactive: true }); + const local = await createBundle(); + const remote = readRemoteGist(state.config.gist_id, auth); + if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`); + const status = classifySync(local.hash, remote.parsed.hash, state.config.last_synced_hash); + if (["local-changed", "conflict", "no-baseline"].includes(status)) { + console.log(`Local settings differ (${status}).`); + console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remote.parsed.hash)} Last: ${shortHash(state.config.last_synced_hash)}`); + if (!await askYesNo("Replace local settings with the Gist version? A backup will be created first.", false)) return; } - await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); - } - state.config.last_synced_hash = remote.parsed.hash; - await saveSyncConfiguration(state.path, state.config); - nativeMessage(`Downloaded from Gist ${state.config.gist_id}\n\nSHA-256: ${remote.parsed.hash}\nRemote updated: ${remote.gist.updated_at || "unknown"}`); + if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); + state.config.last_synced_hash = remote.parsed.hash; + await recordResult(state, `Pulled settings from Gist ${state.config.gist_id}`); + console.log(`Downloaded from ${remote.gist.html_url || state.config.gist_id}`); + console.log(`SHA-256: ${remote.parsed.hash}`); + }); } -async function showGistStatus() { - const state = await promptForGist(); - if (!state) return; - const localBundle = await createBundle(); - const remote = readRemoteGist(state.config.gist_id); - const remoteHash = remote.parsed?.hash || ""; - const lastHash = state.config.last_synced_hash; - let status = "The Gist does not contain a Zed settings file."; - if (remoteHash === localBundle.hash) status = "Synchronized"; - else if (!lastHash) status = "Not synchronized on this machine"; - else if (localBundle.hash === lastHash) status = "Remote changes available"; - else if (remoteHash === lastHash) status = "Local changes pending upload"; - else status = "Conflict: local and remote settings both changed"; - nativeMessage( - `Gist: ${remote.gist.html_url || state.config.gist_id}\nStatus: ${status}\n\nLocal: ${shortHash(localBundle.hash)}\nRemote: ${shortHash(remoteHash)}\nLast sync: ${shortHash(lastHash)}\nRemote updated: ${remote.gist.updated_at || "unknown"}`, - "Zed Settings Gist Status", - ); +async function showStatus() { + const state = await readSyncConfiguration(); + if (!state.config.gist_id) { + console.log("Sync is not configured. Run 'Zed Settings: Set Up / Reconfigure Sync...'."); + return; + } + const auth = await resolveGhAuthentication(); + const local = await createBundle(); + const remote = readRemoteGist(state.config.gist_id, auth); + const status = classifySync(local.hash, remote.parsed?.hash || "", state.config.last_synced_hash); + console.log(`Gist: ${remote.gist.html_url || state.config.gist_id}`); + console.log(`Status: ${status}`); + console.log(`Automatic sync: ${state.config.auto_sync ? `enabled (${state.config.interval_minutes} minutes)` : "disabled"}`); + console.log(`Local: ${shortHash(local.hash)}`); + console.log(`Remote: ${shortHash(remote.parsed?.hash || "")}`); + console.log(`Last: ${shortHash(state.config.last_synced_hash)}`); + console.log(`Last run: ${state.config.last_run_at || "never"}`); + console.log(`Last result: ${state.config.last_result || "none"}`); +} + +async function automaticSync() { + return withSyncLock(async () => { + const state = await readSyncConfiguration(); + if (!state.config.auto_sync) return; + try { + if (!state.config.gist_id) throw new Error("Automatic sync requires setup"); + const auth = await resolveGhAuthentication({ requireStored: true }); + const local = await createBundle(); + const remote = readRemoteGist(state.config.gist_id, auth); + const remoteHash = remote.parsed?.hash || ""; + const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash); + if (status === "synchronized") { + await recordResult(state, "Automatic sync: already synchronized"); + } else if (status === "local-changed") { + updateRemoteGist(state.config.gist_id, local.contents, auth); + state.config.last_synced_hash = local.hash; + await recordResult(state, "Automatic sync: uploaded local changes"); + } else if (status === "remote-changed") { + await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`); + state.config.last_synced_hash = remote.parsed.hash; + await recordResult(state, "Automatic sync: downloaded remote changes"); + } else { + const result = `Automatic sync stopped: ${status}`; + const shouldNotify = state.config.last_result !== result; + await recordResult(state, result); + if (shouldNotify) notify(`${result}. Open the Zed Task Picker to resolve it.`); + } + } catch (error) { + const result = `Automatic sync failed: ${error.message}`; + const shouldNotify = state.config.last_result !== result; + await recordResult(state, result); + if (shouldNotify) notify(result); + throw error; + } + }); +} + +function shellQuote(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function xmlEscape(value) { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function schedulerPlatform() { + return process.env.ZED_SYNC_PLATFORM || process.platform; +} + +async function configureScheduler(enable, intervalMinutes) { + const platform = schedulerPlatform(); + const scriptPath = resolve(process.argv[1]); + const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === "1"; + const actions = []; + const run = (command, args, options = {}) => { + actions.push([command, ...args].join(" ")); + if (dryRun) return { status: 0, stdout: "", stderr: "" }; + return execute(command, args, options); + }; + + if (platform === "win32") { + if (enable) { + const taskCommand = `\"${process.execPath}\" \"${scriptPath}\" auto-sync`; + const result = run("schtasks.exe", ["/Create", "/SC", "MINUTE", "/MO", String(intervalMinutes), "/TN", SCHEDULER_NAME, "/TR", taskCommand, "/F"]); + if (result.status !== 0) throw new Error((result.stderr || result.stdout || "Could not create Windows scheduled task").trim()); + } else { + run("schtasks.exe", ["/Delete", "/TN", SCHEDULER_NAME, "/F"]); + } + } else if (platform === "darwin") { + const plistPath = join(homedir(), "Library", "LaunchAgents", "cool.okk.zed-settings-sync.plist"); + run("launchctl", ["unload", plistPath]); + if (enable) { + const plist = `\n\n\n Labelcool.okk.zed-settings-sync\n ProgramArguments${xmlEscape(process.execPath)}${xmlEscape(scriptPath)}auto-sync\n StartInterval${intervalMinutes * 60}\n RunAtLoad\n\n`; + if (!dryRun) await writeAtomically(plistPath, plist); + actions.push(`write ${plistPath}`); + const result = run("launchctl", ["load", plistPath]); + if (result.status !== 0) throw new Error((result.stderr || "Could not load LaunchAgent").trim()); + } else if (!dryRun) { + await rm(plistPath, { force: true }); + } + } else { + const systemdDirectory = join(homedir(), ".config", "systemd", "user"); + const servicePath = join(systemdDirectory, "zed-settings-sync.service"); + const timerPath = join(systemdDirectory, "zed-settings-sync.timer"); + const systemd = run("systemctl", ["--user", "--version"]); + const systemdSession = systemd.status === 0 + ? run("systemctl", ["--user", "show-environment"]) + : { status: 1 }; + if (enable && systemdSession.status === 0) { + const service = `[Unit]\nDescription=Synchronize Zed settings\n\n[Service]\nType=oneshot\nExecStart=\"${process.execPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" \"${scriptPath.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}\" auto-sync\n`; + const timer = `[Unit]\nDescription=Synchronize Zed settings every ${intervalMinutes} minutes\n\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=${intervalMinutes}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`; + if (!dryRun) { + await writeAtomically(servicePath, service); + await writeAtomically(timerPath, timer); + } + actions.push(`write ${servicePath}`, `write ${timerPath}`); + run("systemctl", ["--user", "daemon-reload"]); + const result = run("systemctl", ["--user", "enable", "--now", "zed-settings-sync.timer"]); + if (result.status !== 0) throw new Error((result.stderr || "Could not enable systemd user timer").trim()); + } else if (!enable) { + if (systemd.status === 0) run("systemctl", ["--user", "disable", "--now", "zed-settings-sync.timer"]); + if (!dryRun) { + await rm(servicePath, { force: true }); + await rm(timerPath, { force: true }); + } + if (systemdSession.status === 0) run("systemctl", ["--user", "daemon-reload"]); + const current = run("crontab", ["-l"]); + if (current.status === 0) { + const lines = current.stdout.split(/\r?\n/).filter((line) => line && !line.includes("# zed-settings-sync")); + run("crontab", ["-"], { input: `${lines.join("\n")}\n` }); + } + } else { + const current = run("crontab", ["-l"]); + const marker = "# zed-settings-sync"; + const lines = (current.status === 0 ? current.stdout : "").split(/\r?\n/).filter((line) => line && !line.includes(marker)); + lines.push(`*/${intervalMinutes} * * * * ${shellQuote(process.execPath)} ${shellQuote(scriptPath)} auto-sync ${marker}`); + const updated = `${lines.join("\n")}\n`; + const result = run("crontab", ["-"], { input: updated }); + if (result.status !== 0) throw new Error((result.stderr || "Could not update crontab").trim()); + } + } + if (dryRun) console.log(actions.join("\n")); +} + +async function enableAutomaticSync() { + const state = await readSyncConfiguration(); + if (!state.config.gist_id) { + console.log("Sync must be configured before automatic synchronization can be enabled."); + await setupSync({ offerAutomaticSync: false }); + } + const configured = await readSyncConfiguration(); + if (!configured.config.gist_id) return; + const auth = await resolveGhAuthentication({ interactive: true, requireStored: true }); + console.log(`Stored GitHub credentials verified for ${auth.login}.`); + await configureScheduler(true, configured.config.interval_minutes); + configured.config.auto_sync = true; + await recordResult(configured, `Automatic sync enabled every ${configured.config.interval_minutes} minutes`); + console.log(`Automatic synchronization is enabled every ${configured.config.interval_minutes} minutes.`); +} + +async function disableAutomaticSync() { + const state = await readSyncConfiguration(); + await configureScheduler(false, state.config.interval_minutes); + state.config.auto_sync = false; + await recordResult(state, "Automatic sync disabled"); + console.log("Automatic synchronization is disabled."); } async function main() { @@ -480,10 +814,13 @@ async function main() { if (path) await importBundle(path); return; } - if (mode === "gist-configure") return configureGist(); - if (mode === "gist-status") return showGistStatus(); - if (mode === "gist-push") return pushGist(); - if (mode === "gist-pull") return pullGist(); + if (mode === "setup") return setupSync(); + if (mode === "status") return showStatus(); + if (mode === "push") return pushGist(); + if (mode === "pull") return pullGist(); + if (mode === "auto-sync") return automaticSync(); + if (mode === "auto-enable") return enableAutomaticSync(); + if (mode === "auto-disable") return disableAutomaticSync(); throw new Error(`Unknown mode: ${mode || ""}`); } @@ -492,9 +829,9 @@ try { } catch (error) { console.error(error.stack || error.message); try { - nativeMessage(error.message, "Zed Settings Sync Error", true); + await appendLog(`ERROR ${error.message}`); } catch { - // The terminal output remains available when the native dialog backend is unavailable. + // The terminal remains the primary error channel. } process.exitCode = 1; } diff --git a/tasks.json b/tasks.json index 5f6c988..99693cf 100644 --- a/tasks.json +++ b/tasks.json @@ -79,8 +79,8 @@ "export-dialog" ], "allow_concurrent_runs": false, - "reveal": "never", - "hide": "always", + "reveal": "always", + "hide": "never", "show_summary": false, "show_command": false, "save": "all" @@ -93,81 +93,94 @@ "import-dialog" ], "allow_concurrent_runs": false, - "reveal": "never", - "hide": "always", - "show_summary": false, - "show_command": false, - "save": "all" - }, - { - "label": "Zed Settings: Configure GitHub Gist...", - "command": "node", - "args": [ - "__SYNC_SCRIPT__", - "gist-configure" - ], - "allow_concurrent_runs": false, - "reveal": "no_focus", + "reveal": "always", "hide": "never", "show_summary": false, "show_command": false, "save": "all" }, { - "label": "Zed Settings: Show GitHub Gist Status", + "label": "Zed Settings: Set Up / Reconfigure Sync...", "command": "node", "args": [ "__SYNC_SCRIPT__", - "gist-status" + "setup" ], "allow_concurrent_runs": false, - "reveal": "no_focus", + "reveal": "always", "hide": "never", - "show_summary": false, + "show_summary": true, "show_command": false, "save": "all" }, { - "label": "Zed Settings: Push to GitHub Gist", + "label": "Zed Settings: Show Sync Status", "command": "node", "args": [ "__SYNC_SCRIPT__", - "gist-push" - ], - "allow_concurrent_runs": false, - "reveal": "no_focus", - "hide": "never", - "show_summary": false, - "show_command": false, - "save": "all" - }, - { - "label": "Zed Settings: Pull from GitHub Gist", - "command": "node", - "args": [ - "__SYNC_SCRIPT__", - "gist-pull" - ], - "allow_concurrent_runs": false, - "reveal": "no_focus", - "hide": "never", - "show_summary": false, - "show_command": false, - "save": "all" - }, - { - "label": "Zed Settings: Authenticate GitHub...", - "command": "powershell", - "args": [ - "-NoExit", - "-Command", - "Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue; gh auth login" + "status" ], "allow_concurrent_runs": false, "reveal": "always", "hide": "never", "show_summary": false, - "show_command": true, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Push Now", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "push" + ], + "allow_concurrent_runs": false, + "reveal": "always", + "hide": "never", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Pull Now", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "pull" + ], + "allow_concurrent_runs": false, + "reveal": "always", + "hide": "never", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Enable Automatic Sync", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "auto-enable" + ], + "allow_concurrent_runs": false, + "reveal": "always", + "hide": "never", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Disable Automatic Sync", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "auto-disable" + ], + "allow_concurrent_runs": false, + "reveal": "always", + "hide": "never", + "show_summary": false, + "show_command": false, "save": "none" }, ] diff --git a/tests/settings-sync.test.mjs b/tests/settings-sync.test.mjs new file mode 100644 index 0000000..719bec2 --- /dev/null +++ b/tests/settings-sync.test.mjs @@ -0,0 +1,388 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { chmod, cp, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const syncScript = join(repository, "scripts", "zed-settings-sync.mjs"); + +async function makeFixture() { + const root = await mkdtemp(join(tmpdir(), "zed-sync-test-")); + const config = join(root, "zed"); + const bin = join(root, "bin"); + await mkdir(join(config, "scripts"), { recursive: true }); + await mkdir(bin, { recursive: true }); + for (const name of ["settings.json", "keymap.json", "tasks.json"]) { + await cp(join(repository, name), join(config, name)); + } + for (const name of ["zed-settings-sync.mjs", "toggle-file-scan-exclusions.mjs", "file-exclusions.json", "settings-sync.json"]) { + await cp(join(repository, "scripts", name), join(config, "scripts", name)); + } + + const fakeGh = join(root, "fake-gh.mjs"); + await writeFile(fakeGh, ` +import { readFileSync, writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +const statePath = process.env.FAKE_GH_STATE; +const state = JSON.parse(readFileSync(statePath, "utf8")); +const save = () => writeFileSync(statePath, JSON.stringify(state), "utf8"); +if (args[0] === "auth" && ["login", "refresh"].includes(args[1])) process.exit(0); +if (args[0] !== "api") process.exit(2); +if (args.includes("user")) { + if (process.env.FAKE_GH_REJECT_TOKEN === "1" && process.env.GH_TOKEN) { + process.stderr.write("bad token"); + process.exit(1); + } + process.stdout.write("test-user\\n"); + process.exit(0); +} +if (args.includes("gists?per_page=1")) { + process.exit(0); +} +if (args.includes("gists?per_page=100")) { + process.stdout.write(JSON.stringify([[state.gist].filter(Boolean)])); + process.exit(0); +} +const methodIndex = args.indexOf("--method"); +const method = methodIndex >= 0 ? args[methodIndex + 1] : "GET"; +if (process.env.FAKE_GH_NETWORK_FAIL === "1") { + process.stderr.write("network unavailable"); + process.exit(1); +} +if (method === "POST") { + const payload = JSON.parse(readFileSync(0, "utf8")); + state.gist = { id: "abcde12345", description: payload.description, public: false, html_url: "https://gist.github.com/abcde12345", updated_at: new Date().toISOString(), files: payload.files }; + save(); + process.stdout.write(JSON.stringify(state.gist)); + process.exit(0); +} +const endpoint = args.find((arg) => arg.startsWith("gists/")); +if (endpoint && method === "PATCH") { + const payload = JSON.parse(readFileSync(0, "utf8")); + state.gist.files = { ...state.gist.files, ...payload.files }; + state.gist.updated_at = new Date().toISOString(); + state.patches = (state.patches || 0) + 1; + save(); + process.stdout.write(JSON.stringify(state.gist)); + process.exit(0); +} +if (endpoint && state.gist) { + process.stdout.write(JSON.stringify(state.gist)); + process.exit(0); +} +process.stderr.write("not found"); +process.exit(1); +`, "utf8"); + + if (process.platform === "win32") { + await writeFile(join(bin, "gh.cmd"), `@echo off\r\n"${process.execPath}" "${fakeGh}" %*\r\n`, "utf8"); + } else { + const shim = join(bin, "gh"); + await writeFile(shim, `#!/bin/sh\nexec "${process.execPath}" "${fakeGh}" "$@"\n`, "utf8"); + await chmod(shim, 0o755); + } + const ghState = join(root, "gh-state.json"); + await writeFile(ghState, JSON.stringify({ gist: null }), "utf8"); + return { root, config, bin, fakeGh, ghState }; +} + +function environment(fixture, extra = {}) { + const env = { + ...process.env, + PATH: `${fixture.bin}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`, + ZED_CONFIG_DIR: fixture.config, + ZED_SYNC_GH_SCRIPT: fixture.fakeGh, + FAKE_GH_STATE: fixture.ghState, + ZED_SYNC_NO_DIALOGS: "1", + ZED_SYNC_NO_NOTIFICATIONS: "1", + ...extra, + }; + delete env.GH_TOKEN; + delete env.GITHUB_TOKEN; + if (extra.GH_TOKEN) env.GH_TOKEN = extra.GH_TOKEN; + return env; +} + +function run(fixture, args, extra = {}) { + return spawnSync(process.execPath, [syncScript, ...args], { + cwd: repository, + env: environment(fixture, extra), + encoding: "utf8", + }); +} + +function parseJsonc(source) { + let output = ""; + let inString = false; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const next = source[index + 1]; + if (lineComment) { + if (character === "\n") { + lineComment = false; + output += character; + } else output += " "; + continue; + } + if (blockComment) { + if (character === "*" && next === "/") { + blockComment = false; + output += " "; + index += 1; + } else output += character === "\n" ? "\n" : " "; + continue; + } + if (inString) { + output += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + output += character; + } else if (character === "/" && next === "/") { + lineComment = true; + output += " "; + index += 1; + } else if (character === "/" && next === "*") { + blockComment = true; + output += " "; + index += 1; + } else { + output += character; + } + } + return JSON.parse(output.replace(/,(\s*[}\]])/g, "$1")); +} + +test("Zed JSONC files parse and expose the new sync Tasks", async () => { + const settings = parseJsonc(await readFile(join(repository, "settings.json"), "utf8")); + const keymap = parseJsonc(await readFile(join(repository, "keymap.json"), "utf8")); + const tasks = parseJsonc(await readFile(join(repository, "tasks.json"), "utf8")); + assert.equal(typeof settings.base_keymap, "string"); + assert.equal(Array.isArray(keymap), true); + const labels = tasks.map((task) => task.label); + for (const label of [ + "Zed Settings: Set Up / Reconfigure Sync...", + "Zed Settings: Show Sync Status", + "Zed Settings: Push Now", + "Zed Settings: Pull Now", + "Zed Settings: Enable Automatic Sync", + "Zed Settings: Disable Automatic Sync", + ]) { + assert.equal(labels.includes(label), true, `Missing Task: ${label}`); + } +}); + +test("export remains a flat JSON bundle without local state or hash metadata", async () => { + const fixture = await makeFixture(); + const output = join(fixture.root, "bundle.json"); + const result = run(fixture, ["export", output]); + assert.equal(result.status, 0, result.stderr); + const bundle = JSON.parse(await readFile(output, "utf8")); + assert.equal(typeof bundle["settings.json"], "string"); + assert.equal(typeof bundle["keymap.json"], "string"); + assert.equal(bundle.files, undefined); + assert.equal(bundle.hash, undefined); + assert.equal(bundle["scripts/settings-sync.json"], undefined); +}); + +test("setup creates a Secret Gist and stores only machine-local sync state", async () => { + const fixture = await makeFixture(); + const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Created Secret Gist/); + const state = JSON.parse(await readFile(join(fixture.config, "scripts", "settings-sync.json"), "utf8")); + assert.equal(state.gist_id, "abcde12345"); + assert.equal(state.auto_sync, false); + assert.equal(state.last_synced_hash.length, 64); + const remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(remote.gist.public, false); +}); + +test("setup initializes a configured Gist that is missing the settings file", async () => { + const fixture = await makeFixture(); + const gist = { + id: "missing1234", + description: "Empty sync Gist", + public: false, + html_url: "https://gist.github.com/missing1234", + updated_at: new Date().toISOString(), + files: { "notes.txt": { content: "empty" } }, + }; + await writeFile(fixture.ghState, JSON.stringify({ gist }), "utf8"); + const statePath = join(fixture.config, "scripts", "settings-sync.json"); + const state = JSON.parse(await readFile(statePath, "utf8")); + state.gist_id = gist.id; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["1", "y", "n"]) }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /does not contain zed-settings-sync.json/); + const remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(typeof remote.gist.files[BUNDLE_NAME].content, "string"); + const updated = JSON.parse(await readFile(statePath, "utf8")); + assert.equal(updated.gist_id, gist.id); + assert.equal(updated.last_synced_hash.length, 64); +}); + +test("an invalid inherited token falls back to stored gh credentials", async () => { + const fixture = await makeFixture(); + let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + result = run(fixture, ["status"], { GH_TOKEN: "invalid", FAKE_GH_REJECT_TOKEN: "1" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /inherited GitHub token is invalid/); + assert.match(result.stdout, /Status: synchronized/); +}); + +test("automatic sync uploads one-sided local changes and stops on conflicts", async () => { + const fixture = await makeFixture(); + let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + const statePath = join(fixture.config, "scripts", "settings-sync.json"); + const state = JSON.parse(await readFile(statePath, "utf8")); + state.auto_sync = true; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8"); + result = run(fixture, ["auto-sync"]); + assert.equal(result.status, 0, result.stderr); + let remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(remote.patches, 1); + + const baseline = JSON.parse(await readFile(statePath, "utf8")).last_synced_hash; + await writeFile(join(fixture.config, "keymap.json"), `${await readFile(join(fixture.config, "keymap.json"), "utf8")}\n`, "utf8"); + const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content); + remoteBundle["settings.json"] += "\n// remote change\n"; + remote.gist.files[BUNDLE_NAME] = { content: `${JSON.stringify(remoteBundle, null, 2)}\n` }; + await writeFile(fixture.ghState, JSON.stringify(remote), "utf8"); + result = run(fixture, ["auto-sync"]); + assert.equal(result.status, 0, result.stderr); + const conflicted = JSON.parse(await readFile(statePath, "utf8")); + assert.equal(conflicted.last_synced_hash, baseline); + assert.match(conflicted.last_result, /conflict/); + remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(remote.patches, 1); +}); + +test("automatic sync downloads one-sided remote changes with a backup", async () => { + const fixture = await makeFixture(); + let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + const statePath = join(fixture.config, "scripts", "settings-sync.json"); + const state = JSON.parse(await readFile(statePath, "utf8")); + state.auto_sync = true; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + + const remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + const remoteBundle = JSON.parse(remote.gist.files[BUNDLE_NAME].content); + remoteBundle["settings.json"] += "\n// downloaded remote change\n"; + remote.gist.files[BUNDLE_NAME].content = `${JSON.stringify(remoteBundle, null, 2)}\n`; + await writeFile(fixture.ghState, JSON.stringify(remote), "utf8"); + + result = run(fixture, ["auto-sync"]); + assert.equal(result.status, 0, result.stderr); + assert.match(await readFile(join(fixture.config, "settings.json"), "utf8"), /downloaded remote change/); + assert.match(await readFile(statePath, "utf8"), /downloaded remote changes/); + assert.equal((await readdir(join(fixture.config, "backups"))).length, 1); +}); + +test("automatic sync does not overwrite without a baseline and records network errors", async () => { + const fixture = await makeFixture(); + let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + const statePath = join(fixture.config, "scripts", "settings-sync.json"); + const state = JSON.parse(await readFile(statePath, "utf8")); + state.auto_sync = true; + state.last_synced_hash = ""; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await writeFile(join(fixture.config, "settings.json"), `${await readFile(join(fixture.config, "settings.json"), "utf8")}\n`, "utf8"); + + result = run(fixture, ["auto-sync"]); + assert.equal(result.status, 0, result.stderr); + let updated = JSON.parse(await readFile(statePath, "utf8")); + assert.match(updated.last_result, /no-baseline/); + let remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(remote.patches, undefined); + + result = run(fixture, ["auto-sync"], { FAKE_GH_NETWORK_FAIL: "1" }); + assert.equal(result.status, 1); + updated = JSON.parse(await readFile(statePath, "utf8")); + assert.match(updated.last_result, /network unavailable/); + remote = JSON.parse(await readFile(fixture.ghState, "utf8")); + assert.equal(remote.patches, undefined); +}); + +test("scheduler generation supports Windows, macOS, and Linux without side effects", async () => { + for (const platform of ["win32", "darwin", "linux"]) { + const fixture = await makeFixture(); + let result = run(fixture, ["setup"], { ZED_SYNC_ANSWERS: JSON.stringify(["n", "n"]) }); + assert.equal(result.status, 0, result.stderr); + result = run(fixture, ["auto-enable"], { + ZED_SYNC_PLATFORM: platform, + ZED_SYNC_SCHEDULER_DRY_RUN: "1", + }); + assert.equal(result.status, 0, `${platform}: ${result.stderr}`); + assert.match(result.stdout, platform === "win32" ? /schtasks/ : platform === "darwin" ? /launchctl/ : /systemctl/); + } +}); + +test("Windows bootstrap installs without gh, authentication, or a Gist", { skip: process.platform !== "win32" }, async () => { + const fixture = await makeFixture(); + const bundle = join(fixture.root, "bundle.json"); + let result = run(fixture, ["export", bundle]); + assert.equal(result.status, 0, result.stderr); + const appData = join(fixture.root, "appdata"); + await mkdir(join(appData, "Zed", "scripts"), { recursive: true }); + await writeFile(join(appData, "Zed", "scripts", "settings-sync.json"), JSON.stringify({ + gist_id: "legacy1234", + last_synced_hash: "abc", + }), "utf8"); + const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "bootstrap.ps1")], { + cwd: repository, + encoding: "utf8", + env: { + ...process.env, + APPDATA: appData, + ZED_SETTINGS_BUNDLE_PATH: bundle, + ZED_SYNC_SKIP_SETUP: "1", + }, + }); + assert.equal(powershell.status, 0, powershell.stderr); + assert.equal(existsSync(join(appData, "Zed", "settings.json")), true); + const installedState = JSON.parse(await readFile(join(appData, "Zed", "scripts", "settings-sync.json"), "utf8")); + assert.equal(installedState.gist_id, "legacy1234"); + assert.equal(installedState.last_synced_hash, "abc"); + assert.equal(installedState.auto_sync, false); + assert.equal(installedState.interval_minutes, 15); +}); + +test("Windows repository installer migrates old local state without losing Gist identity", { skip: process.platform !== "win32" }, async () => { + const fixture = await makeFixture(); + const appData = join(fixture.root, "appdata-install"); + const statePath = join(appData, "Zed", "scripts", "settings-sync.json"); + await mkdir(dirname(statePath), { recursive: true }); + await writeFile(statePath, JSON.stringify({ gist_id: "keep56789", last_synced_hash: "def" }), "utf8"); + const powershell = spawnSync("powershell.exe", ["-NoProfile", "-File", join(repository, "install.ps1")], { + cwd: repository, + encoding: "utf8", + env: { ...process.env, APPDATA: appData }, + }); + assert.equal(powershell.status, 0, powershell.stderr); + const state = JSON.parse(await readFile(statePath, "utf8")); + assert.equal(state.gist_id, "keep56789"); + assert.equal(state.last_synced_hash, "def"); + assert.equal(state.auto_sync, false); + assert.equal(state.interval_minutes, 15); +}); + +const BUNDLE_NAME = "zed-settings-sync.json"; diff --git a/zed-settings-sync.json b/zed-settings-sync.json index ae8c777..a366bd8 100644 --- a/zed-settings-sync.json +++ b/zed-settings-sync.json @@ -1,8 +1,8 @@ { - "keymap.json": "[\n {\n \"bindings\": {\n \"f12\": [\"zed::IncreaseUiFontSize\", { \"persist\": false }],\n \"secondary-f12\": [\"zed::DecreaseUiFontSize\", { \"persist\": false }],\n \"secondary-1\": \"project_panel::ToggleFocus\",\n \"secondary-2\": \"terminal_panel::ToggleFocus\",\n \"secondary-3\": \"workspace::ToggleRightDock\",\n \"secondary-shift-g\": \"git_panel::ToggleFocus\",\n \"secondary-shift-v\": \"zed::Extensions\",\n \"secondary-p\": \"command_palette::Toggle\",\n \"secondary-o\": \"file_finder::Toggle\",\n \"secondary-9\": \"pane::ActivatePreviousItem\",\n \"secondary-0\": \"pane::ActivateNextItem\",\n \"alt-1\": [\"workspace::ActivatePane\", 0],\n \"alt-2\": [\"workspace::ActivatePane\", 1],\n \"alt-3\": [\"workspace::ActivatePane\", 2],\n \"alt-4\": [\"workspace::ActivatePane\", 3],\n \"alt-5\": [\"workspace::ActivatePane\", 4],\n \"secondary-shift-o\": \"workspace::Open\",\n \"secondary-enter\": \"workspace::ToggleZoom\",\n \"secondary-r\": \"task::Spawn\",\n \"secondary-shift-r\": \"projects::OpenRecent\",\n \"shift-alt-r\": null,\n \"secondary-; k\": \"workspace::Reload\",\n \"secondary-; g\": \"git::FileHistory\",\n \"secondary-; p\": \"git::Push\",\n \"secondary-; secondary-p\": \"git::Pull\",\n \"secondary-; n\": \"git::Branch\",\n \"secondary-; s\": \"git::StashAll\",\n \"secondary-; z\": \"git::StashPop\",\n \"secondary-; a\": \"git::StageAll\",\n \"secondary-; u\": [\n \"task::Spawn\",\n { \"task_name\": \"Zed Settings: Push to GitHub Gist\" }\n ],\n \"secondary-; d\": [\n \"task::Spawn\",\n { \"task_name\": \"Zed Settings: Pull from GitHub Gist\" }\n ],\n \"shift-alt--\": [\n \"task::Spawn\",\n { \"task_name\": \"Toggle excluded files\" }\n ]\n }\n },\n {\n \"context\": \"Workspace\",\n \"bindings\": {\n \"secondary-; c\": \"git_panel::ToggleFocus\"\n }\n },\n {\n \"context\": \"GitPanel\",\n \"bindings\": {\n \"secondary-; c\": \"git_panel::FocusEditor\"\n }\n },\n {\n \"context\": \"Editor && mode == full\",\n \"bindings\": {\n \"secondary-shift-[\": \"editor::Fold\",\n \"secondary-shift-]\": \"editor::UnfoldLines\",\n \"secondary--\": \"editor::FoldAll\",\n \"secondary-=\": \"editor::UnfoldAll\"\n }\n },\n {\n \"context\": \"Editor && extension == md\",\n \"bindings\": {\n \"alt-p\": \"markdown::OpenPreview\",\n \"shift-alt-p\": \"markdown::OpenPreviewToTheSide\"\n }\n },\n {\n \"context\": \"ProjectPanel\",\n \"bindings\": {\n \"alt--\": \"project_panel::CollapseAllEntries\"\n }\n }\n]\n", + "keymap.json": "[\n {\n \"bindings\": {\n \"f12\": [\"zed::IncreaseUiFontSize\", { \"persist\": false }],\n \"secondary-f12\": [\"zed::DecreaseUiFontSize\", { \"persist\": false }],\n \"secondary-1\": \"project_panel::ToggleFocus\",\n \"secondary-2\": \"terminal_panel::ToggleFocus\",\n \"secondary-3\": \"workspace::ToggleRightDock\",\n \"secondary-shift-g\": \"git_panel::ToggleFocus\",\n \"secondary-shift-v\": \"zed::Extensions\",\n \"secondary-p\": \"command_palette::Toggle\",\n \"secondary-o\": \"file_finder::Toggle\",\n \"secondary-9\": \"pane::ActivatePreviousItem\",\n \"secondary-0\": \"pane::ActivateNextItem\",\n \"alt-1\": [\"workspace::ActivatePane\", 0],\n \"alt-2\": [\"workspace::ActivatePane\", 1],\n \"alt-3\": [\"workspace::ActivatePane\", 2],\n \"alt-4\": [\"workspace::ActivatePane\", 3],\n \"alt-5\": [\"workspace::ActivatePane\", 4],\n \"secondary-shift-o\": \"workspace::Open\",\n \"secondary-enter\": \"workspace::ToggleZoom\",\n \"secondary-r\": \"task::Spawn\",\n \"secondary-shift-r\": \"projects::OpenRecent\",\n \"shift-alt-r\": null,\n \"secondary-; k\": \"workspace::Reload\",\n \"secondary-; g\": \"git::FileHistory\",\n \"secondary-; p\": \"git::Push\",\n \"secondary-; secondary-p\": \"git::Pull\",\n \"secondary-; n\": \"git::Branch\",\n \"secondary-; s\": \"git::StashAll\",\n \"secondary-; z\": \"git::StashPop\",\n \"secondary-; a\": \"git::StageAll\",\n \"secondary-; u\": [\n \"task::Spawn\",\n { \"task_name\": \"Zed Settings: Push Now\" }\n ],\n \"secondary-; d\": [\n \"task::Spawn\",\n { \"task_name\": \"Zed Settings: Pull Now\" }\n ],\n \"shift-alt--\": [\n \"task::Spawn\",\n { \"task_name\": \"Toggle excluded files\" }\n ]\n }\n },\n {\n \"context\": \"Workspace\",\n \"bindings\": {\n \"secondary-; c\": \"git_panel::ToggleFocus\"\n }\n },\n {\n \"context\": \"GitPanel\",\n \"bindings\": {\n \"secondary-; c\": \"git_panel::FocusEditor\"\n }\n },\n {\n \"context\": \"Editor && mode == full\",\n \"bindings\": {\n \"secondary-shift-[\": \"editor::Fold\",\n \"secondary-shift-]\": \"editor::UnfoldLines\",\n \"secondary--\": \"editor::FoldAll\",\n \"secondary-=\": \"editor::UnfoldAll\"\n }\n },\n {\n \"context\": \"Editor && extension == md\",\n \"bindings\": {\n \"alt-p\": \"markdown::OpenPreview\",\n \"shift-alt-p\": \"markdown::OpenPreviewToTheSide\"\n }\n },\n {\n \"context\": \"ProjectPanel\",\n \"bindings\": {\n \"alt--\": \"project_panel::CollapseAllEntries\"\n }\n }\n]\n", "scripts/file-exclusions.json": "{\n \"defaults\": [\n \"**/.git\",\n \"**/.svn\",\n \"**/.hg\",\n \"**/.jj\",\n \"**/CVS\",\n \"**/.DS_Store\",\n \"**/Thumbs.db\",\n \"**/.classpath\",\n \"**/.settings\"\n ],\n \"custom\": [\n \"**/.playwright-cli\",\n \"**/output\",\n \"**/.cursor\",\n \"**/.vscode\",\n \"**/.idea\",\n \"**/.fleet\",\n \"**/node_modules\",\n \"**/bun.lockb\",\n \"**/pnpm-lock.yaml\",\n \"**/package-lock.json\",\n \"**/yarn.lock\",\n \"**/dist\",\n \"**/out\",\n \"**/build\",\n \"**/.next\",\n \"**/.nuxt\",\n \"**/.output\",\n \"**/.cache\",\n \"**/.turbo\",\n \"**/.vercel\",\n \"**/.netlify\",\n \"**/tsconfig.tsbuildinfo\",\n \"**/auto-imports.d.ts\",\n \"**/components.json\",\n \"**/unplugin-vue-components.d.ts\",\n \"src/*.d.ts\",\n \"**/__pycache__\",\n \"**/__init__.py\",\n \"**/.ipynb_checkpoints\",\n \"**/.pytest_cache\",\n \"**/.mypy_cache\",\n \"**/.venv\",\n \"**/env\",\n \"**/venv\",\n \"**/.github\",\n \"**/.husky\",\n \"**/.npmrc\",\n \"**/.eslintrc*\",\n \"**/.prettier*\",\n \"**/.editorconfig\",\n \"**/.dockerignore\",\n \"**/docker-compose.override.yml\",\n \"**/cypress*\",\n \"**/prisma/migrations\"\n ]\n}\n", "scripts/toggle-file-scan-exclusions.mjs": "import { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { readFile, rename, rm, writeFile } from \"node:fs/promises\";\nimport { fileURLToPath } from \"node:url\";\n\nconst scriptDirectory = dirname(fileURLToPath(import.meta.url));\n\nasync function exclusionRules() {\n const rulesPath = join(scriptDirectory, \"file-exclusions.json\");\n const rules = JSON.parse(await readFile(rulesPath, \"utf8\"));\n if (!Array.isArray(rules.defaults) || !Array.isArray(rules.custom)) {\n throw new Error(\"file-exclusions.json must contain defaults and custom arrays\");\n }\n return {\n defaults: [...new Set(rules.defaults)],\n custom: [...new Set(rules.custom)].filter(\n (entry) => !rules.defaults.includes(entry),\n ),\n };\n}\n\nfunction settingsPath() {\n if (process.platform === \"win32\") {\n const appData = process.env.APPDATA;\n if (!appData) throw new Error(\"APPDATA is not set\");\n return join(appData, \"Zed\", \"settings.json\");\n }\n return join(homedir(), \".config\", \"zed\", \"settings.json\");\n}\n\nfunction findArrayRange(source) {\n const property = /\"file_scan_exclusions\"\\s*:/g.exec(source);\n if (!property) throw new Error(\"file_scan_exclusions is missing from settings.json\");\n\n const start = source.indexOf(\"[\", property.index + property[0].length);\n if (start === -1) throw new Error(\"file_scan_exclusions is not an array\");\n\n let inString = false;\n let escaped = false;\n let depth = 0;\n for (let index = start; index < source.length; index += 1) {\n const character = source[index];\n if (inString) {\n if (escaped) escaped = false;\n else if (character === \"\\\\\") escaped = true;\n else if (character === '\"') inString = false;\n continue;\n }\n if (character === '\"') inString = true;\n else if (character === \"[\") depth += 1;\n else if (character === \"]\" && --depth === 0) return { start, end: index + 1 };\n }\n throw new Error(\"file_scan_exclusions array is not closed\");\n}\n\nfunction formatArray(values, indent, newline) {\n const itemIndent = `${indent} `;\n const items = values.map((value) => `${itemIndent}${JSON.stringify(value)}`);\n return `[${newline}${items.join(`,${newline}`)}${newline}${indent}]`;\n}\n\nasync function replaceAtomically(path, contents) {\n const temporaryPath = join(dirname(path), `.settings.json.${process.pid}.tmp`);\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, path);\n } finally {\n await rm(temporaryPath, { force: true });\n }\n}\n\nconst path = settingsPath();\nconst { defaults, custom } = await exclusionRules();\nconst source = await readFile(path, \"utf8\");\nconst newline = source.includes(\"\\r\\n\") ? \"\\r\\n\" : \"\\n\";\nconst range = findArrayRange(source);\nconst current = JSON.parse(source.slice(range.start, range.end));\nconst customIsActive = current.some((entry) => !defaults.includes(entry));\nconst next = customIsActive ? defaults : [...defaults, ...custom];\nconst lineStart = source.lastIndexOf(\"\\n\", range.start) + 1;\nconst indent = source.slice(lineStart, range.start).match(/^\\s*/)?.[0] ?? \"\";\nconst updated =\n source.slice(0, range.start) +\n formatArray(next, indent, newline) +\n source.slice(range.end);\n\nawait replaceAtomically(path, updated);\nconsole.log(`Excluded files are now ${customIsActive ? \"shown\" : \"hidden\"}.`);\n", - "scripts/zed-settings-sync.mjs": "import { createHash } from \"node:crypto\";\nimport { copyFile, mkdir, readFile, readdir, rename, rm, writeFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { spawnSync } from \"node:child_process\";\n\nconst BUNDLE_NAME = \"zed-settings-sync.json\";\nconst REQUIRED_FILES = [\"settings.json\", \"keymap.json\", \"tasks.json\"];\nconst FIXED_FILES = [\n ...REQUIRED_FILES,\n \"debug.json\",\n \"scripts/toggle-file-scan-exclusions.mjs\",\n \"scripts/file-exclusions.json\",\n \"scripts/zed-settings-sync.mjs\",\n];\nconst OPTIONAL_DIRECTORIES = [\"snippets\", \"themes\"];\nconst LOCAL_SYNC_CONFIG = \"scripts/settings-sync.json\";\n\nfunction zedConfigDirectory() {\n if (process.platform === \"win32\") {\n if (!process.env.APPDATA) throw new Error(\"APPDATA is not set\");\n return join(process.env.APPDATA, \"Zed\");\n }\n return join(process.env.XDG_CONFIG_HOME || join(homedir(), \".config\"), \"zed\");\n}\n\nfunction timestamp() {\n return new Date().toISOString().replace(/[:.]/g, \"-\");\n}\n\nasync function writeAtomically(path, contents) {\n await mkdir(dirname(path), { recursive: true });\n const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, path);\n } finally {\n await rm(temporaryPath, { force: true });\n }\n}\n\nasync function listFilesRecursively(directory, prefix) {\n if (!existsSync(directory)) return [];\n const results = [];\n for (const entry of await readdir(directory, { withFileTypes: true })) {\n const absolutePath = join(directory, entry.name);\n const relativePath = `${prefix}/${entry.name}`;\n if (entry.isDirectory()) results.push(...await listFilesRecursively(absolutePath, relativePath));\n else if (entry.isFile()) results.push(relativePath);\n }\n return results;\n}\n\nasync function synchronizedPaths(configDirectory) {\n const paths = FIXED_FILES.filter((path) => existsSync(join(configDirectory, ...path.split(\"/\"))));\n for (const directory of OPTIONAL_DIRECTORIES) {\n paths.push(...await listFilesRecursively(join(configDirectory, directory), directory));\n }\n return [...new Set(paths)].sort();\n}\n\nfunction isAllowedBundlePath(path) {\n if (typeof path !== \"string\" || path.startsWith(\"/\") || path.includes(\"\\\\\")) return false;\n const segments = path.split(\"/\");\n if (segments.some((segment) => !segment || segment === \".\" || segment === \"..\")) return false;\n if (FIXED_FILES.includes(path) || path === LOCAL_SYNC_CONFIG) return true;\n return OPTIONAL_DIRECTORIES.some((directory) => path.startsWith(`${directory}/`));\n}\n\nfunction replaceTaskPath(source, path, placeholder) {\n return source\n .replaceAll(path.replaceAll(\"\\\\\", \"\\\\\\\\\"), placeholder)\n .replaceAll(path.replaceAll(\"\\\\\", \"/\"), placeholder);\n}\n\nfunction portableTasks(source, configDirectory) {\n return replaceTaskPath(\n replaceTaskPath(\n source,\n join(configDirectory, \"scripts\", \"toggle-file-scan-exclusions.mjs\"),\n \"__TOGGLE_SCRIPT__\",\n ),\n join(configDirectory, \"scripts\", \"zed-settings-sync.mjs\"),\n \"__SYNC_SCRIPT__\",\n );\n}\n\nfunction installedTasks(source, configDirectory) {\n return source\n .replaceAll(\n \"__TOGGLE_SCRIPT__\",\n join(configDirectory, \"scripts\", \"toggle-file-scan-exclusions.mjs\").replaceAll(\"\\\\\", \"/\"),\n )\n .replaceAll(\n \"__SYNC_SCRIPT__\",\n join(configDirectory, \"scripts\", \"zed-settings-sync.mjs\").replaceAll(\"\\\\\", \"/\"),\n );\n}\n\nfunction hashContents(contents) {\n return createHash(\"sha256\").update(contents).digest(\"hex\");\n}\n\nasync function createBundle() {\n const configDirectory = zedConfigDirectory();\n const files = {};\n for (const relativePath of await synchronizedPaths(configDirectory)) {\n const absolutePath = join(configDirectory, ...relativePath.split(\"/\"));\n let contents = await readFile(absolutePath, \"utf8\");\n if (relativePath === \"tasks.json\") contents = portableTasks(contents, configDirectory);\n files[relativePath] = contents;\n }\n for (const required of REQUIRED_FILES) {\n if (!(required in files)) throw new Error(`Cannot export: ${required} is missing`);\n }\n const contents = serializeBundle(files);\n return { files, contents, hash: hashContents(contents) };\n}\n\nfunction serializeBundle(files) {\n return `${JSON.stringify(files, null, 2)}\\n`;\n}\n\nfunction parseBundle(contents, sourceName) {\n let files;\n try {\n files = JSON.parse(contents);\n } catch (error) {\n throw new Error(`Invalid JSON in ${sourceName}: ${error.message}`);\n }\n if (!files || Array.isArray(files) || typeof files !== \"object\") {\n throw new Error(`Invalid Zed settings bundle: ${sourceName}`);\n }\n for (const required of REQUIRED_FILES) {\n if (typeof files[required] !== \"string\") {\n throw new Error(`Invalid bundle: ${required} is missing`);\n }\n }\n for (const [path, value] of Object.entries(files)) {\n if (!isAllowedBundlePath(path) || typeof value !== \"string\") {\n throw new Error(`Invalid bundle file: ${path}`);\n }\n }\n return { files, hash: hashContents(contents) };\n}\n\nasync function exportBundle(path) {\n const bundle = await createBundle();\n await writeAtomically(path, bundle.contents);\n console.log(`Exported Zed settings to ${path}`);\n console.log(`SHA-256: ${bundle.hash}`);\n return bundle;\n}\n\nasync function backupCurrentConfiguration(configDirectory) {\n const backupDirectory = join(configDirectory, \"backups\", `sync-import-${timestamp()}`);\n await mkdir(backupDirectory, { recursive: true });\n for (const relativePath of await synchronizedPaths(configDirectory)) {\n const source = join(configDirectory, ...relativePath.split(\"/\"));\n const destination = join(backupDirectory, ...relativePath.split(\"/\"));\n await mkdir(dirname(destination), { recursive: true });\n await copyFile(source, destination);\n }\n return backupDirectory;\n}\n\nasync function installBundleContents(contents, sourceName) {\n const parsed = parseBundle(contents, sourceName);\n const configDirectory = zedConfigDirectory();\n const backupDirectory = await backupCurrentConfiguration(configDirectory);\n for (const [relativePath, sourceContents] of Object.entries(parsed.files)) {\n // Gist identity and sync history belong to each machine and are never imported.\n if (relativePath === LOCAL_SYNC_CONFIG) continue;\n const destination = join(configDirectory, ...relativePath.split(\"/\"));\n const installedContents = relativePath === \"tasks.json\"\n ? installedTasks(sourceContents, configDirectory)\n : sourceContents;\n await writeAtomically(destination, installedContents);\n }\n console.log(`Imported Zed settings from ${sourceName}`);\n console.log(`Previous settings were backed up to ${backupDirectory}`);\n return { ...parsed, backupDirectory };\n}\n\nasync function importBundle(path) {\n return installBundleContents(await readFile(path, \"utf8\"), path);\n}\n\nfunction runPowerShell(command, extraEnv = {}) {\n const result = spawnSync(\"powershell.exe\", [\"-NoProfile\", \"-STA\", \"-Command\", command], {\n encoding: \"utf8\",\n env: { ...process.env, ...extraEnv },\n windowsHide: false,\n });\n if (result.status !== 0) throw new Error(result.stderr.trim() || \"Windows dialog failed\");\n return result.stdout.trim();\n}\n\nfunction nativeFileDialog(mode) {\n if (process.platform === \"win32\") {\n const common = \"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); \";\n const command = mode === \"save\"\n ? `${common}$d=New-Object System.Windows.Forms.SaveFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; $d.AddExtension=$true; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()`\n : `${common}$d=New-Object System.Windows.Forms.OpenFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()`;\n return runPowerShell(command);\n }\n if (process.platform === \"darwin\") {\n const script = mode === \"save\"\n ? `POSIX path of (choose file name with prompt \"Export Zed settings\" default name \"${BUNDLE_NAME}\")`\n : \"POSIX path of (choose file with prompt \\\"Import Zed settings\\\")\";\n const result = spawnSync(\"osascript\", [\"-e\", script], { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n }\n const args = [\"--file-selection\", `--title=${mode === \"save\" ? \"Export\" : \"Import\"} Zed settings`];\n if (mode === \"save\") args.push(\"--save\", \"--confirm-overwrite\", `--filename=${BUNDLE_NAME}`);\n const result = spawnSync(\"zenity\", args, { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n}\n\nfunction nativeInput(prompt, title, defaultValue = \"\") {\n if (process.platform === \"win32\") {\n const command = `\n [Console]::OutputEncoding=[Text.Encoding]::UTF8\n Add-Type -AssemblyName System.Windows.Forms\n Add-Type -AssemblyName System.Drawing\n $f=New-Object System.Windows.Forms.Form\n $f.Text=$env:ZED_SYNC_TITLE\n $f.Size=New-Object System.Drawing.Size(620,190)\n $f.StartPosition='CenterScreen'\n $f.TopMost=$true\n $f.ShowInTaskbar=$true\n $f.FormBorderStyle='FixedDialog'\n $f.MaximizeBox=$false\n $f.MinimizeBox=$false\n $l=New-Object System.Windows.Forms.Label\n $l.Text=$env:ZED_SYNC_PROMPT\n $l.AutoSize=$false\n $l.Location=New-Object System.Drawing.Point(14,14)\n $l.Size=New-Object System.Drawing.Size(575,42)\n $t=New-Object System.Windows.Forms.TextBox\n $t.Text=$env:ZED_SYNC_DEFAULT\n $t.Location=New-Object System.Drawing.Point(14,62)\n $t.Size=New-Object System.Drawing.Size(575,24)\n $ok=New-Object System.Windows.Forms.Button\n $ok.Text='OK'\n $ok.DialogResult=[System.Windows.Forms.DialogResult]::OK\n $ok.Location=New-Object System.Drawing.Point(427,105)\n $cancel=New-Object System.Windows.Forms.Button\n $cancel.Text='Cancel'\n $cancel.DialogResult=[System.Windows.Forms.DialogResult]::Cancel\n $cancel.Location=New-Object System.Drawing.Point(514,105)\n $f.Controls.AddRange(@($l,$t,$ok,$cancel))\n $f.AcceptButton=$ok\n $f.CancelButton=$cancel\n $f.Add_Shown({$f.Activate();$t.Focus();$t.SelectAll()})\n if($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){[Console]::Write($t.Text)}\n `;\n return runPowerShell(command, {\n ZED_SYNC_PROMPT: prompt,\n ZED_SYNC_TITLE: title,\n ZED_SYNC_DEFAULT: defaultValue,\n });\n }\n if (process.platform === \"darwin\") {\n const escaped = (value) => value.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"');\n const script = `text returned of (display dialog \"${escaped(prompt)}\" with title \"${escaped(title)}\" default answer \"${escaped(defaultValue)}\")`;\n const result = spawnSync(\"osascript\", [\"-e\", script], { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n }\n const result = spawnSync(\"zenity\", [\"--entry\", `--title=${title}`, `--text=${prompt}`, `--entry-text=${defaultValue}`], { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n}\n\nfunction nativeMessage(message, title = \"Zed Settings Sync\", error = false) {\n if (process.platform === \"win32\") {\n const command = \"Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [void][System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON); $o.Close()\";\n runPowerShell(command, {\n ZED_SYNC_MESSAGE: message,\n ZED_SYNC_TITLE: title,\n ZED_SYNC_ICON: error ? \"Error\" : \"Information\",\n });\n return;\n }\n if (process.platform === \"darwin\") {\n const escaped = message.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"');\n spawnSync(\"osascript\", [\"-e\", `display dialog \"${escaped}\" with title \"${title}\" buttons {\"OK\"}`]);\n return;\n }\n spawnSync(\"zenity\", [error ? \"--error\" : \"--info\", `--title=${title}`, `--text=${message}`]);\n}\n\nfunction nativeConfirm(message, title = \"Zed Settings Sync\") {\n if (process.platform === \"win32\") {\n const command = \"Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); [Console]::Write([System.Windows.Forms.MessageBox]::Show($o,$env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning')); $o.Close()\";\n return runPowerShell(command, { ZED_SYNC_MESSAGE: message, ZED_SYNC_TITLE: title }) === \"Yes\";\n }\n if (process.platform === \"darwin\") {\n const escaped = message.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"');\n const result = spawnSync(\"osascript\", [\"-e\", `button returned of (display dialog \"${escaped}\" with title \"${title}\" buttons {\"Cancel\", \"Continue\"} default button \"Continue\" cancel button \"Cancel\")`], { encoding: \"utf8\" });\n return result.status === 0 && result.stdout.trim() === \"Continue\";\n }\n return spawnSync(\"zenity\", [\"--question\", `--title=${title}`, `--text=${message}`]).status === 0;\n}\n\nasync function readSyncConfiguration() {\n const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split(\"/\"));\n let source = {};\n try {\n source = JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (error.code !== \"ENOENT\") throw error;\n }\n return {\n path,\n config: {\n gist_id: typeof source.gist_id === \"string\" ? source.gist_id : \"\",\n last_synced_hash: typeof source.last_synced_hash === \"string\" ? source.last_synced_hash : \"\",\n },\n };\n}\n\nasync function saveSyncConfiguration(path, config) {\n await writeAtomically(path, `${JSON.stringify(config, null, 2)}\\n`);\n}\n\nfunction normalizeGistId(value) {\n const matches = value.trim().match(/[a-f0-9]{8,}/gi);\n if (!matches?.length) throw new Error(\"Enter a valid GitHub Gist ID or URL\");\n return matches.at(-1).toLowerCase();\n}\n\nasync function promptForGist() {\n const state = await readSyncConfiguration();\n console.log(\"Opening Gist configuration dialog...\");\n const entered = nativeInput(\n \"Paste the GitHub Gist ID or full Gist URL. The previous value is remembered on this machine.\",\n \"Zed Settings Gist\",\n state.config.gist_id,\n );\n if (!entered) return null;\n const gistId = normalizeGistId(entered);\n if (gistId !== state.config.gist_id) {\n state.config.gist_id = gistId;\n state.config.last_synced_hash = \"\";\n await saveSyncConfiguration(state.path, state.config);\n }\n return state;\n}\n\nfunction runGh(args, input) {\n const env = { ...process.env };\n delete env.GH_TOKEN;\n delete env.GITHUB_TOKEN;\n const result = spawnSync(\"gh\", args, { encoding: \"utf8\", env, input, windowsHide: true });\n if (result.error?.code === \"ENOENT\") {\n throw new Error(\"GitHub CLI (gh) is not installed. Install it, then run: gh auth login\");\n }\n if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`);\n return result.stdout.trim();\n}\n\nfunction readRemoteGist(gistId) {\n const gist = JSON.parse(runGh([\"api\", `gists/${gistId}`]));\n const file = gist.files?.[BUNDLE_NAME];\n if (!file) return { gist, parsed: null, contents: null };\n if (file.truncated) throw new Error(`${BUNDLE_NAME} is too large to read through the Gist API`);\n return { gist, parsed: parseBundle(file.content, `Gist ${gistId}`), contents: file.content };\n}\n\nfunction updateRemoteGist(gistId, contents) {\n const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } });\n return JSON.parse(runGh([\"api\", \"--method\", \"PATCH\", `gists/${gistId}`, \"--input\", \"-\"], payload));\n}\n\nfunction createRemoteGist(contents) {\n const payload = JSON.stringify({\n description: \"Zed settings sync\",\n public: false,\n files: { [BUNDLE_NAME]: { content: contents } },\n });\n return JSON.parse(runGh([\"api\", \"--method\", \"POST\", \"gists\", \"--input\", \"-\"], payload));\n}\n\nfunction shortHash(hash) {\n return hash ? hash.slice(0, 12) : \"none\";\n}\n\nasync function configureGist() {\n const state = await promptForGist();\n if (!state) return;\n nativeMessage(`Saved Gist ID:\\n${state.config.gist_id}\\n\\nAuthentication is managed by GitHub CLI (gh).`);\n}\n\nasync function pushGist() {\n const state = await readSyncConfiguration();\n const localBundle = await createBundle();\n if (!state.config.gist_id) {\n console.log(\"No Gist is configured. Asking to create one...\");\n const proceed = nativeConfirm(\n \"No GitHub Gist is configured. Create a new Secret Gist from this machine's current Zed settings?\",\n );\n if (!proceed) return;\n const created = createRemoteGist(localBundle.contents);\n state.config.gist_id = created.id;\n state.config.last_synced_hash = localBundle.hash;\n await saveSyncConfiguration(state.path, state.config);\n nativeMessage(`Created and uploaded Secret Gist:\\n${created.html_url || created.id}\\n\\nSHA-256: ${localBundle.hash}`);\n return;\n }\n const remote = readRemoteGist(state.config.gist_id);\n const remoteHash = remote.parsed?.hash || \"\";\n const lastHash = state.config.last_synced_hash;\n if (remoteHash && remoteHash !== localBundle.hash) {\n const remoteChanged = !lastHash || remoteHash !== lastHash;\n if (remoteChanged) {\n const proceed = nativeConfirm(\n `The Gist contains different settings.\\n\\nLocal: ${shortHash(localBundle.hash)}\\nRemote: ${shortHash(remoteHash)}\\nLast sync: ${shortHash(lastHash)}\\n\\nOverwrite the Gist with this machine's settings?`,\n );\n if (!proceed) return;\n }\n }\n const updated = updateRemoteGist(state.config.gist_id, localBundle.contents);\n state.config.last_synced_hash = localBundle.hash;\n await saveSyncConfiguration(state.path, state.config);\n nativeMessage(`Uploaded to Gist ${state.config.gist_id}\\n\\nSHA-256: ${localBundle.hash}\\nUpdated: ${updated.updated_at || \"unknown\"}`);\n}\n\nasync function pullGist() {\n const state = await promptForGist();\n if (!state) return;\n const localBundle = await createBundle();\n const remote = readRemoteGist(state.config.gist_id);\n if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`);\n if (localBundle.hash !== remote.parsed.hash) {\n const localChanged = !state.config.last_synced_hash || localBundle.hash !== state.config.last_synced_hash;\n if (localChanged) {\n const proceed = nativeConfirm(\n `This machine contains different settings.\\n\\nLocal: ${shortHash(localBundle.hash)}\\nRemote: ${shortHash(remote.parsed.hash)}\\nLast sync: ${shortHash(state.config.last_synced_hash)}\\n\\nReplace local settings with the Gist version? A backup will be created first.`,\n );\n if (!proceed) return;\n }\n await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);\n }\n state.config.last_synced_hash = remote.parsed.hash;\n await saveSyncConfiguration(state.path, state.config);\n nativeMessage(`Downloaded from Gist ${state.config.gist_id}\\n\\nSHA-256: ${remote.parsed.hash}\\nRemote updated: ${remote.gist.updated_at || \"unknown\"}`);\n}\n\nasync function showGistStatus() {\n const state = await promptForGist();\n if (!state) return;\n const localBundle = await createBundle();\n const remote = readRemoteGist(state.config.gist_id);\n const remoteHash = remote.parsed?.hash || \"\";\n const lastHash = state.config.last_synced_hash;\n let status = \"The Gist does not contain a Zed settings file.\";\n if (remoteHash === localBundle.hash) status = \"Synchronized\";\n else if (!lastHash) status = \"Not synchronized on this machine\";\n else if (localBundle.hash === lastHash) status = \"Remote changes available\";\n else if (remoteHash === lastHash) status = \"Local changes pending upload\";\n else status = \"Conflict: local and remote settings both changed\";\n nativeMessage(\n `Gist: ${remote.gist.html_url || state.config.gist_id}\\nStatus: ${status}\\n\\nLocal: ${shortHash(localBundle.hash)}\\nRemote: ${shortHash(remoteHash)}\\nLast sync: ${shortHash(lastHash)}\\nRemote updated: ${remote.gist.updated_at || \"unknown\"}`,\n \"Zed Settings Gist Status\",\n );\n}\n\nasync function main() {\n const [mode, explicitPath] = process.argv.slice(2);\n if (mode === \"export\") return exportBundle(resolve(explicitPath));\n if (mode === \"import\") return importBundle(resolve(explicitPath));\n if (mode === \"export-dialog\") {\n const path = nativeFileDialog(\"save\");\n if (path) await exportBundle(path);\n return;\n }\n if (mode === \"import-dialog\") {\n const path = nativeFileDialog(\"open\");\n if (path) await importBundle(path);\n return;\n }\n if (mode === \"gist-configure\") return configureGist();\n if (mode === \"gist-status\") return showGistStatus();\n if (mode === \"gist-push\") return pushGist();\n if (mode === \"gist-pull\") return pullGist();\n throw new Error(`Unknown mode: ${mode || \"\"}`);\n}\n\ntry {\n await main();\n} catch (error) {\n console.error(error.stack || error.message);\n try {\n nativeMessage(error.message, \"Zed Settings Sync Error\", true);\n } catch {\n // The terminal output remains available when the native dialog backend is unavailable.\n }\n process.exitCode = 1;\n}\n", + "scripts/zed-settings-sync.mjs": "import { createHash } from \"node:crypto\";\nimport {\n appendFile,\n copyFile,\n mkdir,\n readFile,\n readdir,\n rename,\n rm,\n stat,\n writeFile,\n} from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, resolve } from \"node:path\";\nimport { spawnSync } from \"node:child_process\";\nimport { createInterface } from \"node:readline/promises\";\n\nconst BUNDLE_NAME = \"zed-settings-sync.json\";\nconst GIST_DESCRIPTION = \"Zed settings sync\";\nconst REQUIRED_FILES = [\"settings.json\", \"keymap.json\", \"tasks.json\"];\nconst FIXED_FILES = [\n ...REQUIRED_FILES,\n \"debug.json\",\n \"scripts/toggle-file-scan-exclusions.mjs\",\n \"scripts/file-exclusions.json\",\n \"scripts/zed-settings-sync.mjs\",\n];\nconst OPTIONAL_DIRECTORIES = [\"snippets\", \"themes\"];\nconst LOCAL_SYNC_CONFIG = \"scripts/settings-sync.json\";\nconst DEFAULT_SYNC_CONFIG = {\n gist_id: \"\",\n last_synced_hash: \"\",\n auto_sync: false,\n interval_minutes: 15,\n last_run_at: \"\",\n last_result: \"\",\n};\nconst LOCK_MAX_AGE_MS = 10 * 60 * 1000;\nconst LOG_MAX_BYTES = 1024 * 1024;\nconst SCHEDULER_NAME = \"ZedSettingsSync\";\n\nfunction zedConfigDirectory() {\n if (process.env.ZED_CONFIG_DIR) return resolve(process.env.ZED_CONFIG_DIR);\n if (process.platform === \"win32\") {\n if (!process.env.APPDATA) throw new Error(\"APPDATA is not set\");\n return join(process.env.APPDATA, \"Zed\");\n }\n return join(process.env.XDG_CONFIG_HOME || join(homedir(), \".config\"), \"zed\");\n}\n\nfunction timestamp() {\n return new Date().toISOString().replace(/[:.]/g, \"-\");\n}\n\nfunction now() {\n return new Date().toISOString();\n}\n\nasync function writeAtomically(path, contents) {\n await mkdir(dirname(path), { recursive: true });\n const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);\n try {\n await writeFile(temporaryPath, contents, \"utf8\");\n await rename(temporaryPath, path);\n } finally {\n await rm(temporaryPath, { force: true });\n }\n}\n\nasync function listFilesRecursively(directory, prefix) {\n if (!existsSync(directory)) return [];\n const results = [];\n for (const entry of await readdir(directory, { withFileTypes: true })) {\n const absolutePath = join(directory, entry.name);\n const relativePath = `${prefix}/${entry.name}`;\n if (entry.isDirectory()) results.push(...await listFilesRecursively(absolutePath, relativePath));\n else if (entry.isFile()) results.push(relativePath);\n }\n return results;\n}\n\nasync function synchronizedPaths(configDirectory) {\n const paths = FIXED_FILES.filter((path) => existsSync(join(configDirectory, ...path.split(\"/\"))));\n for (const directory of OPTIONAL_DIRECTORIES) {\n paths.push(...await listFilesRecursively(join(configDirectory, directory), directory));\n }\n return [...new Set(paths)].sort();\n}\n\nfunction isAllowedBundlePath(path) {\n if (typeof path !== \"string\" || path.startsWith(\"/\") || path.includes(\"\\\\\")) return false;\n const segments = path.split(\"/\");\n if (segments.some((segment) => !segment || segment === \".\" || segment === \"..\")) return false;\n if (FIXED_FILES.includes(path) || path === LOCAL_SYNC_CONFIG) return true;\n return OPTIONAL_DIRECTORIES.some((directory) => path.startsWith(`${directory}/`));\n}\n\nfunction replaceTaskPath(source, path, placeholder) {\n return source\n .replaceAll(path.replaceAll(\"\\\\\", \"\\\\\\\\\"), placeholder)\n .replaceAll(path.replaceAll(\"\\\\\", \"/\"), placeholder);\n}\n\nfunction portableTasks(source, configDirectory) {\n return replaceTaskPath(\n replaceTaskPath(\n source,\n join(configDirectory, \"scripts\", \"toggle-file-scan-exclusions.mjs\"),\n \"__TOGGLE_SCRIPT__\",\n ),\n join(configDirectory, \"scripts\", \"zed-settings-sync.mjs\"),\n \"__SYNC_SCRIPT__\",\n );\n}\n\nfunction installedTasks(source, configDirectory) {\n return source\n .replaceAll(\n \"__TOGGLE_SCRIPT__\",\n join(configDirectory, \"scripts\", \"toggle-file-scan-exclusions.mjs\").replaceAll(\"\\\\\", \"/\"),\n )\n .replaceAll(\n \"__SYNC_SCRIPT__\",\n join(configDirectory, \"scripts\", \"zed-settings-sync.mjs\").replaceAll(\"\\\\\", \"/\"),\n );\n}\n\nfunction hashContents(contents) {\n return createHash(\"sha256\").update(contents).digest(\"hex\");\n}\n\nasync function createBundle() {\n const configDirectory = zedConfigDirectory();\n const files = {};\n for (const relativePath of await synchronizedPaths(configDirectory)) {\n const absolutePath = join(configDirectory, ...relativePath.split(\"/\"));\n let contents = await readFile(absolutePath, \"utf8\");\n if (relativePath === \"tasks.json\") contents = portableTasks(contents, configDirectory);\n files[relativePath] = contents;\n }\n for (const required of REQUIRED_FILES) {\n if (!(required in files)) throw new Error(`Cannot export: ${required} is missing`);\n }\n const contents = serializeBundle(files);\n return { files, contents, hash: hashContents(contents) };\n}\n\nfunction serializeBundle(files) {\n return `${JSON.stringify(files, null, 2)}\\n`;\n}\n\nfunction parseBundle(contents, sourceName) {\n let files;\n try {\n files = JSON.parse(contents);\n } catch (error) {\n throw new Error(`Invalid JSON in ${sourceName}: ${error.message}`);\n }\n if (!files || Array.isArray(files) || typeof files !== \"object\") {\n throw new Error(`Invalid Zed settings bundle: ${sourceName}`);\n }\n for (const required of REQUIRED_FILES) {\n if (typeof files[required] !== \"string\") throw new Error(`Invalid bundle: ${required} is missing`);\n }\n for (const [path, value] of Object.entries(files)) {\n if (!isAllowedBundlePath(path) || typeof value !== \"string\") {\n throw new Error(`Invalid bundle file: ${path}`);\n }\n }\n return { files, hash: hashContents(contents) };\n}\n\nasync function exportBundle(path) {\n const bundle = await createBundle();\n await writeAtomically(path, bundle.contents);\n console.log(`Exported Zed settings to ${path}`);\n console.log(`SHA-256: ${bundle.hash}`);\n return bundle;\n}\n\nasync function backupCurrentConfiguration(configDirectory) {\n const backupDirectory = join(configDirectory, \"backups\", `sync-import-${timestamp()}`);\n await mkdir(backupDirectory, { recursive: true });\n for (const relativePath of await synchronizedPaths(configDirectory)) {\n const source = join(configDirectory, ...relativePath.split(\"/\"));\n const destination = join(backupDirectory, ...relativePath.split(\"/\"));\n await mkdir(dirname(destination), { recursive: true });\n await copyFile(source, destination);\n }\n return backupDirectory;\n}\n\nasync function installBundleContents(contents, sourceName) {\n const parsed = parseBundle(contents, sourceName);\n const configDirectory = zedConfigDirectory();\n const backupDirectory = await backupCurrentConfiguration(configDirectory);\n for (const [relativePath, sourceContents] of Object.entries(parsed.files)) {\n if (relativePath === LOCAL_SYNC_CONFIG) continue;\n const destination = join(configDirectory, ...relativePath.split(\"/\"));\n const installedContents = relativePath === \"tasks.json\"\n ? installedTasks(sourceContents, configDirectory)\n : sourceContents;\n await writeAtomically(destination, installedContents);\n }\n console.log(`Imported Zed settings from ${sourceName}`);\n console.log(`Previous settings were backed up to ${backupDirectory}`);\n return { ...parsed, backupDirectory };\n}\n\nasync function importBundle(path) {\n return installBundleContents(await readFile(path, \"utf8\"), path);\n}\n\nfunction runPowerShell(command, extraEnv = {}) {\n const result = spawnSync(\"powershell.exe\", [\"-NoProfile\", \"-STA\", \"-Command\", command], {\n encoding: \"utf8\",\n env: { ...process.env, ...extraEnv },\n windowsHide: false,\n });\n if (result.status !== 0) throw new Error(result.stderr.trim() || \"Windows dialog failed\");\n return result.stdout.trim();\n}\n\nfunction nativeFileDialog(mode) {\n if (process.env.ZED_SYNC_NO_DIALOGS === \"1\") return \"\";\n if (process.platform === \"win32\") {\n const common = \"[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $o=New-Object System.Windows.Forms.Form; $o.TopMost=$true; $o.ShowInTaskbar=$false; $o.Opacity=0; $o.Show(); $o.Activate(); \";\n const command = mode === \"save\"\n ? `${common}$d=New-Object System.Windows.Forms.SaveFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; $d.AddExtension=$true; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()`\n : `${common}$d=New-Object System.Windows.Forms.OpenFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; if($d.ShowDialog($o) -eq 'OK'){[Console]::Write($d.FileName)}; $o.Close()`;\n return runPowerShell(command);\n }\n if (process.platform === \"darwin\") {\n const script = mode === \"save\"\n ? `POSIX path of (choose file name with prompt \"Export Zed settings\" default name \"${BUNDLE_NAME}\")`\n : \"POSIX path of (choose file with prompt \\\"Import Zed settings\\\")\";\n const result = spawnSync(\"osascript\", [\"-e\", script], { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n }\n const args = [\"--file-selection\", `--title=${mode === \"save\" ? \"Export\" : \"Import\"} Zed settings`];\n if (mode === \"save\") args.push(\"--save\", \"--confirm-overwrite\", `--filename=${BUNDLE_NAME}`);\n const result = spawnSync(\"zenity\", args, { encoding: \"utf8\" });\n return result.status === 0 ? result.stdout.trim() : \"\";\n}\n\nasync function readSyncConfiguration() {\n const path = join(zedConfigDirectory(), ...LOCAL_SYNC_CONFIG.split(\"/\"));\n let source = {};\n try {\n source = JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if (error.code !== \"ENOENT\") throw error;\n }\n return {\n path,\n config: {\n ...DEFAULT_SYNC_CONFIG,\n gist_id: typeof source.gist_id === \"string\" ? source.gist_id : \"\",\n last_synced_hash: typeof source.last_synced_hash === \"string\" ? source.last_synced_hash : \"\",\n auto_sync: source.auto_sync === true,\n interval_minutes: Number.isInteger(source.interval_minutes) && source.interval_minutes >= 5\n ? source.interval_minutes\n : DEFAULT_SYNC_CONFIG.interval_minutes,\n last_run_at: typeof source.last_run_at === \"string\" ? source.last_run_at : \"\",\n last_result: typeof source.last_result === \"string\" ? source.last_result : \"\",\n },\n };\n}\n\nasync function saveSyncConfiguration(path, config) {\n await writeAtomically(path, `${JSON.stringify(config, null, 2)}\\n`);\n}\n\nfunction normalizeGistId(value) {\n const matches = value.trim().match(/[a-f0-9]{8,}/gi);\n if (!matches?.length) throw new Error(\"Enter a valid GitHub Gist ID or URL\");\n return matches.at(-1).toLowerCase();\n}\n\nfunction sanitizedEnvironment() {\n const env = { ...process.env };\n delete env.GH_TOKEN;\n delete env.GITHUB_TOKEN;\n return env;\n}\n\nfunction execute(command, args, options = {}) {\n return spawnSync(command, args, {\n encoding: options.stdio === \"inherit\" ? undefined : \"utf8\",\n env: options.env || process.env,\n input: options.input,\n stdio: options.stdio || \"pipe\",\n windowsHide: options.windowsHide ?? true,\n });\n}\n\nfunction ghMissingMessage() {\n if (process.platform === \"win32\") return \"GitHub CLI is required. Install it with: winget install GitHub.cli\";\n if (process.platform === \"darwin\") return \"GitHub CLI is required. Install it with: brew install gh\";\n return \"GitHub CLI is required. Install it from https://cli.github.com/\";\n}\n\nfunction executeGh(args, options = {}) {\n if (process.env.ZED_SYNC_GH_SCRIPT) {\n return execute(process.execPath, [process.env.ZED_SYNC_GH_SCRIPT, ...args], options);\n }\n return execute(\"gh\", args, options);\n}\n\nfunction testGhAuthentication(env) {\n const user = executeGh([\"api\", \"user\", \"--jq\", \".login\"], { env });\n if (user.error?.code === \"ENOENT\") return { ok: false, missing: true, message: ghMissingMessage() };\n if (user.status !== 0) {\n return { ok: false, login: \"\", message: (user.stderr || user.stdout || \"GitHub authentication failed\").trim() };\n }\n const gists = executeGh([\"api\", \"gists?per_page=1\", \"--silent\"], { env });\n return {\n ok: gists.status === 0,\n login: user.stdout.trim(),\n message: gists.status === 0 ? \"\" : (gists.stderr || gists.stdout || \"The GitHub credential cannot access Gists\").trim(),\n };\n}\n\nasync function resolveGhAuthentication({ interactive = false, requireStored = false } = {}) {\n const hasEnvironmentToken = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN);\n if (!requireStored) {\n const current = testGhAuthentication(process.env);\n if (current.missing) throw new Error(current.message);\n if (current.ok) {\n return { env: process.env, login: current.login, source: hasEnvironmentToken ? \"environment\" : \"credential-store\" };\n }\n if (hasEnvironmentToken) console.log(\"The inherited GitHub token is invalid; trying stored GitHub CLI credentials.\");\n }\n\n const cleanEnv = sanitizedEnvironment();\n const stored = testGhAuthentication(cleanEnv);\n if (stored.missing) throw new Error(stored.message);\n if (stored.ok) return { env: cleanEnv, login: stored.login, source: \"credential-store\" };\n if (!interactive) throw new Error(\"GitHub is not authenticated. Run 'Zed Settings: Set Up / Reconfigure Sync...'.\");\n\n console.log(\"Opening GitHub browser authentication. No personal access token needs to be pasted.\");\n const authenticationArgs = stored.login\n ? [\"auth\", \"refresh\", \"--hostname\", \"github.com\", \"--scopes\", \"gist\"]\n : [\"auth\", \"login\", \"--hostname\", \"github.com\", \"--web\", \"--clipboard\", \"--scopes\", \"gist\"];\n const login = executeGh(\n authenticationArgs,\n { env: cleanEnv, stdio: \"inherit\", windowsHide: false },\n );\n if (login.status !== 0) throw new Error(\"GitHub browser authentication was cancelled or failed.\");\n const verified = testGhAuthentication(cleanEnv);\n if (!verified.ok) throw new Error(verified.message || \"GitHub authentication could not be verified.\");\n return { env: cleanEnv, login: verified.login, source: \"credential-store\" };\n}\n\nfunction runGh(args, auth, input) {\n const result = executeGh(args, { env: auth.env, input });\n if (result.error?.code === \"ENOENT\") throw new Error(ghMissingMessage());\n if (result.status !== 0) throw new Error((result.stderr || result.stdout || `gh ${args[0]} failed`).trim());\n return result.stdout.trim();\n}\n\nfunction readRemoteGist(gistId, auth) {\n const gist = JSON.parse(runGh([\"api\", `gists/${gistId}`], auth));\n const file = gist.files?.[BUNDLE_NAME];\n if (!file) return { gist, parsed: null, contents: null };\n if (file.truncated) throw new Error(`${BUNDLE_NAME} is too large to read through the Gist API`);\n return { gist, parsed: parseBundle(file.content, `Gist ${gistId}`), contents: file.content };\n}\n\nfunction updateRemoteGist(gistId, contents, auth) {\n const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } });\n return JSON.parse(runGh([\"api\", \"--method\", \"PATCH\", `gists/${gistId}`, \"--input\", \"-\"], auth, payload));\n}\n\nfunction createRemoteGist(contents, auth) {\n const payload = JSON.stringify({\n description: GIST_DESCRIPTION,\n public: false,\n files: { [BUNDLE_NAME]: { content: contents } },\n });\n return JSON.parse(runGh([\"api\", \"--method\", \"POST\", \"gists\", \"--input\", \"-\"], auth, payload));\n}\n\nfunction discoverGists(auth) {\n const raw = runGh([\"api\", \"--paginate\", \"--slurp\", \"gists?per_page=100\"], auth);\n const pages = JSON.parse(raw);\n const gists = (Array.isArray(pages[0]) ? pages.flat() : pages)\n .filter((gist) => gist.files?.[BUNDLE_NAME])\n .sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));\n return gists;\n}\n\nfunction shortHash(hash) {\n return hash ? hash.slice(0, 12) : \"none\";\n}\n\nfunction classifySync(localHash, remoteHash, lastHash) {\n if (!remoteHash) return \"remote-missing\";\n if (localHash === remoteHash) return \"synchronized\";\n if (!lastHash) return \"no-baseline\";\n if (localHash === lastHash) return \"remote-changed\";\n if (remoteHash === lastHash) return \"local-changed\";\n return \"conflict\";\n}\n\nlet scriptedAnswers;\nasync function ask(question, defaultValue = \"\") {\n if (scriptedAnswers === undefined) {\n try {\n scriptedAnswers = JSON.parse(process.env.ZED_SYNC_ANSWERS || \"[]\");\n } catch {\n scriptedAnswers = [];\n }\n }\n if (scriptedAnswers.length) {\n const answer = String(scriptedAnswers.shift());\n console.log(`${question}${answer}`);\n return answer;\n }\n if (!process.stdin.isTTY) throw new Error(\"Interactive input is required. Run this command from the Zed Task Picker.\");\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n try {\n return (await rl.question(question)).trim() || defaultValue;\n } finally {\n rl.close();\n }\n}\n\nasync function askYesNo(question, defaultYes = false) {\n const answer = (await ask(`${question} ${defaultYes ? \"[Y/n]\" : \"[y/N]\"} `, defaultYes ? \"y\" : \"n\")).toLowerCase();\n return answer === \"y\" || answer === \"yes\";\n}\n\nasync function appendLog(message) {\n const logDirectory = join(zedConfigDirectory(), \"logs\");\n const logPath = join(logDirectory, \"settings-sync.log\");\n await mkdir(logDirectory, { recursive: true });\n try {\n if ((await stat(logPath)).size > LOG_MAX_BYTES) {\n await rm(`${logPath}.3`, { force: true });\n if (existsSync(`${logPath}.2`)) await rename(`${logPath}.2`, `${logPath}.3`);\n if (existsSync(`${logPath}.1`)) await rename(`${logPath}.1`, `${logPath}.2`);\n await rename(logPath, `${logPath}.1`);\n }\n } catch (error) {\n if (error.code !== \"ENOENT\") throw error;\n }\n await appendFile(logPath, `${now()} ${message}\\n`, \"utf8\");\n}\n\nasync function recordResult(state, result) {\n state.config.last_run_at = now();\n state.config.last_result = result;\n await saveSyncConfiguration(state.path, state.config);\n await appendLog(result);\n}\n\nfunction notify(message) {\n if (process.env.ZED_SYNC_NO_NOTIFICATIONS === \"1\") return;\n if (process.platform === \"win32\") {\n execute(\"msg.exe\", [process.env.USERNAME || \"*\", message]);\n } else if (process.platform === \"darwin\") {\n const escaped = message.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"');\n execute(\"osascript\", [\"-e\", `display notification \"${escaped}\" with title \"Zed Settings Sync\"`]);\n } else {\n execute(\"notify-send\", [\"Zed Settings Sync\", message]);\n }\n}\n\nasync function withSyncLock(action) {\n const lockPath = join(zedConfigDirectory(), \"scripts\", \".settings-sync.lock\");\n try {\n await mkdir(lockPath, { recursive: false });\n } catch (error) {\n if (error.code !== \"EEXIST\") throw error;\n const age = Date.now() - (await stat(lockPath)).mtimeMs;\n if (age <= LOCK_MAX_AGE_MS) {\n console.log(\"Another settings sync is already running.\");\n return null;\n }\n await rm(lockPath, { recursive: true, force: true });\n await mkdir(lockPath);\n }\n try {\n await writeFile(join(lockPath, \"owner.json\"), JSON.stringify({ pid: process.pid, started_at: now() }));\n return await action();\n } finally {\n await rm(lockPath, { recursive: true, force: true });\n }\n}\n\nasync function selectSetupGist(gists, state) {\n console.log(\"\\nAvailable Zed settings Gists:\");\n gists.forEach((gist, index) => {\n const current = gist.id === state.config.gist_id ? \" (current)\" : \"\";\n console.log(` ${index + 1}. ${gist.description || \"Untitled\"} - ${gist.id}${current} - ${gist.updated_at}`);\n });\n console.log(\" N. Create a new Secret Gist from this machine\");\n console.log(\" M. Enter another Gist URL or ID\");\n console.log(\" Q. Cancel\");\n const defaultChoice = gists.length === 1 ? \"1\" : (gists.length === 0 ? \"n\" : \"q\");\n const choice = (await ask(`Choose [${defaultChoice.toUpperCase()}]: `, defaultChoice)).toLowerCase();\n if (choice === \"q\") return { action: \"cancel\" };\n if (choice === \"n\") return { action: \"create\" };\n if (choice === \"m\") return { action: \"existing\", gistId: normalizeGistId(await ask(\"Gist URL or ID: \")) };\n const index = Number.parseInt(choice, 10) - 1;\n if (!Number.isInteger(index) || !gists[index]) throw new Error(\"Invalid Gist selection\");\n return { action: \"existing\", gistId: gists[index].id };\n}\n\nasync function setupSync({ offerAutomaticSync = true } = {}) {\n console.log(\"Zed Settings Sync Setup\\n\");\n const auth = await resolveGhAuthentication({ interactive: true });\n console.log(`Connected to GitHub as ${auth.login} (${auth.source}).`);\n const state = await readSyncConfiguration();\n const gists = discoverGists(auth);\n if (state.config.gist_id && !gists.some((gist) => gist.id === state.config.gist_id)) {\n try {\n gists.unshift(readRemoteGist(state.config.gist_id, auth).gist);\n } catch (error) {\n console.log(`The currently configured Gist could not be loaded: ${error.message}`);\n }\n }\n const selection = await selectSetupGist(gists, state);\n if (selection.action === \"cancel\") {\n console.log(\"Setup cancelled. Local Zed settings remain installed and unchanged.\");\n return;\n }\n\n const local = await createBundle();\n if (selection.action === \"create\") {\n const created = createRemoteGist(local.contents, auth);\n state.config.gist_id = created.id;\n state.config.last_synced_hash = local.hash;\n await recordResult(state, `Created and uploaded Secret Gist ${created.id}`);\n console.log(`\\nCreated Secret Gist: ${created.html_url || created.id}`);\n console.log(`SHA-256: ${local.hash}`);\n } else {\n const remote = readRemoteGist(selection.gistId, auth);\n console.log(`\\nRemote: ${remote.gist.html_url || selection.gistId}`);\n state.config.gist_id = selection.gistId;\n if (!remote.parsed) {\n console.log(`This Gist does not contain ${BUNDLE_NAME}.`);\n if (!await askYesNo(\"Upload this machine's current Zed settings to it?\", true)) return;\n updateRemoteGist(selection.gistId, local.contents, auth);\n state.config.last_synced_hash = local.hash;\n await recordResult(state, `Initialized Gist ${selection.gistId} with this machine's settings`);\n } else {\n console.log(`Local SHA-256: ${shortHash(local.hash)}`);\n console.log(`Remote SHA-256: ${shortHash(remote.parsed.hash)}`);\n console.log(\" P. Pull remote settings onto this machine (backup first)\");\n console.log(\" U. Upload this machine's settings to the Gist\");\n console.log(\" Q. Cancel\");\n const direction = (await ask(\"Initial sync direction [P]: \", \"p\")).toLowerCase();\n if (direction === \"q\") return;\n if (direction === \"p\") {\n if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${selection.gistId}`);\n state.config.last_synced_hash = remote.parsed.hash;\n await recordResult(state, `Pulled settings from Gist ${selection.gistId}`);\n } else if (direction === \"u\") {\n updateRemoteGist(selection.gistId, local.contents, auth);\n state.config.last_synced_hash = local.hash;\n await recordResult(state, `Uploaded settings to Gist ${selection.gistId}`);\n } else {\n throw new Error(\"Invalid initial sync direction\");\n }\n }\n await saveSyncConfiguration(state.path, state.config);\n console.log(`Configured Gist ${selection.gistId}.`);\n }\n\n if (offerAutomaticSync) {\n if (await askYesNo(\"Enable automatic synchronization every 15 minutes?\", false)) {\n await enableAutomaticSync();\n } else {\n console.log(\"Automatic synchronization remains disabled. Use Push Now and Pull Now from the Task Picker.\");\n }\n }\n}\n\nasync function requireConfiguredState() {\n const state = await readSyncConfiguration();\n if (state.config.gist_id) return state;\n console.log(\"No Gist is configured. Starting setup...\\n\");\n await setupSync();\n const configured = await readSyncConfiguration();\n return configured.config.gist_id ? configured : null;\n}\n\nasync function pushGist() {\n return withSyncLock(async () => {\n const state = await requireConfiguredState();\n if (!state) return;\n const auth = await resolveGhAuthentication({ interactive: true });\n const local = await createBundle();\n const remote = readRemoteGist(state.config.gist_id, auth);\n const remoteHash = remote.parsed?.hash || \"\";\n const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);\n if ([\"remote-changed\", \"conflict\", \"no-baseline\"].includes(status)) {\n console.log(`Remote settings differ (${status}).`);\n console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remoteHash)} Last: ${shortHash(state.config.last_synced_hash)}`);\n if (!await askYesNo(\"Overwrite the remote Gist with this machine's settings?\", false)) return;\n }\n updateRemoteGist(state.config.gist_id, local.contents, auth);\n state.config.last_synced_hash = local.hash;\n await recordResult(state, `Uploaded settings to Gist ${state.config.gist_id}`);\n console.log(`Uploaded to ${remote.gist.html_url || state.config.gist_id}`);\n console.log(`SHA-256: ${local.hash}`);\n });\n}\n\nasync function pullGist() {\n return withSyncLock(async () => {\n const state = await requireConfiguredState();\n if (!state) return;\n const auth = await resolveGhAuthentication({ interactive: true });\n const local = await createBundle();\n const remote = readRemoteGist(state.config.gist_id, auth);\n if (!remote.parsed) throw new Error(`Gist ${state.config.gist_id} does not contain ${BUNDLE_NAME}`);\n const status = classifySync(local.hash, remote.parsed.hash, state.config.last_synced_hash);\n if ([\"local-changed\", \"conflict\", \"no-baseline\"].includes(status)) {\n console.log(`Local settings differ (${status}).`);\n console.log(`Local: ${shortHash(local.hash)} Remote: ${shortHash(remote.parsed.hash)} Last: ${shortHash(state.config.last_synced_hash)}`);\n if (!await askYesNo(\"Replace local settings with the Gist version? A backup will be created first.\", false)) return;\n }\n if (local.hash !== remote.parsed.hash) await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);\n state.config.last_synced_hash = remote.parsed.hash;\n await recordResult(state, `Pulled settings from Gist ${state.config.gist_id}`);\n console.log(`Downloaded from ${remote.gist.html_url || state.config.gist_id}`);\n console.log(`SHA-256: ${remote.parsed.hash}`);\n });\n}\n\nasync function showStatus() {\n const state = await readSyncConfiguration();\n if (!state.config.gist_id) {\n console.log(\"Sync is not configured. Run 'Zed Settings: Set Up / Reconfigure Sync...'.\");\n return;\n }\n const auth = await resolveGhAuthentication();\n const local = await createBundle();\n const remote = readRemoteGist(state.config.gist_id, auth);\n const status = classifySync(local.hash, remote.parsed?.hash || \"\", state.config.last_synced_hash);\n console.log(`Gist: ${remote.gist.html_url || state.config.gist_id}`);\n console.log(`Status: ${status}`);\n console.log(`Automatic sync: ${state.config.auto_sync ? `enabled (${state.config.interval_minutes} minutes)` : \"disabled\"}`);\n console.log(`Local: ${shortHash(local.hash)}`);\n console.log(`Remote: ${shortHash(remote.parsed?.hash || \"\")}`);\n console.log(`Last: ${shortHash(state.config.last_synced_hash)}`);\n console.log(`Last run: ${state.config.last_run_at || \"never\"}`);\n console.log(`Last result: ${state.config.last_result || \"none\"}`);\n}\n\nasync function automaticSync() {\n return withSyncLock(async () => {\n const state = await readSyncConfiguration();\n if (!state.config.auto_sync) return;\n try {\n if (!state.config.gist_id) throw new Error(\"Automatic sync requires setup\");\n const auth = await resolveGhAuthentication({ requireStored: true });\n const local = await createBundle();\n const remote = readRemoteGist(state.config.gist_id, auth);\n const remoteHash = remote.parsed?.hash || \"\";\n const status = classifySync(local.hash, remoteHash, state.config.last_synced_hash);\n if (status === \"synchronized\") {\n await recordResult(state, \"Automatic sync: already synchronized\");\n } else if (status === \"local-changed\") {\n updateRemoteGist(state.config.gist_id, local.contents, auth);\n state.config.last_synced_hash = local.hash;\n await recordResult(state, \"Automatic sync: uploaded local changes\");\n } else if (status === \"remote-changed\") {\n await installBundleContents(remote.contents, `Gist ${state.config.gist_id}`);\n state.config.last_synced_hash = remote.parsed.hash;\n await recordResult(state, \"Automatic sync: downloaded remote changes\");\n } else {\n const result = `Automatic sync stopped: ${status}`;\n const shouldNotify = state.config.last_result !== result;\n await recordResult(state, result);\n if (shouldNotify) notify(`${result}. Open the Zed Task Picker to resolve it.`);\n }\n } catch (error) {\n const result = `Automatic sync failed: ${error.message}`;\n const shouldNotify = state.config.last_result !== result;\n await recordResult(state, result);\n if (shouldNotify) notify(result);\n throw error;\n }\n });\n}\n\nfunction shellQuote(value) {\n return `'${value.replaceAll(\"'\", `'\"'\"'`)}'`;\n}\n\nfunction xmlEscape(value) {\n return value.replaceAll(\"&\", \"&\").replaceAll(\"<\", \"<\").replaceAll(\">\", \">\");\n}\n\nfunction schedulerPlatform() {\n return process.env.ZED_SYNC_PLATFORM || process.platform;\n}\n\nasync function configureScheduler(enable, intervalMinutes) {\n const platform = schedulerPlatform();\n const scriptPath = resolve(process.argv[1]);\n const dryRun = process.env.ZED_SYNC_SCHEDULER_DRY_RUN === \"1\";\n const actions = [];\n const run = (command, args, options = {}) => {\n actions.push([command, ...args].join(\" \"));\n if (dryRun) return { status: 0, stdout: \"\", stderr: \"\" };\n return execute(command, args, options);\n };\n\n if (platform === \"win32\") {\n if (enable) {\n const taskCommand = `\\\"${process.execPath}\\\" \\\"${scriptPath}\\\" auto-sync`;\n const result = run(\"schtasks.exe\", [\"/Create\", \"/SC\", \"MINUTE\", \"/MO\", String(intervalMinutes), \"/TN\", SCHEDULER_NAME, \"/TR\", taskCommand, \"/F\"]);\n if (result.status !== 0) throw new Error((result.stderr || result.stdout || \"Could not create Windows scheduled task\").trim());\n } else {\n run(\"schtasks.exe\", [\"/Delete\", \"/TN\", SCHEDULER_NAME, \"/F\"]);\n }\n } else if (platform === \"darwin\") {\n const plistPath = join(homedir(), \"Library\", \"LaunchAgents\", \"cool.okk.zed-settings-sync.plist\");\n run(\"launchctl\", [\"unload\", plistPath]);\n if (enable) {\n const plist = `\\n\\n\\n Labelcool.okk.zed-settings-sync\\n ProgramArguments${xmlEscape(process.execPath)}${xmlEscape(scriptPath)}auto-sync\\n StartInterval${intervalMinutes * 60}\\n RunAtLoad\\n\\n`;\n if (!dryRun) await writeAtomically(plistPath, plist);\n actions.push(`write ${plistPath}`);\n const result = run(\"launchctl\", [\"load\", plistPath]);\n if (result.status !== 0) throw new Error((result.stderr || \"Could not load LaunchAgent\").trim());\n } else if (!dryRun) {\n await rm(plistPath, { force: true });\n }\n } else {\n const systemdDirectory = join(homedir(), \".config\", \"systemd\", \"user\");\n const servicePath = join(systemdDirectory, \"zed-settings-sync.service\");\n const timerPath = join(systemdDirectory, \"zed-settings-sync.timer\");\n const systemd = run(\"systemctl\", [\"--user\", \"--version\"]);\n const systemdSession = systemd.status === 0\n ? run(\"systemctl\", [\"--user\", \"show-environment\"])\n : { status: 1 };\n if (enable && systemdSession.status === 0) {\n const service = `[Unit]\\nDescription=Synchronize Zed settings\\n\\n[Service]\\nType=oneshot\\nExecStart=\\\"${process.execPath.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"')}\\\" \\\"${scriptPath.replaceAll(\"\\\\\", \"\\\\\\\\\").replaceAll('\"', '\\\\\"')}\\\" auto-sync\\n`;\n const timer = `[Unit]\\nDescription=Synchronize Zed settings every ${intervalMinutes} minutes\\n\\n[Timer]\\nOnBootSec=2min\\nOnUnitActiveSec=${intervalMinutes}min\\nPersistent=true\\n\\n[Install]\\nWantedBy=timers.target\\n`;\n if (!dryRun) {\n await writeAtomically(servicePath, service);\n await writeAtomically(timerPath, timer);\n }\n actions.push(`write ${servicePath}`, `write ${timerPath}`);\n run(\"systemctl\", [\"--user\", \"daemon-reload\"]);\n const result = run(\"systemctl\", [\"--user\", \"enable\", \"--now\", \"zed-settings-sync.timer\"]);\n if (result.status !== 0) throw new Error((result.stderr || \"Could not enable systemd user timer\").trim());\n } else if (!enable) {\n if (systemd.status === 0) run(\"systemctl\", [\"--user\", \"disable\", \"--now\", \"zed-settings-sync.timer\"]);\n if (!dryRun) {\n await rm(servicePath, { force: true });\n await rm(timerPath, { force: true });\n }\n if (systemdSession.status === 0) run(\"systemctl\", [\"--user\", \"daemon-reload\"]);\n const current = run(\"crontab\", [\"-l\"]);\n if (current.status === 0) {\n const lines = current.stdout.split(/\\r?\\n/).filter((line) => line && !line.includes(\"# zed-settings-sync\"));\n run(\"crontab\", [\"-\"], { input: `${lines.join(\"\\n\")}\\n` });\n }\n } else {\n const current = run(\"crontab\", [\"-l\"]);\n const marker = \"# zed-settings-sync\";\n const lines = (current.status === 0 ? current.stdout : \"\").split(/\\r?\\n/).filter((line) => line && !line.includes(marker));\n lines.push(`*/${intervalMinutes} * * * * ${shellQuote(process.execPath)} ${shellQuote(scriptPath)} auto-sync ${marker}`);\n const updated = `${lines.join(\"\\n\")}\\n`;\n const result = run(\"crontab\", [\"-\"], { input: updated });\n if (result.status !== 0) throw new Error((result.stderr || \"Could not update crontab\").trim());\n }\n }\n if (dryRun) console.log(actions.join(\"\\n\"));\n}\n\nasync function enableAutomaticSync() {\n const state = await readSyncConfiguration();\n if (!state.config.gist_id) {\n console.log(\"Sync must be configured before automatic synchronization can be enabled.\");\n await setupSync({ offerAutomaticSync: false });\n }\n const configured = await readSyncConfiguration();\n if (!configured.config.gist_id) return;\n const auth = await resolveGhAuthentication({ interactive: true, requireStored: true });\n console.log(`Stored GitHub credentials verified for ${auth.login}.`);\n await configureScheduler(true, configured.config.interval_minutes);\n configured.config.auto_sync = true;\n await recordResult(configured, `Automatic sync enabled every ${configured.config.interval_minutes} minutes`);\n console.log(`Automatic synchronization is enabled every ${configured.config.interval_minutes} minutes.`);\n}\n\nasync function disableAutomaticSync() {\n const state = await readSyncConfiguration();\n await configureScheduler(false, state.config.interval_minutes);\n state.config.auto_sync = false;\n await recordResult(state, \"Automatic sync disabled\");\n console.log(\"Automatic synchronization is disabled.\");\n}\n\nasync function main() {\n const [mode, explicitPath] = process.argv.slice(2);\n if (mode === \"export\") return exportBundle(resolve(explicitPath));\n if (mode === \"import\") return importBundle(resolve(explicitPath));\n if (mode === \"export-dialog\") {\n const path = nativeFileDialog(\"save\");\n if (path) await exportBundle(path);\n return;\n }\n if (mode === \"import-dialog\") {\n const path = nativeFileDialog(\"open\");\n if (path) await importBundle(path);\n return;\n }\n if (mode === \"setup\") return setupSync();\n if (mode === \"status\") return showStatus();\n if (mode === \"push\") return pushGist();\n if (mode === \"pull\") return pullGist();\n if (mode === \"auto-sync\") return automaticSync();\n if (mode === \"auto-enable\") return enableAutomaticSync();\n if (mode === \"auto-disable\") return disableAutomaticSync();\n throw new Error(`Unknown mode: ${mode || \"\"}`);\n}\n\ntry {\n await main();\n} catch (error) {\n console.error(error.stack || error.message);\n try {\n await appendLog(`ERROR ${error.message}`);\n } catch {\n // The terminal remains the primary error channel.\n }\n process.exitCode = 1;\n}\n", "settings.json": "// Zed settings\n//\n// For information on how to configure Zed, see the Zed\n// documentation: https://zed.dev/docs/configuring-zed\n//\n// To see all of Zed's default settings without changing your\n// custom settings, run `zed: open default settings` from the\n// command palette (cmd-shift-p / ctrl-shift-p)\n{\n \"cli_default_open_behavior\": \"existing_window\",\n \"icon_theme\": \"Material Icon Theme\",\n \"auto_install_extensions\": {\n \"dockerfile\": true,\n \"env\": true,\n \"html\": true,\n \"material-icon-theme\": true,\n \"mcp-server-context7\": true,\n \"mcp-server-github\": true,\n \"one-dark-pro\": true,\n \"powershell\": true,\n \"toml\": true\n },\n \"disable_ai\": false,\n \"proxy\": \"\",\n \"agent\": {\n \"sidebar_side\": \"right\",\n \"favorite_models\": [],\n \"model_parameters\": []\n },\n \"project_panel\": {\n \"dock\": \"left\"\n },\n \"telemetry\": {\n \"diagnostics\": false,\n \"metrics\": false,\n \"anthropic_retention\": false\n },\n \"terminal\": {\n \"font_size\": 16.0,\n \"cursor_shape\": \"bar\"\n },\n \"base_keymap\": \"VSCode\",\n \"file_scan_exclusions\": [\n \"**/.git\",\n \"**/.svn\",\n \"**/.hg\",\n \"**/.jj\",\n \"**/CVS\",\n \"**/.DS_Store\",\n \"**/Thumbs.db\",\n \"**/.classpath\",\n \"**/.settings\",\n \"**/.playwright-cli\",\n \"**/output\",\n \"**/.cursor\",\n \"**/.vscode\",\n \"**/.idea\",\n \"**/.fleet\",\n \"**/node_modules\",\n \"**/bun.lockb\",\n \"**/pnpm-lock.yaml\",\n \"**/package-lock.json\",\n \"**/yarn.lock\",\n \"**/dist\",\n \"**/out\",\n \"**/build\",\n \"**/.next\",\n \"**/.nuxt\",\n \"**/.output\",\n \"**/.cache\",\n \"**/.turbo\",\n \"**/.vercel\",\n \"**/.netlify\",\n \"**/tsconfig.tsbuildinfo\",\n \"**/auto-imports.d.ts\",\n \"**/components.json\",\n \"**/unplugin-vue-components.d.ts\",\n \"src/*.d.ts\",\n \"**/__pycache__\",\n \"**/__init__.py\",\n \"**/.ipynb_checkpoints\",\n \"**/.pytest_cache\",\n \"**/.mypy_cache\",\n \"**/.venv\",\n \"**/env\",\n \"**/venv\",\n \"**/.github\",\n \"**/.husky\",\n \"**/.npmrc\",\n \"**/.eslintrc*\",\n \"**/.prettier*\",\n \"**/.editorconfig\",\n \"**/.dockerignore\",\n \"**/docker-compose.override.yml\",\n \"**/cypress*\",\n \"**/prisma/migrations\"\n ],\n \"preview_tabs\": {\n \"enabled\": false\n },\n \"git_panel\": {\n \"fallback_branch_name\": \"master\"\n },\n \"sticky_scroll\": {\n \"enabled\": true\n },\n \"minimap\": {\n \"show\": \"always\"\n },\n \"hover_popover_delay\": 250,\n \"cursor_blink\": true,\n \"autosave\": \"on_focus_change\",\n \"buffer_font_fallbacks\": [\n \"Consolas\",\n \"Sarasa Gothic SC\",\n \"Courier New\",\n \"monospace\"\n ],\n \"buffer_font_family\": \"Fira Code\",\n \"auto_indent_on_paste\": true,\n \"linked_edits\": true,\n \"show_edit_predictions\": true,\n \"line_ending\": \"prefer_lf\",\n \"hard_tabs\": false,\n \"tab_size\": 2,\n \"agent_servers\": {\n \"codex-acp\": {\n \"type\": \"registry\"\n }\n },\n \"ui_font_size\": 16,\n \"buffer_font_size\": 16.0,\n \"theme\": {\n \"mode\": \"system\",\n \"light\": \"One Light\",\n \"dark\": \"One Dark Pro\",\n },\n}\n", - "tasks.json": "// Project tasks configuration. See https://zed.dev/docs/tasks for documentation.\r\n//\n// Example:\n[\n /* The generated example task is intentionally disabled.\n {\n \"label\": \"Example task\",\r\n \"command\": \"for i in {1..5}; do echo \\\"Hello $i/5\\\"; sleep 1; done\",\r\n //\"args\": [],\r\n // Env overrides for the command, will be appended to the terminal's environment from the settings.\r\n \"env\": { \"foo\": \"bar\" },\r\n // Current working directory to spawn the command into, defaults to current project root.\r\n //\"cwd\": \"/path/to/working/directory\",\r\n // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`.\r\n \"use_new_terminal\": false,\r\n // Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`.\r\n \"allow_concurrent_runs\": false,\r\n // What to do with the terminal pane and tab, after the command was started:\r\n // * `always` — always show the task's pane, and focus the corresponding tab in it (default)\r\n // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it\r\n // * `never` — do not alter focus, but still add/reuse the task's tab in its pane\r\n \"reveal\": \"always\",\r\n // Where to place the task's terminal item after starting the task:\r\n // * `dock` — in the terminal dock, \"regular\" terminal items' place (default)\r\n // * `center` — in the central pane group, \"main\" editor area\r\n \"reveal_target\": \"dock\",\r\n // What to do with the terminal pane and tab, after the command had finished:\r\n // * `never` — Do nothing when the command finishes (default)\r\n // * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it\r\n // * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`\r\n \"hide\": \"never\",\r\n // Which shell to use when running a task inside the terminal.\r\n // May take 3 values:\r\n // 1. (default) Use the system's default terminal configuration in /etc/passwd\r\n // \"shell\": \"system\"\r\n // 2. A program:\r\n // \"shell\": {\r\n // \"program\": \"sh\"\r\n // }\r\n // 3. A program with arguments:\r\n // \"shell\": {\r\n // \"with_arguments\": {\r\n // \"program\": \"/bin/bash\",\r\n // \"args\": [\"--login\"]\r\n // }\r\n // }\r\n \"shell\": \"system\",\r\n // Whether to show the task line in the output of the spawned task, defaults to `true`.\r\n \"show_summary\": true,\r\n // Whether to show the command line in the output of the spawned task, defaults to `true`.\r\n \"show_command\": true,\r\n // Which edited buffers to save before running the task:\r\n // * `all` — save all edited buffers\r\n // * `current` — save currently active buffer only\r\n // * `none` — don't save any buffers\r\n \"save\": \"none\",\r\n // Represents the tags for inline runnable indicators, or spawning multiple tasks at once.\r\n // \"tags\": []\n }, */\n {\n \"label\": \"Toggle excluded files\",\n \"command\": \"node\",\n \"args\": [\n \"__TOGGLE_SCRIPT__\"\n ],\n \"use_new_terminal\": false,\n \"allow_concurrent_runs\": false,\n \"reveal\": \"never\",\n \"hide\": \"always\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"none\"\n },\n {\n \"label\": \"Zed Settings: Export JSON...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"export-dialog\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"never\",\n \"hide\": \"always\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Import JSON...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"import-dialog\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"never\",\n \"hide\": \"always\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Configure GitHub Gist...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"gist-configure\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"no_focus\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Show GitHub Gist Status\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"gist-status\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"no_focus\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Push to GitHub Gist\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"gist-push\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"no_focus\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Pull from GitHub Gist\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"gist-pull\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"no_focus\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Authenticate GitHub...\",\n \"command\": \"powershell\",\n \"args\": [\n \"-NoExit\",\n \"-Command\",\n \"Remove-Item Env:GH_TOKEN,Env:GITHUB_TOKEN -ErrorAction SilentlyContinue; gh auth login\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": true,\n \"save\": \"none\"\n },\n]\n" + "tasks.json": "// Project tasks configuration. See https://zed.dev/docs/tasks for documentation.\r\n//\n// Example:\n[\n /* The generated example task is intentionally disabled.\n {\n \"label\": \"Example task\",\r\n \"command\": \"for i in {1..5}; do echo \\\"Hello $i/5\\\"; sleep 1; done\",\r\n //\"args\": [],\r\n // Env overrides for the command, will be appended to the terminal's environment from the settings.\r\n \"env\": { \"foo\": \"bar\" },\r\n // Current working directory to spawn the command into, defaults to current project root.\r\n //\"cwd\": \"/path/to/working/directory\",\r\n // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`.\r\n \"use_new_terminal\": false,\r\n // Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`.\r\n \"allow_concurrent_runs\": false,\r\n // What to do with the terminal pane and tab, after the command was started:\r\n // * `always` — always show the task's pane, and focus the corresponding tab in it (default)\r\n // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it\r\n // * `never` — do not alter focus, but still add/reuse the task's tab in its pane\r\n \"reveal\": \"always\",\r\n // Where to place the task's terminal item after starting the task:\r\n // * `dock` — in the terminal dock, \"regular\" terminal items' place (default)\r\n // * `center` — in the central pane group, \"main\" editor area\r\n \"reveal_target\": \"dock\",\r\n // What to do with the terminal pane and tab, after the command had finished:\r\n // * `never` — Do nothing when the command finishes (default)\r\n // * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it\r\n // * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`\r\n \"hide\": \"never\",\r\n // Which shell to use when running a task inside the terminal.\r\n // May take 3 values:\r\n // 1. (default) Use the system's default terminal configuration in /etc/passwd\r\n // \"shell\": \"system\"\r\n // 2. A program:\r\n // \"shell\": {\r\n // \"program\": \"sh\"\r\n // }\r\n // 3. A program with arguments:\r\n // \"shell\": {\r\n // \"with_arguments\": {\r\n // \"program\": \"/bin/bash\",\r\n // \"args\": [\"--login\"]\r\n // }\r\n // }\r\n \"shell\": \"system\",\r\n // Whether to show the task line in the output of the spawned task, defaults to `true`.\r\n \"show_summary\": true,\r\n // Whether to show the command line in the output of the spawned task, defaults to `true`.\r\n \"show_command\": true,\r\n // Which edited buffers to save before running the task:\r\n // * `all` — save all edited buffers\r\n // * `current` — save currently active buffer only\r\n // * `none` — don't save any buffers\r\n \"save\": \"none\",\r\n // Represents the tags for inline runnable indicators, or spawning multiple tasks at once.\r\n // \"tags\": []\n }, */\n {\n \"label\": \"Toggle excluded files\",\n \"command\": \"node\",\n \"args\": [\n \"__TOGGLE_SCRIPT__\"\n ],\n \"use_new_terminal\": false,\n \"allow_concurrent_runs\": false,\n \"reveal\": \"never\",\n \"hide\": \"always\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"none\"\n },\n {\n \"label\": \"Zed Settings: Export JSON...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"export-dialog\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Import JSON...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"import-dialog\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Set Up / Reconfigure Sync...\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"setup\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": true,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Show Sync Status\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"status\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Push Now\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"push\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Pull Now\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"pull\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Enable Automatic Sync\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"auto-enable\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"all\"\n },\n {\n \"label\": \"Zed Settings: Disable Automatic Sync\",\n \"command\": \"node\",\n \"args\": [\n \"__SYNC_SCRIPT__\",\n \"auto-disable\"\n ],\n \"allow_concurrent_runs\": false,\n \"reveal\": \"always\",\n \"hide\": \"never\",\n \"show_summary\": false,\n \"show_command\": false,\n \"save\": \"none\"\n },\n]\n" }