From 13cf327f484c00e7e47a2c9bf87269a45836362e Mon Sep 17 00:00:00 2001 From: purp1e <1+purp1e@noreply.git.okk.cool> Date: Wed, 29 Jul 2026 17:11:18 +0800 Subject: [PATCH] Add portable Zed Gist sync configuration --- .gitignore | 3 + AI-SETUP.md | 34 ++ README.md | 102 ++++++ SYNC-MANUAL.zh-CN.md | 256 ++++++++++++++ bootstrap-from-gist.ps1 | 134 ++++++++ bootstrap-from-gist.sh | 121 +++++++ export.ps1 | 25 ++ export.sh | 22 ++ install.ps1 | 38 ++ install.sh | 35 ++ keymap.json | 81 +++++ scripts/file-exclusions.json | 59 ++++ scripts/settings-sync.json | 4 + scripts/toggle-file-scan-exclusions.mjs | 88 +++++ scripts/zed-settings-sync.mjs | 440 ++++++++++++++++++++++++ settings.json | 138 ++++++++ tasks.json | 169 +++++++++ 17 files changed, 1749 insertions(+) create mode 100644 .gitignore create mode 100644 AI-SETUP.md create mode 100644 README.md create mode 100644 SYNC-MANUAL.zh-CN.md create mode 100644 bootstrap-from-gist.ps1 create mode 100644 bootstrap-from-gist.sh create mode 100644 export.ps1 create mode 100644 export.sh create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 keymap.json create mode 100644 scripts/file-exclusions.json create mode 100644 scripts/settings-sync.json create mode 100644 scripts/toggle-file-scan-exclusions.mjs create mode 100644 scripts/zed-settings-sync.mjs create mode 100644 settings.json create mode 100644 tasks.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6828948 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +backups/ +*.tmp +*.log diff --git a/AI-SETUP.md b/AI-SETUP.md new file mode 100644 index 0000000..71c97ba --- /dev/null +++ b/AI-SETUP.md @@ -0,0 +1,34 @@ +# Zed AI setup + +## Codex in the Agent Panel + +The live configuration now has `disable_ai: false` and already registers `codex-acp`. Restart or reload Zed, open the ACP Registry, install or confirm Codex, then create a Codex thread from the Agent Panel. Codex owns its authentication and billing; use its native login flow for ChatGPT or API-based authentication supported by the installed version. + +This does not require a VS Code extension. Zed hosts Codex through ACP. + +## Self-hosted or compatible LLM API + +Use an OpenAI-compatible provider for an endpoint implementing OpenAI chat-completions or Responses semantics: + +```jsonc +"language_models": { + "openai_compatible": { + "my-provider": { + "api_url": "https://llm.example.com/v1", + "available_models": [ + { + "name": "my-model", + "display_name": "My Model", + "max_tokens": 128000 + } + ] + } + } +} +``` + +Enter the key through **Agent Settings > LLM Providers**, or set `MY_PROVIDER_API_KEY`. Do not put the key in `settings.json`. + +Anthropic-compatible endpoints can be configured under `language_models.anthropic_compatible`. First-class providers include Anthropic, OpenAI, Google AI, Mistral, DeepSeek, xAI, and OpenCode. Zed also supports OpenRouter, Vercel AI Gateway, Bedrock, Ollama, LM Studio, llama.cpp, and local OpenAI-compatible servers. + +Using your own key or local endpoint does not require paying for Zed-hosted models. The API provider may still charge for usage. Zed-hosted models use Zed's plan and usage billing. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3b9bf1 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# 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 +``` + +macOS/Linux: + +```sh +curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh +``` + +脚本会处理 GitHub 登录、询问 Gist URL/ID、从 Gist 下载单个 `zed-settings-sync.json`、备份当前 Zed 配置并完成安装。安装后重启 Zed。 + +For first-machine setup, new-machine onboarding, daily Gist synchronization, and conflict handling, see [`SYNC-MANUAL.zh-CN.md`](./SYNC-MANUAL.zh-CN.md). + +## Files + +- `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 + +Open the Task Picker and search for `Zed Settings`: + +- `Export JSON...` and `Import JSON...` show a native file picker. +- `Configure GitHub Gist...` accepts a 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` and `Pull from GitHub Gist` use the remembered Gist and ask before overwriting divergent settings. +- `Authenticate GitHub...` runs `gh auth login` in the terminal. + +GitHub CLI (`gh`) must be installed. The Gist must already exist and may contain other files; synchronization only creates or replaces `zed-settings-sync.json` inside it. + +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. + +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`: 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` 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: + +```powershell +.\install.ps1 +``` + +Export later changes from Zed back into this directory: + +```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 +``` + +## 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. diff --git a/SYNC-MANUAL.zh-CN.md b/SYNC-MANUAL.zh-CN.md new file mode 100644 index 0000000..953c335 --- /dev/null +++ b/SYNC-MANUAL.zh-CN.md @@ -0,0 +1,256 @@ +# Zed 设置同步使用手册 + +本方案使用 GitHub Gist 中的单个 `zed-settings-sync.json` 同步 Zed 设置。JSON 顶层直接保存各配置文件的路径和完整内容;SHA-256 只在运行时计算,不写入同步文件。 + +## 1. 同步内容 + +会同步: + +- `settings.json`:界面、字体、主题、编辑器行为及扩展自动安装清单。 +- `keymap.json`:自定义键位。 +- `tasks.json`:文件显隐和设置同步任务。 +- `scripts/` 中的文件显隐及同步脚本。 +- 非空的 `snippets/` 和 `themes/` 目录。 + +不会同步: + +- GitHub 登录凭据、API Key 和系统环境变量。 +- 本机保存的 Gist ID 和上次同步哈希。 +- Zed 数据库、缓存、聊天记录、项目文件及扩展二进制。 + +扩展 ID 位于 `settings.json` 的 `auto_install_extensions` 中。导入后由 Zed 在目标平台重新安装扩展。 + +## 2. 准备 Gist + +1. 登录 GitHub,打开 。 +2. 创建一个 Secret Gist。GitHub 不允许创建完全空白的 Gist,可以先放一个 `README.md`。 +3. 复制完整 Gist URL 或末尾的 Gist ID。 + +Gist ID 不是访问令牌。身份认证由 GitHub CLI `gh` 管理。 + +Secret Gist 只是不出现在公开搜索中,获得链接的人仍然可以读取其内容。同步 JSON 不包含 API Key,但仍应妥善保管 Gist URL。 + +## 3. 主机器首次上传 + +### 3.1 安装和认证 GitHub CLI + +当前 Windows 机器已经安装 `gh`。其他机器可以使用系统包管理器安装: + +```powershell +winget install GitHub.cli +``` + +macOS: + +```sh +brew install gh +``` + +安装后,在 Zed 中按 `Mod+R` 打开 Task Picker,运行: + +```text +Zed Settings: Authenticate GitHub... +``` + +也可以在终端执行: + +```sh +gh auth login +``` + +### 3.2 保存 Gist + +1. 按 `Mod+R`。 +2. 运行 `Zed Settings: Configure GitHub Gist...`。 +3. 粘贴 Gist URL 或 Gist ID。 +4. 运行 `Zed Settings: Push to GitHub Gist`。 +5. 运行 `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 一行安装 + +Windows PowerShell: + +```powershell +irm https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.ps1 | iex +``` + +macOS/Linux: + +```sh +curl -fsSL https://git.okk.cool/purp1e/zed-sync/raw/branch/master/bootstrap-from-gist.sh | sh +``` + +迁移到其他 Gitea 仓库时,Raw URL 的常见形式是: + +```powershell +irm https:///<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.ps1 | iex +``` + +```sh +curl -fsSL https:///<用户>/<仓库>/raw/branch/main/bootstrap-from-gist.sh | sh +``` + +执行过程中脚本会: + +1. 检查 Node.js 和 `gh`。 +2. 在尚未登录时运行 `gh auth login`。 +3. 询问 Gist URL 或 ID。 +4. 从 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`。 + +为了降低执行远程脚本的供应链风险,可以把 URL 中的 `main` 换成审核过的 commit SHA。bootstrap 仓库不应加入访问令牌或其他秘密。 + +### 4.3 便携目录备用安装 + +不能访问 Raw URL 时,仍可复制完整 `zed-config-portable` 目录并运行: + +```powershell +.\install.ps1 +``` + +```sh +sh ./install.sh +``` + +然后在 Task Picker 中认证 GitHub、配置 Gist 并执行首次 Pull。 + +## 5. 之后的日常使用 + +开始在一台机器工作前: + +1. 按 `Mod+R`。 +2. 运行 `Zed Settings: Show GitHub Gist Status`。 +3. 如果显示 `Remote changes available`,执行 Pull。 + +修改设置、键位、Tasks、排除规则或 snippets 后: + +1. 执行 Push。 +2. 确认状态变为 `Synchronized`。 +3. 再到其他机器执行 Pull。 + +常用快捷键: + +| 快捷键 | 操作 | +|---|---| +| `Mod+R` | 打开 Task Picker | +| `Mod+; U` | Push 到 Gist | +| `Mod+; D` | 从 Gist Pull | +| `Mod+; K` | Reload Zed | + +`Mod` 在 Windows/Linux 是 Ctrl,在 macOS 是 Command。 + +## 6. 哈希和冲突处理 + +系统比较三个 SHA-256: + +- 当前机器实时生成的单 JSON 哈希。 +- Gist 中单 JSON 的哈希。 +- 当前机器上次成功 Push/Pull 时保存的哈希。 + +Status 可能显示: + +| 状态 | 含义 | 建议操作 | +|---|---|---| +| `Synchronized` | 本地和 Gist 一致 | 无需操作 | +| `Local changes pending upload` | 只有本机有变化 | Push | +| `Remote changes available` | 只有 Gist 有变化 | Pull | +| `Conflict` | 本机和 Gist 都有变化 | 先导出本地备份,再决定 Push 或 Pull | +| `Not synchronized on this machine` | 这台机器尚无同步基线 | 首次接入通常选择 Pull | + +Push 覆盖远端前、Pull 覆盖本地前都会显示哈希并要求确认。Pull 会自动备份本地配置;系统不会尝试逐字段自动合并。 + +## 7. 离线 JSON 导入导出 + +不使用 Gist 时,也可以使用完全相同的单 JSON 格式: + +```text +Zed Settings: Export JSON... +Zed Settings: Import JSON... +``` + +两个命令都会弹出系统文件选择窗口。直接 Import 不会修改已保存的 Gist ID 和上次同步哈希;导入内容会被视为新的本地变化,可以随后 Push 到 Gist。 + +## 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 前的备份位于: + +```text +Windows: %APPDATA%\Zed\backups\sync-import-<时间> +macOS/Linux: ~/.config/zed/backups/sync-import-<时间> +``` + +便携安装器产生的备份使用 `portable-install-<时间>` 名称。 + +## 10. 常见问题 + +### Task Picker 中没有 Zed Settings + +重新运行一行 bootstrap 命令,或运行便携目录中的 `install.ps1` / `install.sh`,然后 Reload Zed。 + +### 提示找不到 gh + +安装 GitHub CLI 后完全退出并重启 Zed,使编辑器读取更新后的 PATH。 + +### Gist 返回 404 + +检查 Gist URL/ID、当前 GitHub 账号以及 Gist 是否仍然存在。重新运行 `Configure GitHub Gist...` 可以更换记录。 + +### Gist 中没有 zed-settings-sync.json + +先在已经配置好的主机器上执行一次 Push。 + +### Pull 后键位或界面没有立即变化 + +执行 `Mod+; K` Reload Zed;如果扩展仍在安装,等待完成后再重启一次。 + +### 换了另一个 Gist + +运行 `Configure GitHub Gist...` 并输入新 ID。系统会保留新 ID,同时清空旧 Gist 对应的上次同步哈希,下一次 Push/Pull 会重新确认覆盖方向。 diff --git a/bootstrap-from-gist.ps1 b/bootstrap-from-gist.ps1 new file mode 100644 index 0000000..7859fda --- /dev/null +++ b/bootstrap-from-gist.ps1 @@ -0,0 +1,134 @@ +[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" +Require-Command "gh" "winget install GitHub.cli" + +& 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 full URL" } +$gistId = Get-GistId $entered +if ($env:ZED_SETTINGS_BUNDLE_PATH) { + $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" + } + $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 new file mode 100644 index 0000000..d96953c --- /dev/null +++ b/bootstrap-from-gist.sh @@ -0,0 +1,121 @@ +#!/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 +} +command -v gh >/dev/null 2>&1 || { + printf '%s\n' 'GitHub CLI (gh) is required: https://cli.github.com/' >&2 + exit 1 +} + +if ! gh auth status >/dev/null 2>&1; then + printf '%s\n' 'GitHub authentication is required.' + gh auth login /dev/tty +fi + +if [ -n "${ZED_SETTINGS_GIST_ID:-}" ]; then + entered=$ZED_SETTINGS_GIST_ID +else + printf 'GitHub Gist ID or full URL: ' >/dev/tty + IFS= read -r entered &2 + exit 1 +fi + +temporary_dir=$(mktemp -d) +trap 'rm -rf "$temporary_dir"' EXIT INT TERM +response="$temporary_dir/gist.json" +if [ -z "${ZED_SETTINGS_BUNDLE_PATH:-}" ]; then + gh api "gists/$gist_id" > "$response" +fi + +node - "$response" "$gist_id" "${ZED_SETTINGS_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/export.ps1 b/export.ps1 new file mode 100644 index 0000000..fcece97 --- /dev/null +++ b/export.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$source = Join-Path $env:APPDATA "Zed" +$target = $PSScriptRoot +$utf8 = New-Object System.Text.UTF8Encoding($false) + +Copy-Item -LiteralPath (Join-Path $source "settings.json") -Destination (Join-Path $target "settings.json") -Force +Copy-Item -LiteralPath (Join-Path $source "keymap.json") -Destination (Join-Path $target "keymap.json") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\toggle-file-scan-exclusions.mjs") -Destination (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\file-exclusions.json") -Destination (Join-Path $target "scripts\file-exclusions.json") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\zed-settings-sync.mjs") -Destination (Join-Path $target "scripts\zed-settings-sync.mjs") -Force + +$tasks = [IO.File]::ReadAllText((Join-Path $source "tasks.json")) +foreach ($script in @( + @{ Path = (Join-Path $source "scripts\toggle-file-scan-exclusions.mjs"); Placeholder = "__TOGGLE_SCRIPT__" }, + @{ Path = (Join-Path $source "scripts\zed-settings-sync.mjs"); Placeholder = "__SYNC_SCRIPT__" } +)) { + $tasks = $tasks.Replace($script.Path.Replace("\", "\\"), $script.Placeholder) + $tasks = $tasks.Replace($script.Path.Replace("\", "/"), $script.Placeholder) +} +[IO.File]::WriteAllText((Join-Path $target "tasks.json"), $tasks, $utf8) + +Write-Host "Exported Zed configuration from $source" diff --git a/export.sh b/export.sh new file mode 100644 index 0000000..9be6e94 --- /dev/null +++ b/export.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +target=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source="${XDG_CONFIG_HOME:-$HOME/.config}/zed" + +cp "$source/settings.json" "$target/settings.json" +cp "$source/keymap.json" "$target/keymap.json" +cp "$source/scripts/toggle-file-scan-exclusions.mjs" "$target/scripts/toggle-file-scan-exclusions.mjs" +cp "$source/scripts/file-exclusions.json" "$target/scripts/file-exclusions.json" +cp "$source/scripts/zed-settings-sync.mjs" "$target/scripts/zed-settings-sync.mjs" + +node - "$source/tasks.json" "$target/tasks.json" "$source/scripts/toggle-file-scan-exclusions.mjs" "$source/scripts/zed-settings-sync.mjs" <<'NODE' +const fs = require("node:fs"); +const [source, target, toggleScript, syncScript] = process.argv.slice(2); +const tasks = fs.readFileSync(source, "utf8") + .replaceAll(toggleScript, "__TOGGLE_SCRIPT__") + .replaceAll(syncScript, "__SYNC_SCRIPT__"); +fs.writeFileSync(target, tasks); +NODE + +printf 'Exported Zed configuration from %s\n' "$source" diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..5507515 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,38 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = "Stop" +$source = $PSScriptRoot +$target = Join-Path $env:APPDATA "Zed" +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$backup = Join-Path $target "backups\portable-install-$timestamp" +$utf8 = New-Object System.Text.UTF8Encoding($false) + +New-Item -ItemType Directory -Force -Path $target, $backup, (Join-Path $target "scripts") | Out-Null + +foreach ($name in @("settings.json", "keymap.json", "tasks.json")) { + $existing = Join-Path $target $name + if (Test-Path -LiteralPath $existing) { + Copy-Item -LiteralPath $existing -Destination (Join-Path $backup $name) + } +} + +Copy-Item -LiteralPath (Join-Path $source "settings.json") -Destination (Join-Path $target "settings.json") -Force +Copy-Item -LiteralPath (Join-Path $source "keymap.json") -Destination (Join-Path $target "keymap.json") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\toggle-file-scan-exclusions.mjs") -Destination (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\file-exclusions.json") -Destination (Join-Path $target "scripts\file-exclusions.json") -Force +Copy-Item -LiteralPath (Join-Path $source "scripts\zed-settings-sync.mjs") -Destination (Join-Path $target "scripts\zed-settings-sync.mjs") -Force +$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 +} + +$toggleScriptPath = (Join-Path $target "scripts\toggle-file-scan-exclusions.mjs").Replace("\", "/") +$syncScriptPath = (Join-Path $target "scripts\zed-settings-sync.mjs").Replace("\", "/") +$tasks = [IO.File]::ReadAllText((Join-Path $source "tasks.json")) +$tasks = $tasks.Replace("__TOGGLE_SCRIPT__", $toggleScriptPath) +$tasks = $tasks.Replace("__SYNC_SCRIPT__", $syncScriptPath) +[IO.File]::WriteAllText((Join-Path $target "tasks.json"), $tasks, $utf8) + +Write-Host "Installed Zed configuration to $target" +Write-Host "Previous configuration backed up to $backup" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..de0a0d5 --- /dev/null +++ b/install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +target="${XDG_CONFIG_HOME:-$HOME/.config}/zed" +timestamp=$(date +%Y%m%d-%H%M%S) +backup="$target/backups/portable-install-$timestamp" + +mkdir -p "$target/scripts" "$backup" +for name in settings.json keymap.json tasks.json; do + if [ -f "$target/$name" ]; then + cp "$target/$name" "$backup/$name" + fi +done + +cp "$source_dir/settings.json" "$target/settings.json" +cp "$source_dir/keymap.json" "$target/keymap.json" +cp "$source_dir/scripts/toggle-file-scan-exclusions.mjs" "$target/scripts/toggle-file-scan-exclusions.mjs" +cp "$source_dir/scripts/file-exclusions.json" "$target/scripts/file-exclusions.json" +cp "$source_dir/scripts/zed-settings-sync.mjs" "$target/scripts/zed-settings-sync.mjs" +if [ ! -f "$target/scripts/settings-sync.json" ]; then + cp "$source_dir/scripts/settings-sync.json" "$target/scripts/settings-sync.json" +fi + +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); +const tasks = fs.readFileSync(source, "utf8") + .replaceAll("__TOGGLE_SCRIPT__", toggleScript) + .replaceAll("__SYNC_SCRIPT__", syncScript); +fs.writeFileSync(target, tasks); +NODE + +printf 'Installed Zed configuration to %s\n' "$target" +printf 'Previous configuration backed up to %s\n' "$backup" diff --git a/keymap.json b/keymap.json new file mode 100644 index 0000000..f922309 --- /dev/null +++ b/keymap.json @@ -0,0 +1,81 @@ +[ + { + "bindings": { + "f12": ["zed::IncreaseUiFontSize", { "persist": false }], + "secondary-f12": ["zed::DecreaseUiFontSize", { "persist": false }], + "secondary-1": "project_panel::ToggleFocus", + "secondary-2": "terminal_panel::ToggleFocus", + "secondary-3": "workspace::ToggleRightDock", + "secondary-shift-g": "git_panel::ToggleFocus", + "secondary-shift-v": "zed::Extensions", + "secondary-p": "command_palette::Toggle", + "secondary-o": "file_finder::Toggle", + "secondary-9": "pane::ActivatePreviousItem", + "secondary-0": "pane::ActivateNextItem", + "alt-1": ["workspace::ActivatePane", 0], + "alt-2": ["workspace::ActivatePane", 1], + "alt-3": ["workspace::ActivatePane", 2], + "alt-4": ["workspace::ActivatePane", 3], + "alt-5": ["workspace::ActivatePane", 4], + "secondary-shift-o": "workspace::Open", + "secondary-enter": "workspace::ToggleZoom", + "secondary-r": "task::Spawn", + "secondary-shift-r": "projects::OpenRecent", + "shift-alt-r": null, + "secondary-; k": "workspace::Reload", + "secondary-; g": "git::FileHistory", + "secondary-; p": "git::Push", + "secondary-; secondary-p": "git::Pull", + "secondary-; n": "git::Branch", + "secondary-; s": "git::StashAll", + "secondary-; z": "git::StashPop", + "secondary-; a": "git::StageAll", + "secondary-; u": [ + "task::Spawn", + { "task_name": "Zed Settings: Push to GitHub Gist" } + ], + "secondary-; d": [ + "task::Spawn", + { "task_name": "Zed Settings: Pull from GitHub Gist" } + ], + "shift-alt--": [ + "task::Spawn", + { "task_name": "Toggle excluded files" } + ] + } + }, + { + "context": "Workspace", + "bindings": { + "secondary-; c": "git_panel::ToggleFocus" + } + }, + { + "context": "GitPanel", + "bindings": { + "secondary-; c": "git_panel::FocusEditor" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "secondary-shift-[": "editor::Fold", + "secondary-shift-]": "editor::UnfoldLines", + "secondary--": "editor::FoldAll", + "secondary-=": "editor::UnfoldAll" + } + }, + { + "context": "Editor && extension == md", + "bindings": { + "alt-p": "markdown::OpenPreview", + "shift-alt-p": "markdown::OpenPreviewToTheSide" + } + }, + { + "context": "ProjectPanel", + "bindings": { + "alt--": "project_panel::CollapseAllEntries" + } + } +] diff --git a/scripts/file-exclusions.json b/scripts/file-exclusions.json new file mode 100644 index 0000000..8d41c5c --- /dev/null +++ b/scripts/file-exclusions.json @@ -0,0 +1,59 @@ +{ + "defaults": [ + "**/.git", + "**/.svn", + "**/.hg", + "**/.jj", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db", + "**/.classpath", + "**/.settings" + ], + "custom": [ + "**/.playwright-cli", + "**/output", + "**/.cursor", + "**/.vscode", + "**/.idea", + "**/.fleet", + "**/node_modules", + "**/bun.lockb", + "**/pnpm-lock.yaml", + "**/package-lock.json", + "**/yarn.lock", + "**/dist", + "**/out", + "**/build", + "**/.next", + "**/.nuxt", + "**/.output", + "**/.cache", + "**/.turbo", + "**/.vercel", + "**/.netlify", + "**/tsconfig.tsbuildinfo", + "**/auto-imports.d.ts", + "**/components.json", + "**/unplugin-vue-components.d.ts", + "src/*.d.ts", + "**/__pycache__", + "**/__init__.py", + "**/.ipynb_checkpoints", + "**/.pytest_cache", + "**/.mypy_cache", + "**/.venv", + "**/env", + "**/venv", + "**/.github", + "**/.husky", + "**/.npmrc", + "**/.eslintrc*", + "**/.prettier*", + "**/.editorconfig", + "**/.dockerignore", + "**/docker-compose.override.yml", + "**/cypress*", + "**/prisma/migrations" + ] +} diff --git a/scripts/settings-sync.json b/scripts/settings-sync.json new file mode 100644 index 0000000..75925cb --- /dev/null +++ b/scripts/settings-sync.json @@ -0,0 +1,4 @@ +{ + "gist_id": "", + "last_synced_hash": "" +} diff --git a/scripts/toggle-file-scan-exclusions.mjs b/scripts/toggle-file-scan-exclusions.mjs new file mode 100644 index 0000000..703b2d6 --- /dev/null +++ b/scripts/toggle-file-scan-exclusions.mjs @@ -0,0 +1,88 @@ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); + +async function exclusionRules() { + const rulesPath = join(scriptDirectory, "file-exclusions.json"); + const rules = JSON.parse(await readFile(rulesPath, "utf8")); + if (!Array.isArray(rules.defaults) || !Array.isArray(rules.custom)) { + throw new Error("file-exclusions.json must contain defaults and custom arrays"); + } + return { + defaults: [...new Set(rules.defaults)], + custom: [...new Set(rules.custom)].filter( + (entry) => !rules.defaults.includes(entry), + ), + }; +} + +function settingsPath() { + if (process.platform === "win32") { + const appData = process.env.APPDATA; + if (!appData) throw new Error("APPDATA is not set"); + return join(appData, "Zed", "settings.json"); + } + return join(homedir(), ".config", "zed", "settings.json"); +} + +function findArrayRange(source) { + const property = /"file_scan_exclusions"\s*:/g.exec(source); + if (!property) throw new Error("file_scan_exclusions is missing from settings.json"); + + const start = source.indexOf("[", property.index + property[0].length); + if (start === -1) throw new Error("file_scan_exclusions is not an array"); + + let inString = false; + let escaped = false; + let depth = 0; + for (let index = start; index < source.length; index += 1) { + const character = source[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') inString = true; + else if (character === "[") depth += 1; + else if (character === "]" && --depth === 0) return { start, end: index + 1 }; + } + throw new Error("file_scan_exclusions array is not closed"); +} + +function formatArray(values, indent, newline) { + const itemIndent = `${indent} `; + const items = values.map((value) => `${itemIndent}${JSON.stringify(value)}`); + return `[${newline}${items.join(`,${newline}`)}${newline}${indent}]`; +} + +async function replaceAtomically(path, contents) { + const temporaryPath = join(dirname(path), `.settings.json.${process.pid}.tmp`); + try { + await writeFile(temporaryPath, contents, "utf8"); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +const path = settingsPath(); +const { defaults, custom } = await exclusionRules(); +const source = await readFile(path, "utf8"); +const newline = source.includes("\r\n") ? "\r\n" : "\n"; +const range = findArrayRange(source); +const current = JSON.parse(source.slice(range.start, range.end)); +const customIsActive = current.some((entry) => !defaults.includes(entry)); +const next = customIsActive ? defaults : [...defaults, ...custom]; +const lineStart = source.lastIndexOf("\n", range.start) + 1; +const indent = source.slice(lineStart, range.start).match(/^\s*/)?.[0] ?? ""; +const updated = + source.slice(0, range.start) + + formatArray(next, indent, newline) + + source.slice(range.end); + +await replaceAtomically(path, updated); +console.log(`Excluded files are now ${customIsActive ? "shown" : "hidden"}.`); diff --git a/scripts/zed-settings-sync.mjs b/scripts/zed-settings-sync.mjs new file mode 100644 index 0000000..42e6e7a --- /dev/null +++ b/scripts/zed-settings-sync.mjs @@ -0,0 +1,440 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, readdir, rename, rm, 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"; + +const BUNDLE_NAME = "zed-settings-sync.json"; +const REQUIRED_FILES = ["settings.json", "keymap.json", "tasks.json"]; +const FIXED_FILES = [ + ...REQUIRED_FILES, + "debug.json", + "scripts/toggle-file-scan-exclusions.mjs", + "scripts/file-exclusions.json", + "scripts/zed-settings-sync.mjs", +]; +const OPTIONAL_DIRECTORIES = ["snippets", "themes"]; +const LOCAL_SYNC_CONFIG = "scripts/settings-sync.json"; + +function zedConfigDirectory() { + if (process.platform === "win32") { + if (!process.env.APPDATA) throw new Error("APPDATA is not set"); + return join(process.env.APPDATA, "Zed"); + } + return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "zed"); +} + +function timestamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +async function writeAtomically(path, contents) { + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + try { + await writeFile(temporaryPath, contents, "utf8"); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +async function listFilesRecursively(directory, prefix) { + if (!existsSync(directory)) return []; + const results = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const absolutePath = join(directory, entry.name); + const relativePath = `${prefix}/${entry.name}`; + if (entry.isDirectory()) results.push(...await listFilesRecursively(absolutePath, relativePath)); + else if (entry.isFile()) results.push(relativePath); + } + return results; +} + +async function synchronizedPaths(configDirectory) { + const paths = FIXED_FILES.filter((path) => existsSync(join(configDirectory, ...path.split("/")))); + for (const directory of OPTIONAL_DIRECTORIES) { + paths.push(...await listFilesRecursively(join(configDirectory, directory), directory)); + } + return [...new Set(paths)].sort(); +} + +function isAllowedBundlePath(path) { + if (typeof path !== "string" || path.startsWith("/") || path.includes("\\")) return false; + const segments = path.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) return false; + if (FIXED_FILES.includes(path) || path === LOCAL_SYNC_CONFIG) return true; + return OPTIONAL_DIRECTORIES.some((directory) => path.startsWith(`${directory}/`)); +} + +function replaceTaskPath(source, path, placeholder) { + return source + .replaceAll(path.replaceAll("\\", "\\\\"), placeholder) + .replaceAll(path.replaceAll("\\", "/"), placeholder); +} + +function portableTasks(source, configDirectory) { + return replaceTaskPath( + replaceTaskPath( + source, + join(configDirectory, "scripts", "toggle-file-scan-exclusions.mjs"), + "__TOGGLE_SCRIPT__", + ), + join(configDirectory, "scripts", "zed-settings-sync.mjs"), + "__SYNC_SCRIPT__", + ); +} + +function installedTasks(source, configDirectory) { + return source + .replaceAll( + "__TOGGLE_SCRIPT__", + join(configDirectory, "scripts", "toggle-file-scan-exclusions.mjs").replaceAll("\\", "/"), + ) + .replaceAll( + "__SYNC_SCRIPT__", + join(configDirectory, "scripts", "zed-settings-sync.mjs").replaceAll("\\", "/"), + ); +} + +function hashContents(contents) { + return createHash("sha256").update(contents).digest("hex"); +} + +async function createBundle() { + const configDirectory = zedConfigDirectory(); + const files = {}; + for (const relativePath of await synchronizedPaths(configDirectory)) { + const absolutePath = join(configDirectory, ...relativePath.split("/")); + let contents = await readFile(absolutePath, "utf8"); + if (relativePath === "tasks.json") contents = portableTasks(contents, configDirectory); + files[relativePath] = contents; + } + for (const required of REQUIRED_FILES) { + if (!(required in files)) throw new Error(`Cannot export: ${required} is missing`); + } + const contents = serializeBundle(files); + return { files, contents, hash: hashContents(contents) }; +} + +function serializeBundle(files) { + return `${JSON.stringify(files, null, 2)}\n`; +} + +function parseBundle(contents, sourceName) { + let files; + try { + files = JSON.parse(contents); + } catch (error) { + throw new Error(`Invalid JSON in ${sourceName}: ${error.message}`); + } + if (!files || Array.isArray(files) || typeof files !== "object") { + 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`); + } + } + for (const [path, value] of Object.entries(files)) { + if (!isAllowedBundlePath(path) || typeof value !== "string") { + throw new Error(`Invalid bundle file: ${path}`); + } + } + return { files, hash: hashContents(contents) }; +} + +async function exportBundle(path) { + const bundle = await createBundle(); + await writeAtomically(path, bundle.contents); + console.log(`Exported Zed settings to ${path}`); + console.log(`SHA-256: ${bundle.hash}`); + return bundle; +} + +async function backupCurrentConfiguration(configDirectory) { + const backupDirectory = join(configDirectory, "backups", `sync-import-${timestamp()}`); + await mkdir(backupDirectory, { recursive: true }); + for (const relativePath of await synchronizedPaths(configDirectory)) { + const source = join(configDirectory, ...relativePath.split("/")); + const destination = join(backupDirectory, ...relativePath.split("/")); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } + return backupDirectory; +} + +async function installBundleContents(contents, sourceName) { + const parsed = parseBundle(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" + ? installedTasks(sourceContents, configDirectory) + : sourceContents; + await writeAtomically(destination, installedContents); + } + console.log(`Imported Zed settings from ${sourceName}`); + console.log(`Previous settings were backed up to ${backupDirectory}`); + return { ...parsed, backupDirectory }; +} + +async function importBundle(path) { + return installBundleContents(await readFile(path, "utf8"), path); +} + +function runPowerShell(command, extraEnv = {}) { + const result = spawnSync("powershell.exe", ["-NoProfile", "-STA", "-Command", command], { + encoding: "utf8", + env: { ...process.env, ...extraEnv }, + windowsHide: true, + }); + if (result.status !== 0) throw new Error(result.stderr.trim() || "Windows dialog failed"); + return result.stdout.trim(); +} + +function nativeFileDialog(mode) { + if (process.platform === "win32") { + const common = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; "; + const command = mode === "save" + ? `${common}$d=New-Object System.Windows.Forms.SaveFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; $d.AddExtension=$true; if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}` + : `${common}$d=New-Object System.Windows.Forms.OpenFileDialog; $d.Filter='Zed settings (*.json)|*.json'; $d.FileName='${BUNDLE_NAME}'; if($d.ShowDialog() -eq 'OK'){[Console]::Write($d.FileName)}`; + return runPowerShell(command); + } + if (process.platform === "darwin") { + const script = mode === "save" + ? `POSIX path of (choose file name with prompt "Export Zed settings" default name "${BUNDLE_NAME}")` + : "POSIX path of (choose file with prompt \"Import Zed settings\")"; + const result = spawnSync("osascript", ["-e", script], { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : ""; + } + const args = ["--file-selection", `--title=${mode === "save" ? "Export" : "Import"} Zed settings`]; + if (mode === "save") args.push("--save", "--confirm-overwrite", `--filename=${BUNDLE_NAME}`); + const result = spawnSync("zenity", args, { encoding: "utf8" }); + 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 Microsoft.VisualBasic; [Console]::Write([Microsoft.VisualBasic.Interaction]::InputBox($env:ZED_SYNC_PROMPT,$env:ZED_SYNC_TITLE,$env:ZED_SYNC_DEFAULT))"; + 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; [void][System.Windows.Forms.MessageBox]::Show($env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'OK',$env:ZED_SYNC_ICON)"; + 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; [Console]::Write([System.Windows.Forms.MessageBox]::Show($env:ZED_SYNC_MESSAGE,$env:ZED_SYNC_TITLE,'YesNo','Warning'))"; + 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 = {}; + try { + source = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + return { + path, + config: { + gist_id: typeof source.gist_id === "string" ? source.gist_id : "", + last_synced_hash: typeof source.last_synced_hash === "string" ? source.last_synced_hash : "", + }, + }; +} + +async function saveSyncConfiguration(path, config) { + await writeAtomically(path, `${JSON.stringify(config, null, 2)}\n`); +} + +function normalizeGistId(value) { + const matches = value.trim().match(/[a-f0-9]{8,}/gi); + if (!matches?.length) throw new Error("Enter a valid GitHub Gist ID or URL"); + return matches.at(-1).toLowerCase(); +} + +async function promptForGist() { + const state = await readSyncConfiguration(); + 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) { + const result = spawnSync("gh", args, { encoding: "utf8", input, windowsHide: true }); + if (result.error?.code === "ENOENT") { + throw new Error("GitHub CLI (gh) is not installed. Install it, then run: gh auth login"); + } + if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args[0]} failed`); + return result.stdout.trim(); +} + +function readRemoteGist(gistId) { + const gist = JSON.parse(runGh(["api", `gists/${gistId}`])); + 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) { + const payload = JSON.stringify({ files: { [BUNDLE_NAME]: { content: contents } } }); + return JSON.parse(runGh(["api", "--method", "PATCH", `gists/${gistId}`, "--input", "-"], payload)); +} + +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).`); +} + +async function pushGist() { + 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; + 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; + } + } + 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"}`); +} + +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; + } + 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"}`); +} + +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 main() { + const [mode, explicitPath] = process.argv.slice(2); + if (mode === "export") return exportBundle(resolve(explicitPath)); + if (mode === "import") return importBundle(resolve(explicitPath)); + if (mode === "export-dialog") { + const path = nativeFileDialog("save"); + if (path) await exportBundle(path); + return; + } + if (mode === "import-dialog") { + const path = nativeFileDialog("open"); + 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(); + throw new Error(`Unknown mode: ${mode || ""}`); +} + +try { + await main(); +} catch (error) { + console.error(error.stack || error.message); + try { + nativeMessage(error.message, "Zed Settings Sync Error", true); + } catch { + // The terminal output remains available when the native dialog backend is unavailable. + } + process.exitCode = 1; +} diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..cfa5986 --- /dev/null +++ b/settings.json @@ -0,0 +1,138 @@ +// Zed settings +// +// For information on how to configure Zed, see the Zed +// documentation: https://zed.dev/docs/configuring-zed +// +// To see all of Zed's default settings without changing your +// custom settings, run `zed: open default settings` from the +// command palette (cmd-shift-p / ctrl-shift-p) +{ + "cli_default_open_behavior": "existing_window", + "icon_theme": "Material Icon Theme", + "auto_install_extensions": { + "dockerfile": true, + "env": true, + "html": true, + "material-icon-theme": true, + "mcp-server-context7": true, + "mcp-server-github": true, + "one-dark-pro": true, + "powershell": true, + "toml": true + }, + "disable_ai": false, + "proxy": "", + "agent": { + "sidebar_side": "right", + "favorite_models": [], + "model_parameters": [] + }, + "project_panel": { + "dock": "left" + }, + "telemetry": { + "diagnostics": false, + "metrics": false, + "anthropic_retention": false + }, + "terminal": { + "font_size": 16.0, + "cursor_shape": "bar" + }, + "base_keymap": "VSCode", + "file_scan_exclusions": [ + "**/.git", + "**/.svn", + "**/.hg", + "**/.jj", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db", + "**/.classpath", + "**/.settings", + "**/.playwright-cli", + "**/output", + "**/.cursor", + "**/.vscode", + "**/.idea", + "**/.fleet", + "**/node_modules", + "**/bun.lockb", + "**/pnpm-lock.yaml", + "**/package-lock.json", + "**/yarn.lock", + "**/dist", + "**/out", + "**/build", + "**/.next", + "**/.nuxt", + "**/.output", + "**/.cache", + "**/.turbo", + "**/.vercel", + "**/.netlify", + "**/tsconfig.tsbuildinfo", + "**/auto-imports.d.ts", + "**/components.json", + "**/unplugin-vue-components.d.ts", + "src/*.d.ts", + "**/__pycache__", + "**/__init__.py", + "**/.ipynb_checkpoints", + "**/.pytest_cache", + "**/.mypy_cache", + "**/.venv", + "**/env", + "**/venv", + "**/.github", + "**/.husky", + "**/.npmrc", + "**/.eslintrc*", + "**/.prettier*", + "**/.editorconfig", + "**/.dockerignore", + "**/docker-compose.override.yml", + "**/cypress*", + "**/prisma/migrations" + ], + "preview_tabs": { + "enabled": false + }, + "git_panel": { + "fallback_branch_name": "master" + }, + "sticky_scroll": { + "enabled": true + }, + "minimap": { + "show": "always" + }, + "hover_popover_delay": 250, + "cursor_blink": true, + "autosave": "on_focus_change", + "buffer_font_fallbacks": [ + "Consolas", + "Sarasa Gothic SC", + "Courier New", + "monospace" + ], + "buffer_font_family": "Fira Code", + "auto_indent_on_paste": true, + "linked_edits": true, + "show_edit_predictions": true, + "line_ending": "prefer_lf", + "hard_tabs": false, + "tab_size": 2, + "agent_servers": { + "codex-acp": { + "type": "registry" + } + }, + "ui_font_size": 16, + "buffer_font_size": 16.0, + "theme": { + "mode": "system", + "light": "One Light", + "dark": "One Dark Pro", + }, +} diff --git a/tasks.json b/tasks.json new file mode 100644 index 0000000..fd8e95f --- /dev/null +++ b/tasks.json @@ -0,0 +1,169 @@ +// Project tasks configuration. See https://zed.dev/docs/tasks for documentation. +// +// Example: +[ + /* The generated example task is intentionally disabled. + { + "label": "Example task", + "command": "for i in {1..5}; do echo \"Hello $i/5\"; sleep 1; done", + //"args": [], + // Env overrides for the command, will be appended to the terminal's environment from the settings. + "env": { "foo": "bar" }, + // Current working directory to spawn the command into, defaults to current project root. + //"cwd": "/path/to/working/directory", + // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`. + "use_new_terminal": false, + // Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`. + "allow_concurrent_runs": false, + // What to do with the terminal pane and tab, after the command was started: + // * `always` — always show the task's pane, and focus the corresponding tab in it (default) + // * `no_focus` — always show the task's pane, add the task's tab in it, but don't focus it + // * `never` — do not alter focus, but still add/reuse the task's tab in its pane + "reveal": "always", + // Where to place the task's terminal item after starting the task: + // * `dock` — in the terminal dock, "regular" terminal items' place (default) + // * `center` — in the central pane group, "main" editor area + "reveal_target": "dock", + // What to do with the terminal pane and tab, after the command had finished: + // * `never` — Do nothing when the command finishes (default) + // * `always` — always hide the terminal tab, hide the pane also if it was the last tab in it + // * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always` + "hide": "never", + // Which shell to use when running a task inside the terminal. + // May take 3 values: + // 1. (default) Use the system's default terminal configuration in /etc/passwd + // "shell": "system" + // 2. A program: + // "shell": { + // "program": "sh" + // } + // 3. A program with arguments: + // "shell": { + // "with_arguments": { + // "program": "/bin/bash", + // "args": ["--login"] + // } + // } + "shell": "system", + // Whether to show the task line in the output of the spawned task, defaults to `true`. + "show_summary": true, + // Whether to show the command line in the output of the spawned task, defaults to `true`. + "show_command": true, + // Which edited buffers to save before running the task: + // * `all` — save all edited buffers + // * `current` — save currently active buffer only + // * `none` — don't save any buffers + "save": "none", + // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. + // "tags": [] + }, */ + { + "label": "Toggle excluded files", + "command": "node", + "args": [ + "__TOGGLE_SCRIPT__" + ], + "use_new_terminal": false, + "allow_concurrent_runs": false, + "reveal": "never", + "hide": "always", + "show_summary": false, + "show_command": false, + "save": "none" + }, + { + "label": "Zed Settings: Export JSON...", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "export-dialog" + ], + "allow_concurrent_runs": false, + "reveal": "never", + "hide": "always", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Import JSON...", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "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": "never", + "hide": "always", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Show GitHub Gist Status", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "gist-status" + ], + "allow_concurrent_runs": false, + "reveal": "never", + "hide": "always", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Push to GitHub Gist", + "command": "node", + "args": [ + "__SYNC_SCRIPT__", + "gist-push" + ], + "allow_concurrent_runs": false, + "reveal": "no_focus", + "hide": "on_success", + "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": "on_success", + "show_summary": false, + "show_command": false, + "save": "all" + }, + { + "label": "Zed Settings: Authenticate GitHub...", + "command": "gh", + "args": ["auth", "login"], + "allow_concurrent_runs": false, + "reveal": "always", + "hide": "never", + "show_summary": false, + "show_command": true, + "save": "none" + }, +]