diff --git a/.github/workflows/gpui-check.yml b/.github/workflows/gpui-check.yml new file mode 100644 index 00000000..dd2ae21a --- /dev/null +++ b/.github/workflows/gpui-check.yml @@ -0,0 +1,43 @@ +name: GPUI Check + +on: + workflow_call: + +jobs: + gpui: + name: GPUI + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Linux dependencies + run: | + sudo apt update + sudo apt install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libsqlite3-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libvulkan-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + src-gpui -> target + + - name: Test GPUI crate + run: cargo test --manifest-path src-gpui/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbf21c49..cd9a1af5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,37 +13,29 @@ on: jobs: smoke: - name: Linux Smoke Gate - uses: ./.github/workflows/tauri-smoke.yml + name: GPUI Gate + uses: ./.github/workflows/gpui-check.yml - publish: - name: Publish (${{ matrix.name }}) + gpui-binaries: + name: GPUI binary (${{ matrix.name }}) needs: smoke - runs-on: ${{ matrix.platform }} - permissions: - contents: write + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - # Build macOS packages on native CPU runners so app architectures stay aligned. - - name: macOS arm64 - platform: macos-15 - rustTargets: aarch64-apple-darwin - args: --target aarch64-apple-darwin - - name: macOS x64 - platform: macos-15-intel - rustTargets: x86_64-apple-darwin - args: --target x86_64-apple-darwin - - name: Linux x64 - platform: ubuntu-24.04 - rustTargets: "" - args: "" - - name: Windows x64 - platform: windows-latest - rustTargets: "" - args: --bundles nsis - + - name: linux-x64 + os: ubuntu-24.04 + target: "" + bin: 2code + - name: macos-arm64 + os: macos-15 + target: aarch64-apple-darwin + bin: 2code + - name: windows-x64 + os: windows-latest + target: "" + bin: 2code.exe steps: - name: Checkout uses: actions/checkout@v6 @@ -53,83 +45,90 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ build-essential \ - curl \ - wget \ - file \ - just \ + pkg-config \ libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Install just (macOS) - if: runner.os == 'macOS' - run: brew install just - - - name: Install just (Windows) - if: runner.os == 'Windows' - run: choco install just -y - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + libsqlite3-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libvulkan-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev - name: Setup Rust + if: matrix.target == '' + uses: dtolnay/rust-toolchain@stable + + - name: Setup Rust with target + if: matrix.target != '' uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.rustTargets }} + targets: ${{ matrix.target }} - name: Cache Rust artifacts uses: Swatinem/rust-cache@v2 with: cache-bin: false workspaces: | - src-tauri -> target - - - name: Install app dependencies - run: bun install --frozen-lockfile + src-gpui -> target - - name: Install Apple Developer ID G2 certificate - if: runner.os == 'macOS' + - name: Build GPUI app shell: bash run: | - curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o "$RUNNER_TEMP/DeveloperIDG2CA.cer" - security import "$RUNNER_TEMP/DeveloperIDG2CA.cer" -k "$HOME/Library/Keychains/login.keychain-db" - security find-identity -v -p codesigning + if [ -n "${{ matrix.target }}" ]; then + cargo build --release --manifest-path src-gpui/Cargo.toml --target "${{ matrix.target }}" + echo "GPUI_BIN=src-gpui/target/${{ matrix.target }}/release/${{ matrix.bin }}" >> "$GITHUB_ENV" + else + cargo build --release --manifest-path src-gpui/Cargo.toml + echo "GPUI_BIN=src-gpui/target/release/${{ matrix.bin }}" >> "$GITHUB_ENV" + fi - - name: Prepare Apple notarization key - if: runner.os == 'macOS' - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + - name: Upload GPUI binary + uses: actions/upload-artifact@v4 + with: + name: 2code-gpui-${{ matrix.name }} + path: ${{ env.GPUI_BIN }} + if-no-files-found: error + + publish: + name: Publish GPUI + needs: gpui-binaries + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download GPUI binaries + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Stage release assets run: | - mkdir -p "$RUNNER_TEMP/appstoreconnect/private_keys" - key_path="$RUNNER_TEMP/appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8" - printf '%s' "$APPLE_API_KEY_P8" > "$key_path" - chmod 600 "$key_path" - echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + mkdir -p release-assets + if [ -f dist/2code-gpui-linux-x64/2code ]; then + cp dist/2code-gpui-linux-x64/2code release-assets/2code-linux-x64 + fi + if [ -f dist/2code-gpui-macos-arm64/2code ]; then + cp dist/2code-gpui-macos-arm64/2code release-assets/2code-macos-arm64 + fi + if [ -f dist/2code-gpui-windows-x64/2code.exe ]; then + cp dist/2code-gpui-windows-x64/2code.exe release-assets/2code-windows-x64.exe + fi + ls -la release-assets + test -n "$(ls -A release-assets)" - - name: Build and publish - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 with: - tauriScript: bun tauri - tagName: v__VERSION__ - releaseName: 2code v__VERSION__ - releaseBody: See the assets to download this version and install. - releaseDraft: false + files: release-assets/* + generate_release_notes: true prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || contains(github.ref_name, '-') }} - generateReleaseNotes: true - includeUpdaterJson: true - retryAttempts: 2 - args: ${{ matrix.args }} + body: | + Native GPUI 2code builds. Download the binary for your platform and run it. + + - `2code-linux-x64` + - `2code-macos-arm64` + - `2code-windows-x64.exe` diff --git a/.github/workflows/tauri-smoke.yml b/.github/workflows/tauri-smoke.yml index d483e118..29106c0d 100644 --- a/.github/workflows/tauri-smoke.yml +++ b/.github/workflows/tauri-smoke.yml @@ -2,13 +2,7 @@ name: Tauri Smoke on: workflow_call: - push: - branches: - - dev - - "codex/**" - pull_request: - branches: - - dev + workflow_dispatch: jobs: smoke: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b9e99424..3022bc61 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,10 +8,12 @@ on: pull_request: branches: - dev + workflow_dispatch: jobs: frontend: - name: Frontend + name: Leftover React (manual) + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 steps: @@ -102,3 +104,7 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path src-tauri/Cargo.toml + + gpui: + name: GPUI check + uses: ./.github/workflows/gpui-check.yml diff --git a/AGENTS.md b/AGENTS.md index 8ef1757b..c42390b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,88 +1,84 @@ # AGENTS.md — 2code -**Generated:** 2026-04-09 | **Commit:** 93661da | **Branch:** dev +**Generated:** 2026-04-09 | **Branch:** cursor/gpui-rewrite-ee3c ## OVERVIEW -Tauri 2 desktop app for managing code projects with integrated PTY terminals. React 19 + TS frontend, Rust workspace backend, SQLite via Diesel. +GPUI desktop app for managing code projects with integrated PTY terminals. Product UI is `src-gpui/` calling Rust domain crates directly. Leftover Tauri 2 + React 19 in `src/` / `src-tauri/` is reference only. SQLite via Diesel. ## STRUCTURE ``` 2code/ -├── src/ # React 19 + Vite frontend -│ ├── features/ # Feature-first: debug git home profiles projects settings terminal topbar watcher -│ ├── shared/ # lib/ providers/ components/ hooks/ -│ ├── layout/ # AppSidebar + sidebar/ sub-components -│ ├── generated/ # AUTO-GENERATED Tauri IPC bindings (DO NOT EDIT, gitignored) -│ └── paraglide/ # AUTO-GENERATED i18n messages (DO NOT EDIT, gitignored) +├── src-gpui/ # GPUI product UI (bin: 2code) +│ └── src/ui/ # sidebar home workspace tree terminal git settings dialogs ├── src-tauri/ -│ ├── src/handler/ # #[tauri::command] entry points (8 files) │ ├── crates/infra/src/ # DB, PTY, git, shell init, watcher, logger, slug │ ├── crates/service/src/ # Business logic: project, profile, pty, watcher │ ├── crates/repo/src/ # Diesel CRUD: project, profile, pty │ ├── crates/model/src/ # DTOs, Diesel models, error types +│ ├── src/handler/ # Leftover Tauri #[tauri::command] entry points │ └── migrations/ # Diesel SQL migrations (embedded at compile time) -├── messages/ # i18n source: en.json zh.json -└── justfile # Build helpers: coverage, fmt +├── src/ # Leftover React 19 + Vite frontend (not shipping) +├── messages/ # i18n source: en.json zh.json (GPUI loads these) +├── docs/ui-inventory.md # Visual/behavior rewrite spec +└── justfile # start / gpui / verify / leftover-tauri ``` ## WHERE TO LOOK | Task | Location | |------|----------| -| Add Tauri command | `src-tauri/src/handler/*.rs` → register in `lib.rs` → run `cargo tauri-typegen generate` | -| Consume IPC in frontend | Import from `@/generated` → wrap in TanStack Query hook | -| Query keys | `src/shared/lib/queryKeys.ts` — always use this, never inline strings | -| Terminal tabs/state | `src/features/terminal/store.ts` (Zustand + Immer) | +| Product window / actions | `src-gpui/src/app.rs` + `src-gpui/src/main.rs` | +| Call domain services | `src-gpui/src/backend.rs` (no IPC) | +| Terminal tabs / PTY grid | `src-gpui/src/ui/terminal.rs` + `src-gpui/src/state.rs` | +| File tree / icons | `src-gpui/src/ui/file_tree.rs` + `file_icons.rs` | +| Settings window | `src-gpui/src/ui/settings.rs` | +| i18n | `messages/en.json` + `zh.json` → `src-gpui/src/i18n.rs` | +| Agent status detection | `src-gpui/src/detector/` | | PTY session lifecycle | `src-tauri/crates/infra/src/pty.rs` + `crates/service/src/pty.rs` | | DB migrations | `src-tauri/migrations/` (Diesel; auto-applied on startup) | -| Git operations | `src-tauri/crates/infra/src/git.rs` + `src-tauri/src/handler/debug.rs` | -| Context ID resolution | `crates/repo/src/project.rs::resolve_context_folder` (polymorphic project/profile) | +| Git operations | `src-tauri/crates/infra/src/git.rs` | +| Context ID resolution | `crates/repo/src/project.rs::resolve_context_folder` | | Worktree profiles | `crates/service/src/profile.rs` — creates `~/.2code/workspace/{id}` | -| Agent status detection | `src/features/terminal/detector/` → `Terminal.tsx` → terminalStore | -| i18n messages | `messages/en.json` + `messages/zh.json` → `import * as m from "@/paraglide/messages.js"` | | Shell init injection | `infra/shell_init.rs` (ZDOTDIR-based) | +| Leftover Tauri command | `src-tauri/src/handler/*.rs` — do not add product features here | ## COMMANDS ```bash -bun tauri dev # full dev (frontend + Rust hot reload) -bun run dev # frontend only -bun tauri build # production build -cd src-tauri && cargo test # Rust tests -cargo tauri-typegen generate # regenerate src/generated/ after Rust command changes -just fmt # format TS + Rust -just coverage # llvm-cov HTML report +just start # GPUI product UI +just gpui # cd src-gpui && cargo run +just verify # cargo test (GPUI) + cargo test (domain) +just leftover-tauri # leftover webview shell only +cd src-tauri && cargo test # domain crate tests +just fmt # format leftover TS + Rust ``` ## STATE PATTERNS -- **Server state**: TanStack Query — always invalidate on mutations -- **Client state**: Zustand with Immer — terminal store uses `Set` (requires `enableMapSet()`) -- **Persist**: `terminalSettingsStore`, `notificationStore`, `themeStore` use localStorage via `persist` middleware -- **Outside React**: `useTerminalStore.getState().addTab(...)` (direct access, no hook) +- **GPUI**: `AppView` + `AppData` / `Overlay` in `state.rs`; prefs in `gpui-prefs.json` +- **Domain**: Diesel via `Backend` (single SQLite mutex) +- **Leftover React**: TanStack Query + Zustand — do not extend for product work ## KEY PATTERNS -- **IPC flow**: Rust `#[tauri::command]` → `tauri-typegen` → `src/generated/` → TanStack Query hook -- **Terminal persistence**: CSS `display: none` on tab switch — NEVER unmount or conditionally render terminals -- **Context ID**: git handlers accept project ID or profile ID — backend resolves via `resolve_context_folder` -- **Rust test setup**: in-memory SQLite + `conn.run_pending_migrations(MIGRATIONS)` in `setup_db()` -- **DB lock**: single `Arc>` — acquire/release quickly, never hold across awaits +- **No IPC in the product UI**: GPUI calls `service::*` / `repo::*` on the same process +- **Terminal persistence**: keep every `pty-{id}` mounted; hide inactive sessions +- **Context ID**: git accepts project ID or profile ID — `resolve_context_folder` +- **Rust test setup**: in-memory SQLite + `conn.run_pending_migrations(MIGRATIONS)` +- **DB lock**: single `Arc>` — acquire/release quickly ## ANTI-PATTERNS -- `src/api/` — forbidden; all IPC via `src/generated/` auto-gen -- `src/generated/` or `src/paraglide/` — DO NOT EDIT (gitignored, regenerated) -- `src-tauri/src/schema.rs` — DO NOT EDIT (Diesel generated) -- Conditional rendering of `` — breaks xterm.js state -- Legacy UI-library APIs/components — removed; use shadcn/ui primitives from `src/components/ui` -- Long-held DB mutex locks — causes deadlocks -- Font listing / sound APIs without macOS platform guard (macOS-only) +- Treating `src/` React or `bun tauri dev` as the product +- Dropping `pty-{id}` from the element tree (GPUI unmounts the session) +- Unmounting the file tree when the profile sidebar closes (use width 0) +- Opening Settings as a route instead of a second window +- Wholesale rustfmt of `src-gpui/src/ui/settings.rs` or `dialogs.rs` +- Editing `src-tauri/src/schema.rs` (Diesel generated) +- Long-held DB mutex locks ## GOTCHAS -- `src-tauri/src/main.rs:1` — `#![cfg_attr(…, windows_subsystem = "windows")]` has `DO NOT REMOVE!!` -- `topbar` feature is NOT part of `git` feature despite CLAUDE.md proximity — it's a separate customizable control bar system -- Immer `MapSet` plugin must be enabled before any store using `Set`/`Map` (already done in `store.ts`) -- `noUnusedLocals` + `noUnusedParameters` enforced in tsconfig — TS will error on unused vars -- CI: `.github/workflows/tauri-smoke.yml` — smoke test on `ubuntu-24.04` using `xvfb-run` (virtual display) + `webkit2gtk-driver` + Tauri driver. Not a full test suite. -- E2E: `e2e-tests/` uses Mocha + Selenium WebDriver via Tauri driver (not Playwright/Cypress) -- Frontend uses Vitest (`npm test` = `vitest run`); test files colocated as `*.test.ts` — Zustand store tests use `resetStore()` helper pattern -- ESLint uses `@antfu/eslint-config` with React flat config — configuration lives in `eslint.config.js` at the repo root +- rustc 1.87+; `cc = "=1.2.67"` pin in `src-gpui/Cargo.toml` +- `ElementId` via `ui::eid(...)` (`SharedString`), not `From` +- Safe rustfmt: `--edition 2021 --config hard_tabs=true,tab_spaces=4,max_width=120` +- Font listing / sound APIs are platform-backed +- CI product gate: `.github/workflows/gpui-check.yml` (`cargo test` in `src-gpui`) +- Release ships GPUI binaries (`2code-linux-x64` / `2code-macos-arm64` / `2code-windows-x64.exe`) +- Leftover `tauri-smoke.yml` is workflow_dispatch / workflow_call only - `openspec/` dir at root is OpenSpec workflow tooling — not application code -- `src-tauri/src/bridge.rs` — trait impls (`TauriPtyEmitter`, `TauriWatchSender`) that decouple service layer from Tauri diff --git a/CLAUDE.md b/CLAUDE.md index ecc4954c..e6d31e7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,158 +4,120 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**2code** is a Tauri 2 desktop application for managing code projects with integrated terminal sessions. It combines a React 19 frontend with a Rust backend, featuring: +**2code** is a GPUI desktop application for managing code projects with integrated PTY terminals. The shipping UI lives in `src-gpui/` and calls the existing Rust domain crates directly (no Tauri IPC). +- Two windows: main (1440×900 overlay chrome) and Settings (880×640) - Project management with folder selection and metadata - Profile management via git worktrees (branch-isolated workspaces) -- Persistent PTY (pseudo-terminal) sessions with scrollback restoration +- Persistent PTY sessions with scrollback restoration (never unmount on tab/route change) - SQLite database for project/session/profile storage - Project-level configuration (`2code.json`) for setup/teardown scripts - Git diff/commit history browsing -- i18n support via Paraglide.js (English + Chinese) +- i18n from `messages/en.json` + `messages/zh.json` (loaded directly by GPUI) + +The leftover Tauri 2 + React 19 shell in `src/` + `src-tauri/` is **not** the product. Domain crates under `src-tauri/crates/` are shared. Visual/behavior spec: `docs/ui-inventory.md`. ## Commands ```bash -# Dev server (frontend + backend hot-reload) -bun tauri dev - -# Frontend-only dev -bun run dev - -# Production build -bun tauri build - -# Frontend-only build (runs paraglide compile → tsc → vite build) -bun run build - -# Rust tests +# Product UI (GPUI) +just start # same as just gpui +just gpui # cd src-gpui && cargo run +cd src-gpui && cargo run + +# Tests / gate +just verify # gpui tests + domain crate tests +just gpui-check # cd src-gpui && cargo test cd src-tauri && cargo test -cd src-tauri && cargo test test_name # single test +cd src-tauri && cargo test test_name -# Regenerate TypeScript bindings from Rust commands -cargo tauri-typegen generate +# Leftover Tauri + React (reference only) +just leftover-tauri +bun run leftover:tauri:dev -# Format code -just fmt # runs 'fama' +# Format +just fmt # fama (TS leftover) + rustfmt ``` -## Architecture - -### Frontend (`/src`) - -React 19 + TypeScript + Vite. Provider stack (outermost → innermost): `QueryClientProvider` → `ThemeProvider` → `TooltipProvider` → `BrowserRouter` → `AppRoot`, with the shadcn `Toaster` mounted inside `TooltipProvider`. - -**Routing** (react-router v7): `/` → HomePage, `/projects/:id/profiles/:profileId` → ProjectDetailPage, `/settings` → SettingsPage, `*` → redirect to `/`. +App data matches the old Tauri path: `dirs::data_dir()/com.akrc.code` (`app.db` + `gpui-prefs.json`). -**Key directories (feature-based organization):** +## Architecture -- `generated/` — Auto-generated Tauri IPC bindings via `tauri-typegen` (gitignored, do not edit) -- `features/home/` — HomePage -- `features/projects/` — ProjectDetailPage, project hooks (`useProjects`, `useCreateProject`, `useProjectProfiles`, etc.) and dialogs (Create/Delete/Rename) -- `features/profiles/` — Profile hooks (`useCreateProfile`, `useDeleteProfile`) and dialogs -- `features/terminal/` — Terminal store, hooks (`useCreateTerminalTab`, `useCloseTerminalTab`, `useRestoreTerminals`, `useTerminalTheme`), themes, and components (Terminal, TerminalTabs, TerminalLayer, TerminalPreview) -- `features/git/` — GitDiffDialog, ProjectTopBar (git branch display + diff trigger), and components (ChangesFileList, CommitList, GitDiffPane, HistoryFileList) -- `features/settings/` — SettingsPage, picker components, and Zustand stores (`stores/terminalSettingsStore`, `stores/themeStore`, `stores/notificationStore`) -- `features/watcher/` — File system watcher hook (`useFileWatcher`) for live project updates via Tauri events -- `features/debug/` — Debug panel (Cmd+Shift+D toggle), debug logger, and stores (`debugStore`, `debugLogStore`) -- `shared/lib/` — Query client config, centralized query keys, cached promise utility -- `shared/providers/` — ThemeProvider -- `shared/components/` — Fallbacks (PageSkeleton, PageError, SidebarSkeleton), SidebarLink. ErrorBoundary is from `react-error-boundary` package. -- `layout/` — AppSidebar and `sidebar/` sub-components (ProjectMenuItem, ProfileList, ProfileItem) +### Product UI (`/src-gpui`) -**State management:** +Standalone crate (not in the `src-tauri` workspace), bin name `2code`. Stack: `gpui` + `gpui-component`, path-deps on `../src-tauri/crates/{model,repo,infra,service}`. -- Zustand for client state (terminal tabs per project, font preferences, notification settings) -- TanStack Query for server state (projects, sessions, profiles) -- Query keys centralized in `shared/lib/queryKeys.ts` — always use `queryKeys.projects.all` / `queryKeys.git.diff(profileId)` pattern -- `terminalSettingsStore`, `notificationStore`, and `themeStore` use persist middleware (localStorage). Terminal store is rebuilt from DB on startup. +**Key modules:** -**UI Framework:** +- `main.rs` — window chrome, tracing + debug log channel +- `app.rs` — `AppView` actions, shortcuts, orchestration +- `backend.rs` — direct service/repo calls (no IPC) +- `state.rs` — workspaces, overlays, dialogs, toasts +- `prefs.rs` — `gpui-prefs.json` (theme, fonts, top bar, templates, sidebar) +- `ui/` — sidebar, home, workspace, file tree, terminal, git, settings, dialogs, palette, debug +- `detector/` — agent status manifests (same rules as the leftover React detector) +- `i18n.rs` — `t` / `tf` over `messages/{en,zh}.json` +- `platform.rs` — fonts, sounds, installed apps +- `updater.rs` — GitHub releases + in-place GPUI binary replace -- shadcn/ui primitives in `src/components/ui` (Base UI + Tailwind CSS v4) -- `next-themes` for dark/light mode (wrapped in custom ThemeProvider) -- `sonner` for toast notifications +**Windows:** main 1440×900 overlay title bar, macOS traffic lights at (16, 24); Settings via `cx.open_window` 880×640, title `"Settings"`. -### Backend (`/src-tauri`) +**Shortcuts:** Cmd+, settings · Cmd+Shift+D debug · Cmd+K palette · Cmd+T/W terminal · Cmd+E sidebar · Cmd+G git diff · Cmd+S save · Cmd/Ctrl+Enter commit. -Rust application with Tauri 2. Entry: `main.rs` → `lib.rs`. +**Invariants:** -**Layered architecture** (4 layers): +- Terminals stay mounted: every session keeps a stable `pty-{id}` element. Hide inactive ones; do not omit the id for a frame. +- File tree stays mounted across Files/Git/Notes **and** when the profile sidebar is closed (width 0, not unmount). +- Settings is a second window, not a route. +- Brand text `"2Code"` is hardcoded. -1. **Handler** (`handler/`) — Tauri `#[tauri::command]` entry points. Extracts state (DbPool, PtySessionMap), acquires DB lock, delegates to service layer. Thin layer — no business logic. -2. **Service** (`service/`) — Business logic and orchestration. Coordinates between repository and infrastructure layers (e.g., creating temp dirs, initializing git repos, running scripts). -3. **Repository** (`repo/`) — Direct database access via Diesel ORM. CRUD operations and complex queries (e.g., `resolve_context_folder` tries profiles table first, falls back to projects). -4. **Infrastructure** (`infra/`) — Cross-cutting concerns: `db.rs` (SQLite setup + migrations), `git.rs` (git command execution), `pty.rs` (PTY session lifecycle), `pty_log.rs` (per-session output stored as `{app_data_dir}/pty_logs/{session_id}.log` files), `slug.rs` (CJK-aware slug generation), `config.rs` (project config loading + script execution), `logger.rs` (debug logging), `watcher.rs` (file system watching), `shell_init.rs` (ZDOTDIR-based shell init injection). +### Domain crates (`/src-tauri/crates`) -**Model** (`model/`) — Diesel models and DTOs: Queryable structs (`Project`, `Profile`, `PtySessionRecord`), Insertable structs (`NewProject`, `NewProfile`), AsChangeset structs (`UpdateProject`, `UpdateProfile`), and non-DB types (`GitCommit`, `GitAuthor`, `WatchEvent`, `LogEntry`). +No Tauri dependency. Four layers plus models: -**Database:** SQLite via Diesel ORM, single connection wrapped in `Arc>` (not a pool). Stored at `app_data_dir()/app.db`. Pragmas: WAL journal mode, foreign keys ON. Tables: `projects`, `profiles`, `pty_sessions` (session **metadata** only — output bytes live in per-session files, not the DB). +1. **Service** — business logic (project, profile, pty, watcher, filesystem) +2. **Repository** — Diesel CRUD; `resolve_context_folder` tries profiles then projects +3. **Infrastructure** — SQLite + migrations, git, PTY, pty_log, slug, config, logger, watcher, shell_init +4. **Model** — Diesel models and DTOs -**Database migrations:** Diesel migrations in `src-tauri/migrations/`, embedded at compile time via `diesel_migrations::embed_migrations!()` and run on app startup in `infra::db::init_db()`. Schema auto-generated in `src/schema.rs`. +**Database:** SQLite, single `Arc>` (not a pool) at `app_data_dir()/app.db`. WAL, foreign keys ON. Tables: `projects`, `profiles`, `pty_sessions` (metadata only — output bytes live in `pty_logs/{session_id}.log`). Migrations in `src-tauri/migrations/`, embedded at compile time. -**PTY output streaming:** Background thread reads 4KB chunks → sends raw PTY chunks to the active output sink registered by `attach_pty_output(sessionId, streamId)`. `stream_pty_output` owns a `tauri::ipc::Channel<&[u8]>` and sends each chunk with `on_output.send(chunk.as_slice())`; `detach_pty_output` must pass the same `streamId` so stale React cleanup cannot remove a newer stream for the same session. `Terminal.tsx` receives `ArrayBuffer` and writes `new Uint8Array(payload)` to xterm. Output produced before attach is recovered from the persisted log via `get_pty_session_history` (same seam the old global-event API had). Bytes are sent as-is — xterm.js decodes UTF-8 across writes, so no backend boundary splitting is needed. Session exit is still a low-volume global event (`pty-exit-{id}`). A separate persistence thread via mpsc channel with 32KB flush buffer appends raw bytes to the session's `pty_logs/{session_id}.log` file (see `infra::pty_log`). No byte cap — a log lives only for one session (removed on restore/close/delete) and scrollback is bounded on restore by the vt100 emulator (`sanitize_history`, 10k lines). Orphan logs are reaped on startup by `service::pty::gc_orphan_logs`. +**PTY:** `service::pty::create_session` + `PtyContext` + `PtyEventEmitter`. Restore: `mark_all_closed` → fetch sessions → `restore_session` → feed history into vt100 → attach. GPUI renders the vt100 screen; leftover React used xterm.js over a Tauri Channel. **Workspace crates:** `model/`, `repo/`, `service/`, and `infra/`. -**Agent status detection:** `Terminal.tsx` detects coding-agent state from xterm screen text, OSC title, and OSC progress after live output writes. Rules live in `src/features/terminal/detector/rules/`, one manifest per agent. The detector publishes `running|waiting|idle` to `terminalStore`; waiting status can play the configured system sound via the generated `playSystemSound` Tauri command. - -### IPC Pattern (Frontend ↔ Backend) - -The project uses **tauri-typegen** to auto-generate typed TypeScript bindings from Rust commands. Config in `tauri.conf.json` under `plugins.typegen` (output: `src/generated/`). +### Leftover Tauri + React (`/src`, `/src-tauri`) -**Adding a new command:** - -1. Define Rust command with `#[tauri::command]` in `handler/*.rs` -2. Register in `lib.rs` via `tauri::generate_handler![]` -3. Run `cargo tauri-typegen generate` to regenerate TypeScript bindings -4. Import generated function directly: `import { myCommand } from "@/generated"` -5. Consume via TanStack Query hook in the relevant `src/features/*/hooks.ts` with query invalidation on mutations - -**Do not** create manual API wrappers in `src/api/` — all IPC bindings are auto-generated. +Reference implementation of the old webview UI. Do not add product features there. Do not delete until a real-machine GUI pass against `docs/ui-inventory.md` §20. Default start/release/CI paths are GPUI. ## Key Patterns ### Terminal Persistence -Terminals never unmount — tab switches and route changes use CSS `display: none` to preserve xterm.js state. The `TerminalLayer` component renders as a persistent absolute-positioned overlay across all routes. +All PTY grids from every workspace stay in one layer with stable `pty-{id}` ids. Tab switches and profile changes hide inactive terminals; they must not be dropped from the element tree. **Session restoration on app start:** -1. Fetch all sessions from DB (including closed ones with scrollback) -2. Create new PTY session with same metadata -3. Pass old `session.id` as `restoreFrom` prop -4. Terminal component fetches history, writes to xterm, then deletes old record - -**Session cleanup:** `mark_all_open_sessions_closed()` runs both on startup (orphan cleanup) and on exit (graceful shutdown). +1. `mark_all_open_sessions_closed()` +2. Fetch sessions (including closed ones with scrollback) +3. `restore_session` with the old id +4. Feed persisted history into vt100, then attach live output ### Context ID Resolution -Git operations (`get_git_diff`, `get_git_log`, `get_commit_diff`) accept a `contextId` parameter that can be either a project ID or a profile ID. The backend resolves this polymorphically via `repo::project::resolve_context_folder()`: profile ID → profile's worktree path; project ID → project's folder. This lets git operations work seamlessly with both regular project folders and profile worktrees. +Git operations accept a project ID or a profile ID. `repo::project::resolve_context_folder()`: profile → worktree path; project → folder. ### Profile System (Git Worktrees) -Profiles create isolated branch workspaces using `git worktree add`. Each profile gets a worktree in `~/.2code/workspace/{profile_id}`. Branch names are sanitized (CJK → pinyin, special chars stripped). On creation, `setup_script` from `2code.json` runs in the worktree. On deletion, `teardown_script` runs, then the worktree and branch are removed. +`git worktree add` into `~/.2code/workspace/{profile_id}` (or the configured worktree dir). Branch names are sanitized (CJK → pinyin). `setup_script` / `teardown_script` from `2code.json` run on create/delete. ### Project Configuration (`2code.json`) -Projects can include a `2code.json` in their root folder: - ```json { "setup_script": ["npm install"], "teardown_script": ["rm -rf node_modules"] } ``` -Scripts execute via `sh -c` in the project/worktree directory. Used automatically during profile creation/deletion. - -### Zustand Store Convention - -```typescript -// Direct access in mutations (outside React): -useTerminalStore.getState().addTab(...) - -// Reactive subscriptions in components: -const tabs = useTerminalStore(s => s.profiles[profileId]?.tabs) -``` +Scripts execute via `sh -c` in the project/worktree directory. ### Rust Test Pattern @@ -170,37 +132,29 @@ fn setup_db() -> SqliteConnection { } ``` -Tests are colocated with implementation in `#[cfg(test)]` modules. +Tests are colocated with implementation in `#[cfg(test)]` modules. GPUI unit tests live in `src-gpui/src/**`. ## Internationalization (i18n) -Paraglide.js v2 with inlang message format plugin. Source messages in `messages/{locale}.json`. Generated code in `src/paraglide/` (gitignored, do not edit). - -**Usage:** `import * as m from "@/paraglide/messages.js"` → `m.home()` +Source messages in `messages/{locale}.json`. GPUI loads them in `src-gpui/src/i18n.rs` (`t` / `tf`). The leftover React shell still uses Paraglide (`src/paraglide/`, gitignored). -**Critical:** `project.inlang/settings.json` **must** include the modules array: +**Critical for leftover Paraglide:** `project.inlang/settings.json` **must** include: ```json "modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"] ``` -Without this, paraglide compiles but generates empty message files. Also requires `allowJs: true` in tsconfig.json. - -## Path Aliases - -`@/` maps to `src/` — configured in both `vite.config.ts` (resolve.alias) and `tsconfig.json` (paths). Keep them in sync. - ## Gotchas -- **Database is single-connection** (`Arc>`), not a pool — avoid long-held locks -- **Terminals use CSS display for show/hide** — do not refactor to conditional rendering or they lose xterm state -- **PTY output sends `&[u8]` over a per-session IPC `Channel`** (not global events; frontend receives `ArrayBuffer`) — xterm.js handles UTF-8 decoding across chunk boundaries, so no backend boundary splitting is done -- **Terminal font metrics must be measured on an attached canvas** — WebKit only resolves locally installed fonts for canvases that are in the document; detached canvases and `OffscreenCanvas` silently report fallback metrics. xterm 6 measures offscreen but paints via the DOM, so `src/features/terminal/lib/xtermMetricsPatch.ts` redirects both of its measurement surfaces (`CharSizeService` and `WidthCache`) to attached canvases. Without it the terminal leaves ~1/6 of its width empty with any font whose advance ratio differs from the fallback's (e.g. Sarasa's 0.5 em vs Menlo's 0.6 em). See `src/features/terminal/AGENTS.md`. -- **Font listing and sound playback are platform-backed**: macOS uses `core-text` + `/System/Library/Sounds` + `afplay`; Linux uses `fontdb` + XDG sound dirs + desktop audio players; Windows uses `fontdb` + `C:\Windows\Media` + PowerShell `Media.SoundPlayer`. -- **UI components** should use shadcn/ui primitives from `src/components/ui`; do not add legacy UI-library APIs back -- **Directory/branch name generation** uses `pinyin` crate for CJK → romanized slugs — well-tested, don't simplify -- **macOS title bar** uses overlay style with custom traffic light positioning — window chrome is defined in `tauri.conf.json` -- **Tauri plugins**: `tauri-plugin-opener`, `tauri-plugin-dialog`, `tauri-plugin-notification`, `tauri-plugin-store` — all registered in `lib.rs` -- **Generated bindings** (`src/generated/`) are gitignored — run `cargo tauri-typegen generate` after changing Rust commands -- **Diesel schema** (`src-tauri/src/schema.rs`) is auto-generated — do not edit manually; run `diesel print-schema` or migrations -- **Immer MapSet plugin** — terminal store uses `Set` for `notifiedTabs`, requires `enableMapSet()` from immer before store creation. Already called at module level in `store.ts`; if adding `Set`/`Map` to other immer stores, enable it there too +- **Database is single-connection** — acquire/release quickly, never hold across awaits +- **Do not drop `pty-{id}`** — GPUI persistence is by ElementId; omitting an id unmounts the session +- **Do not rustfmt `src-gpui/src/ui/settings.rs` or `dialogs.rs` wholesale** — `settings.rs` has `rustfmt_skip`; both explode under default rustfmt +- Safe rustfmt: `rustfmt --edition 2021 --config hard_tabs=true,tab_spaces=4,max_width=120` on edited files only +- **`ElementId` does not impl `From`** — use `ui::eid(...)` (`SharedString`) +- **`div().overflow_y_scroll()` and `div().tooltip(...)` do not exist** — Button has `.tooltip(String)` +- **`InputState::selected_text` is `pub(super)`** — use `EntityInputHandler::selected_text_range` (UTF-16) +- Font listing / sound playback are platform-backed (core-text / fontdb / XDG / Windows Media) +- Directory/branch slugs use the `pinyin` crate — don't simplify +- Diesel schema `src-tauri/src/schema.rs` is auto-generated — do not edit +- `src/` / `src/generated/` / `src/paraglide/` are leftover React — do not treat as the product UI +- rustc **1.87+**; pin **`cc = "=1.2.67"`** in `src-gpui/Cargo.toml` diff --git a/README.md b/README.md index c66f99dc..07b1c137 100644 --- a/README.md +++ b/README.md @@ -39,67 +39,67 @@ AI-assisted development creates more parallel state than a normal coding session ## Tech Stack -- **Desktop shell**: Tauri 2 -- **Frontend**: React 19, TypeScript, Vite -- **UI state**: Zustand + Immer -- **Server state**: TanStack Query -- **Backend**: Rust workspace +- **Desktop shell**: GPUI + gpui-component (`src-gpui/`) — this is the current product UI +- **Domain backend**: Rust workspace crates in `src-tauri/crates/` (model, repo, service, infra) - **Database**: SQLite via Diesel migrations -- **Terminal runtime**: integrated PTY service -- **Package manager**: Bun +- **Terminal runtime**: integrated PTY service + vt100 +- **Leftover reference shell**: Tauri 2 + React 19 in `src/` / `src-tauri/` — not the shipping product UI. Domain crates under `src-tauri/crates/` are still used by GPUI. ## Getting Started ### Prerequisites -- macOS -- Bun -- Rust stable toolchain -- Tauri 2 development prerequisites +- Rust 1.87+ (GPUI) - `just` for development helper commands -- `fama` on `PATH` for `just fmt` TypeScript formatting - -### Install dependencies - -```bash -bun install -``` +- On Linux: `libxkbcommon`, Vulkan, and Wayland/X11 development libraries +- The previous Tauri + React shell also needs Bun and Tauri 2 if you still run that path ### Run the desktop app ```bash -bun tauri dev +just start +# or +just gpui +# or +cd src-gpui && cargo run ``` -### Run only the frontend +The GPUI app uses the same SQLite database as the old Tauri shell: `$XDG_DATA_HOME/com.akrc.code/app.db` (or the platform equivalent). Preferences live in `gpui-prefs.json` next to the DB. + +### Leftover Tauri + React shell ```bash -bun run dev +bun install +just leftover-tauri +# or: bun run leftover:tauri:dev ``` ### Build ```bash -bun tauri build +cd src-gpui && cargo build --release ``` +Tagged releases publish GPUI binaries (`2code-linux-x64`, `2code-macos-arm64`, `2code-windows-x64.exe`). The leftover Tauri bundle is only `bun run legacy:tauri:build`. + ## Useful Commands | Command | Description | | --- | --- | -| `bun tauri dev` | Run the full desktop app with frontend and Rust hot reload | -| `bun run dev` | Run the Vite frontend only | -| `bun tauri build` | Build the production desktop app | -| `cd src-tauri && cargo test` | Run Rust tests | -| `cargo tauri-typegen generate` | Regenerate frontend IPC bindings after Rust command changes | +| `just start` / `./start.sh` | Run the GPUI desktop app | +| `just gpui` | Run the GPUI desktop app | +| `just gpui-check` / `just verify` | Run GPUI unit tests, then domain crate tests | +| `cd src-tauri && cargo test` | Run Rust domain tests | | `just fmt` | Format TypeScript and Rust | | `just coverage` | Generate Rust coverage report | +| `just leftover-tauri` | Run the leftover Tauri + React shell | ## Project Structure ```text 2code/ -├── src/ # React + Vite frontend +├── src-gpui/ # GPUI native desktop shell (primary) +├── src/ # Legacy React + Vite frontend │ ├── features/ # Feature-first app modules │ ├── shared/ # Shared lib, providers, components, hooks │ ├── layout/ # App shell and sidebar diff --git a/docs/README.md b/docs/README.md index 2dc9ea3f..8112b387 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,17 @@ # 2code Architecture Documentation -> Auto-generated structural documentation for the **2code** Tauri 2 desktop application. +> Structural documentation for **2code**. The current product UI is the GPUI native shell in `src-gpui/`. The Tauri + React tree remains as a reference implementation. ## Overview -**2code** is a macOS desktop app for managing code projects with integrated persistent terminal sessions. It pairs a React 19 frontend with a Rust backend, connected via Tauri 2 IPC with auto-generated TypeScript bindings. +**2code** is a desktop app for managing code projects with integrated persistent terminal sessions. The GPUI rewrite in `src-gpui/` owns the windows and UI; it reuses the existing Rust domain crates (`model`, `repo`, `service`, `infra`) without Tauri IPC. ### Tech Stack | Layer | Technology | | -------------- | ------------------------------------------ | -| Frontend | React 19, TypeScript, Vite 8, shadcn/ui | +| Native UI | GPUI + gpui-component (`src-gpui/`) | +| Frontend | React 19, TypeScript, Vite 8, shadcn/ui (legacy Tauri shell) | | State (client) | Zustand 5 + immer | | State (server) | TanStack Query 5 | | Routing | react-router v7 | @@ -42,6 +43,8 @@ │ ├── generated/ # Auto-generated Tauri IPC bindings (gitignored) │ └── paraglide/ # Generated i18n code (gitignored) │ +├── src-gpui/ # GPUI native rewrite (primary desktop shell) +│ ├── src-tauri/ # Backend (Rust) │ ├── src/ │ │ ├── lib.rs # App setup: plugins, state, commands, lifecycle @@ -70,3 +73,8 @@ | [API Reference](api-reference.md) | All Tauri commands, Tauri events, HTTP endpoints | | [Configuration](configuration.md) | Config files, environment variables, database schema | | [Notification Behavior](notification-behavior.md) | Terminal unread-dot state machine and click behavior | +| [UI Inventory](ui-inventory.md) | Framework-agnostic rewrite spec for every window, screen, component, layout, and dialog | +| [Sidebar UI Inventory](sidebar-ui-inventory.md) | Pixel-level appendix: app sidebar + profile sidebar | +| [Home / Project UI Inventory](ui-inventory-home-project.md) | Pixel-level appendix: home, file tree, viewer, command palette, project dialogs | +| [Settings / Terminal / Git UI Inventory](ui-inventory-settings-terminal-git-debug-updater.md) | Pixel-level appendix: settings window, terminal, git, debug, updater | +| [GPUI rewrite](../src-gpui/README.md) | Native GPUI desktop shell that implements the UI inventory | diff --git a/docs/sidebar-ui-inventory.md b/docs/sidebar-ui-inventory.md new file mode 100644 index 00000000..1ebf9f4e --- /dev/null +++ b/docs/sidebar-ui-inventory.md @@ -0,0 +1,1073 @@ +# 2code Sidebar / Left-Nav UI Inventory + +Framework-agnostic inventory of every visual and interactive element in the app’s left navigation surfaces. Derived exclusively from source files listed in the exploration scope; nothing below is inferred beyond what the code defines. + +**Scope:** Primary app sidebar (`AppSidebar`), profile secondary sidebar (`ProfileSidebar`), related primitives, loading/error fallbacks, window chrome that affects sidebar layout, and the top-bar mode switch that controls the profile sidebar. + +**Last audited against:** `src/layout/*`, `src/features/projects/ProfileSidebar.tsx`, `src/features/projects/SidebarModeSwitch.tsx`, `src/features/git/components/SidebarGitPanel.tsx`, `src/components/ui/sidebar.tsx`, and supporting shared components. + +--- + +## 1. Application layout context + +``` +App (flex h-full flex-col) +└── div (flex min-h-0 flex-1) ← horizontal split + ├── AsyncBoundary + │ └── AppSidebar OR SidebarSkeleton OR SidebarError + └── main (relative flex-1 overflow-y-auto bg-card) + ├── Routes (HomePage, ProjectDetailPage, …) + └── TerminalLayer (persistent overlay) + +ProjectDetailPage → ProfileLayout +└── div (flex h-full flex-col) + ├── CommandPalette + ├── div.border-b → ProjectTopBar (contains SidebarModeSwitch + expand-sidebar when collapsed) + └── div (flex min-h-0 min-w-0 flex-1) + ├── ProfileSidebar (files | git | notes) + └── div.min-h-0.flex-1 → main content (terminals, file viewer, …) + +Windows only: WindowControls (fixed top-right, h-7) +``` + +When `useAppSidebarStore.isCollapsed === true`, `AppSidebar` renders **nothing** (`return null`). The expand control appears in `ProjectTopBar` (project routes only), not on `HomePage`. + +--- + +## 2. Design tokens and numeric constants + +### 2.1 App sidebar width (`sidebarStore.ts`) + +| Constant | Value | Usage | +|----------|-------|--------| +| `APP_SIDEBAR_MIN_WIDTH` | **220** px | Resize separator `aria-valuemin`, clamp | +| `APP_SIDEBAR_MAX_WIDTH` | **420** px | Resize separator `aria-valuemax`, clamp | +| `APP_SIDEBAR_DEFAULT_WIDTH` | **250** px | Initial persisted width | +| Resize keyboard step | **16** px | `useHorizontalResize` default `step` | + +CSS variable `--sidebar-width` is set on: +- `SidebarProvider` inline style in `AppSidebar` +- `document.documentElement` via `sidebarStore` subscribe/sync + +Default in `app.css`: `--sidebar-width: 250px` + +### 2.2 Profile sidebar panel width (`ProfileSidebar.tsx`) + +| Constant | Value | +|----------|-------| +| `SIDEBAR_PANEL_MIN_WIDTH` | **180** px | +| `SIDEBAR_PANEL_MAX_WIDTH` | **560** px | +| `DEFAULT_SIDEBAR_PANEL_WIDTH` | **208** px | +| localStorage key | `"file-tree-panel"` | +| Resize keyboard step | **16** px | + +### 2.3 shadcn sidebar primitives (`sidebar.tsx`) — used but partially overridden + +| Token | Value | Notes | +|-------|-------|-------| +| `SIDEBAR_WIDTH` | `16rem` (**256** px at 16px root) | Overridden by AppSidebar dynamic px | +| `SIDEBAR_WIDTH_MOBILE` | `18rem` (**288** px) | Not used: AppSidebar sets `collapsible="none"` | +| `SIDEBAR_WIDTH_ICON` | `3rem` (**48** px) | Not used in AppSidebar | +| `SIDEBAR_KEYBOARD_SHORTCUT` | `"b"` | Cmd/Ctrl+B in `SidebarProvider` — AppSidebar does not use shadcn collapse | + +### 2.4 Sidebar color tokens (`app.css`) + +Light `:root`: +- `--sidebar`: oklch(0.985 0 0) +- `--sidebar-foreground`: oklch(0.145 0 0) +- `--sidebar-accent`: oklch(0.97 0 0) +- `--sidebar-accent-foreground`: oklch(0.205 0 0) +- `--sidebar-border`: oklch(0.922 0 0) +- `--sidebar-ring`: oklch(0.708 0 0) + +Dark `.dark` overrides exist for the same keys. + +### 2.5 Tailwind size reference (used in sidebar UI) + +| Class | Computed (16px root) | +|-------|----------------------| +| `size-4` / `[&_svg]:size-4` | 16×16 px | +| `size-3.5` | 14×14 px | +| `size-2` | 8×8 px (agent dot) | +| `size-6` | 24×24 px (maximize button) | +| `h-7` | 28 px | +| `h-8` | 32 px | +| `h-12` | 48 px (header brand button) | +| `w-5` / `min-w-5` / `h-5` | 20 px (badges, group actions) | +| `w-2` | 8 px (resize hit area) | +| `w-[250px]` | 250 px (skeleton/error fallback) | +| `pt-8` | 32 px (macOS header) | +| `pt-2` | 8 px (non-macOS header) | +| `p-2` | 8 px (header/footer/group padding) | +| `px-2` / `py-1.5` | horizontal 8 / vertical 6 px | +| `mx-3` | horizontal margin 12 px | +| `pl-4` | padding-left 16 px (reorder nested projects) | +| `right-9` | right offset 36 px (edit-order action) | +| `right-[-4px]` | extends resize handle 4 px past edge | +| `text-xs` | 12 px font | +| `text-sm` | 14 px font | +| `text-[0.625rem]` | 10 px (avatar fallback letter) | +| `rounded-md` | `--radius` related (~10 px with 0.625rem radius) | + +--- + +## 3. Shared UI primitives (`components/ui/sidebar.tsx`) + +App sidebar composes these building blocks. `AppSidebar` passes `collapsible="none"`, so only the **non-collapsible** code path runs (fixed-width column, no Sheet/mobile/offcanvas). + +### 3.1 `SidebarProvider` + +- Wrapper: `div.group/sidebar-wrapper.flex.min-h-svh.w-full` +- Merges CSS vars: `--sidebar-width`, `--sidebar-width-icon` +- AppSidebar adds: `className="h-full min-h-0 w-auto shrink-0"` and inline `--sidebar-width: ${sidebarWidth}px` + +### 3.2 `Sidebar` (collapsible="none" branch) + +- `div[data-slot=sidebar]` +- Classes: `flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground` + AppSidebar: `relative min-h-0 shrink-0 border-r` +- AppSidebar adds: `role="navigation"`, `aria-label={m.sideNavLabel()}`, `onKeyDown={handleKeyDown}` + +### 3.3 `SidebarHeader` + +- `div[data-slot=sidebar-header]` +- Default: `flex flex-col gap-2 p-2` +- AppSidebar: `shrink-0`, `data-tauri-drag-region`, platform padding (see §10) + +### 3.4 `SidebarContent` + +- `div[data-slot=sidebar-content]` +- Default: `no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto …` +- AppSidebar: `overflow-x-hidden [scrollbar-gutter:stable]` + +### 3.5 `SidebarFooter` + +- `div[data-slot=sidebar-footer]` +- Default: `flex flex-col gap-2 p-2` +- AppSidebar: `shrink-0` + +### 3.6 `SidebarGroup` + +- `div[data-slot=sidebar-group]` +- `relative flex w-full min-w-0 flex-col p-2` + +### 3.7 `SidebarGroupLabel` + +- `div` with: `flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 …` +- Contains section title text + optional `SidebarGroupAction` buttons (absolutely positioned) + +### 3.8 `SidebarGroupAction` + +- Button: `absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 … [&>svg]:size-4` +- Reorder-mode “done” variant adds: `right-9 bg-sidebar-accent text-sidebar-accent-foreground` +- Normal edit-order: `right-9` only on pencil button; plus button at default `right-3` + +### 3.9 `SidebarGroupContent` + +- `div`: `w-full text-sm` + +### 3.10 `SidebarMenu` / `SidebarMenuItem` + +- Menu: `ul.flex.w-full.min-w-0.flex-col.gap-0` +- Item: `li.group/menu-item.relative` + +### 3.11 `SidebarMenuButton` (variants) + +Base (`sidebarMenuButtonVariants`): +- `peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm …` +- `group-has-data-[sidebar=menu-action]/menu-item:pr-8` — reserves space for trailing action +- `[&_svg]:size-4 [&_svg]:shrink-0` +- `[&>span:last-child]:truncate` +- **Hover:** `hover:bg-sidebar-accent hover:text-sidebar-accent-foreground` +- **Active:** `data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground` +- **Focus:** `focus-visible:ring-2` on `ring-sidebar-ring` + +Sizes: +- `default`: `h-8 text-sm` +- `sm`: `h-7 text-xs` +- `lg`: `h-12 text-sm` (header brand) + +### 3.12 `SidebarMenuAction` + +- `absolute top-1.5 right-1 flex aspect-square w-5 … [&>svg]:size-4` +- **Hover:** `hover:bg-sidebar-accent hover:text-sidebar-accent-foreground` +- `showOnHover` mode (not used in AppSidebar project items): opacity 0 until group hover + +### 3.13 `SidebarMenuBadge` + +- `absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums` +- Used for project-group project counts + +### 3.14 `SidebarMenuSub` + +- Default: `ul.mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5` +- `ProjectGroupSection` override: `mx-0 translate-x-0 gap-0 border-l-0 px-0 py-0` + +### 3.15 `SidebarMenuSubItem` + +- `li.group/menu-sub-item.relative` + +### 3.16 `SidebarMenuSubButton` + +- `flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sm …` +- `[&>svg]:size-4`, active/hover same accent pattern as menu button + +--- + +## 4. Primary app sidebar — `AppSidebar.tsx` + +### 4.1 Visibility and collapse + +- **Fully hidden** when `isCollapsed === true` (entire component returns `null`). +- Collapse toggle: header `SidebarMenuAction` with `SidebarSimpleIcon`, `aria-label={m.collapseSidebar()}`, `aria-expanded={true}`, `onClick={toggleCollapsed}`. +- Persisted in localStorage key `"app-sidebar-width"` (partialized: `width`, `isCollapsed`, `collapsedProjectGroupIds`; `isReorderMode` is **not** persisted). + +### 4.2 Component tree (normal mode, projects exist) + +``` +AppSidebar +├── SidebarProvider (--sidebar-width: Npx) +│ └── Sidebar [role=navigation, aria-label=sideNavLabel] +│ ├── LayoutGroup id="app-sidebar" +│ │ ├── SidebarHeader [data-tauri-drag-region] +│ │ │ └── SidebarMenu +│ │ │ └── SidebarMenuItem +│ │ │ ├── SidebarMenuButton size=lg pointer-events-none +│ │ │ │ └── span.font-semibold → "2Code" +│ │ │ └── SidebarMenuAction → SidebarSimpleIcon (collapse) +│ │ ├── SidebarContent +│ │ │ ├── [optional] SidebarGroup "Pinned" +│ │ │ │ ├── SidebarGroupLabel → pinnedProjects +│ │ │ │ └── SidebarGroupContent → SidebarMenu +│ │ │ │ └── ProjectMenuItem × N +│ │ │ └── SidebarGroup "Projects" +│ │ │ ├── SidebarGroupLabel +│ │ │ │ ├── span → sidebarProjectsSection +│ │ │ │ ├── SidebarGroupAction → PencilSimpleLineIcon (edit order) +│ │ │ │ └── SidebarGroupAction id=add-project-button → PlusIcon +│ │ │ └── SidebarGroupContent → SidebarMenu +│ │ │ ├── ProjectGroupSection × groups +│ │ │ └── ProjectMenuItem × top-level projects +│ │ └── SidebarFooter +│ │ └── SidebarMenu +│ │ └── SidebarMenuItem +│ │ └── SidebarMenuButton → GearSixIcon + settings +│ └── div[role=separator] (width resize handle) +└── CreateProjectDialog +``` + +### 4.3 Header row + +| Element | Content | Layout / classes | Interaction | +|---------|---------|------------------|-------------| +| Brand button | Text **"2Code"** (hardcoded, not i18n) | `SidebarMenuButton size="lg" className="pointer-events-none"`, inner `span.font-semibold` | Non-interactive (`pointer-events-none`) | +| Collapse action | `SidebarSimpleIcon` weight `regular` | `SidebarMenuAction`, default action positioning | Click → `toggleCollapsed`; a11y: `collapseSidebar` | + +Both sit in one `SidebarMenuItem`. Menu button height **48 px** (`lg`). + +### 4.4 Empty projects state + +When `projects.length === 0`: + +``` +SidebarGroup +└── SidebarGroupContent + └── SidebarMenu + └── SidebarLink to="/" icon=HouseIcon → home +``` + +No pinned/projects sections, no footer change (footer still shows Settings). + +### 4.5 Pinned projects section + +**Visible when:** `sidebarLayout.pinnedProjects.length > 0` (normal mode). + +| Element | Content | +|---------|---------| +| `SidebarGroupLabel` | `m.pinnedProjects()` → **"Pinned"** | +| Rows | `ProjectMenuItem` per pinned project | + +### 4.6 Projects section (normal mode) + +| Element | Content | a11y | +|---------|---------|------| +| Label text | `m.sidebarProjectsSection()` → **"Projects"** | — | +| Edit order | `PencilSimpleLineIcon`, `className="right-9"` | `aria-label={editProjectOrder}`, `aria-pressed={isReorderMode}`, `disabled={isSidebarLayoutSaving}` | +| New project | `PlusIcon`, `id="add-project-button"` | `aria-label={newProject}` → **"New Project"** | +| Body | `ProjectGroupSection` or `ProjectMenuItem` per `topEntries` | — | + +**Dialog launched:** `CreateProjectDialog` via plus button. + +### 4.7 Reorder mode (`isReorderMode === true`) + +Replaces normal pinned/projects rendering with drag-and-drop UI. + +#### 4.7.1 Pinned group (reorder) + +- Label: **"Pinned"** +- `DndContext` + `SortableContext` + `SortableProjectRow` per pinned project +- Trailing `SidebarDropZone` id=`sidebar-drop:pinned`, label `dropHereToPin`, `compact` when pinned list non-empty + +#### 4.7.2 Projects group (reorder) + +- Label row: **"Projects"** + **CheckIcon** action (`doneEditingProjectOrder`, `className="right-9 bg-sidebar-accent text-sidebar-accent-foreground"`) + **PlusIcon** (new project) +- Top-level sortable entries: + - **Group:** `SortableGroupRow` with nested `SidebarMenu className="pl-4"` containing nested `SortableProjectRow` + group drop zone + - **Project:** `SortableProjectRow` +- Bottom `SidebarDropZone` id=`sidebar-drop:top-level`, label `dropHereToUnpinOrMoveOut` + +#### 4.7.3 `SortableProjectRow` + +``` +SidebarMenuItem [opacity 0.45 when dragging] +├── SidebarMenuButton [data-sidebar-item, bg-sidebar-accent when dragging] +│ ├── span [cursor-grab] → DotsSixVerticalIcon (muted) +│ ├── ProjectAvatar +│ └── span → project.name +└── SidebarMenuAction + ├── aria-label: pinProject | unpinProject + ├── aria-pressed={isPinned} + └── StarIcon +``` + +- Drag activation: `PointerSensor` distance **5** px +- Pin toggle calls `handleTogglePinned` (disabled while save in flight) + +#### 4.7.4 `SortableGroupRow` + +Same drag handle + `FolderIcon` + group name; `SidebarMenuBadge` shows `{projects.length}`; children = nested project list + drop zone. + +#### 4.7.5 `SidebarDropZone` + +- Container: `mx-3 rounded-md border border-dashed px-3 text-center text-xs text-muted-foreground` +- Spacing: `compact ? "my-1 py-1" : "my-2 py-2"` +- **isOver:** `border-foreground/30 bg-muted`; else `border-border` + +### 4.8 Footer — Settings + +| Element | Icon | Label | Action | +|---------|------|-------|--------| +| `SidebarMenuButton` | `GearSixIcon` | `m.settings()` → **"Settings"** | `openSettingsWindow()` (separate Tauri window) | +| Attribute | `data-sidebar-item` | — | Included in arrow-key nav | + +### 4.9 Width resize separator + +``` +div[role=separator] + aria-label="Resize sidebar" ← hardcoded English, not i18n + aria-orientation="vertical" + aria-valuemin=220 aria-valuemax=420 aria-valuenow={sidebarWidth} + tabIndex=0 + className: + absolute top-0 right-[-4px] bottom-0 w-2 cursor-col-resize + before: absolute vertical 1px line at center + hover:before:bg-border + focus-visible:outline-2 outline-offset-[-2px] outline-(--app-focus-ring) + dragging: before:bg-foreground/30 +``` + +Keyboard on separator: ArrowLeft/Right ±16 px, Home=min, End=max. + +### 4.10 Keyboard navigation (`handleKeyDown` on `Sidebar`) + +- **ArrowDown / ArrowUp:** Cycle focus among `[data-sidebar-item]` elements inside sidebar ref. +- Does not wrap parent containers; flat query of all items in DOM order (includes nested profile rows when expanded). + +### 4.11 Global shortcuts affecting sidebar (from `App.tsx`) + +| Shortcut | Action | +|----------|--------| +| Cmd/Ctrl+, | `openSettingsWindow()` (same as footer) | +| Cmd/Ctrl+Shift+D | Debug panel (unrelated) | + +### 4.12 Error toast + +Failed layout persist: `toast.error(sidebarOrderUpdateFailed)` with error message description. + +--- + +## 5. `ProjectMenuItem.tsx` + +One row per project in the app sidebar (normal mode). + +### 5.1 Tree + +``` +SidebarMenuItem.group/project-item +├── ContextMenu +│ ├── ContextMenuTrigger → SidebarMenuButton → NavLink +│ │ ├── ProjectAvatar +│ │ └── span.min-w-0.flex-1.truncate.font-medium → project.name +│ └── ContextMenuContent +│ ├── ProjectGroupMenu (submenu) +│ ├── projectSettings +│ ├── renameProject +│ ├── [separator] +│ └── deleteProject (destructive) +├── [branch A] hasOnlyDefaultProfile +│ └── Tooltip → SidebarMenuAction (create profile) +│ ├── PlusIcon (hidden until group-hover/group-focus-within) +│ └── AgentStatusDot (hidden on hover/focus-within when indicator present) +├── [branch B] multiple profiles +│ └── SidebarMenuAction (expand/collapse) +│ └── CaretDownIcon | CaretRightIcon +└── [if expanded && !hasOnlyDefaultProfile] + └── SidebarMenuSub + ├── SidebarMenuSubItem → default profile row + ├── ProfileList (non-default profiles) + └── (CreateProfileDialog only in branch A on action) +``` + +### 5.2 Project row button + +- **Link target:** `/projects/{id}/profiles/{defaultProfileId}` or `/projects/{id}` if no default profile +- **Attributes:** `data-project-id`, `data-testid="project-sidebar-item"`, `data-sidebar-item` +- **Active when:** `hasOnlyDefaultProfile && activeProfileId === defaultProfile.id` +- **Text:** `project.name`, truncated, `font-medium` + +### 5.3 Trailing action — single-profile projects + +When `nonDefaultProfiles.length === 0`: + +| State | Visible control | +|-------|-----------------| +| Default | `AgentStatusDot` if agent status/completion exists | +| `:hover` / `:focus-within` on `.group/project-item` | `PlusIcon` (`hidden group-hover/project-item:block group-focus-within/project-item:block`) | + +- **Tooltip:** side `right`, text `createProfile` → **"New Profile"** +- **aria-label:** `createProfile` +- Click/Enter/Space → `CreateProfileDialog` + +Agent indicator source: `useProfileAgentStatus(defaultProfileId) ?? useProfileAgentCompletion(defaultProfileId)`. + +### 5.4 Trailing action — multi-profile projects + +- **Icon:** `CaretDownIcon` (expanded) or `CaretRightIcon` (collapsed) +- **aria-label:** `toggleProjectGroup({ name: project.name })` → **"Toggle project group {name}"** +- **aria-expanded:** `expanded` (default expanded: `userExpanded ?? true`) +- Click toggles local `userExpanded` state + +### 5.5 Default profile sub-row (multi-profile only) + +``` +SidebarMenuSubButton → NavLink(defaultProfileUrl) +├── TerminalWindowIcon +├── OverflowTooltipText(displayValue=defaultProfile, tooltipValue=defaultProfile) +└── AgentStatusDot (if indicator) +``` + +- **Label i18n:** `defaultProfile` → **"Default"** +- **Active when:** `activeProfileId === defaultProfile.id` + +### 5.6 Context menu items + +| Item | i18n key | English | Opens | +|------|----------|---------|-------| +| Submenu | `addToProjectGroup` | Add to Project Group | Inline submenu | +| | `projectSettings` | Project Settings | `ProjectSettingsDialog` | +| | `renameProject` | Rename | `RenameProjectDialog` | +| Separator | — | — | — | +| Destructive | `deleteProject` | Delete Project | `DeleteProjectDialog` | + +--- + +## 6. `ProjectGroupSection.tsx` + +Folder row grouping projects under a `ProjectGroup`. + +### 6.1 Tree + +``` +SidebarMenuItem +├── SidebarMenuButton [type=button, data-sidebar-item] +│ ├── CaretRightIcon | CaretDownIcon +│ └── span → group.name +├── SidebarMenuBadge → projects.length +└── AnimatePresence + └── motion.div (height/opacity animation, overflow hidden) + └── SidebarMenuSub [mx-0 translate-x-0 gap-0 border-l-0 px-0 py-0] + └── ProjectMenuItem × projects +``` + +### 6.2 Collapse state + +- Stored in `useAppSidebarStore.collapsedProjectGroupIds` (persisted) +- `collapsed === true` → caret right, children not rendered +- **aria-expanded:** `!collapsed` +- **aria-label:** `toggleProjectGroup({ name: group.name })` +- Enter/Space on button toggles + +### 6.3 Animation + +- Duration **0.18** s, ease `[0.22, 1, 0.36, 1]` +- `prefers-reduced-motion`: no height/opacity animation (exit keeps opacity 1; enter skips initial) + +--- + +## 7. `ProjectGroupMenu.tsx` (context submenu) + +Rendered inside project context menu. + +### 7.1 Structure + +``` +ContextMenuSub +├── ContextMenuSubTrigger → addToProjectGroup (truncated) +└── ContextMenuSubContent.min-w-56 + ├── [if no groups] div.px-3.py-2.text-sm.text-muted-foreground → noProjectGroups + ├── [else] ContextMenuItem per group + │ ├── CheckIcon (opacity-0 if not current) + │ └── span.truncate → group.name + ├── [if project in group] separator + removeFromProjectGroup (XIcon) + ├── separator + └── create flow OR createProjectGroup item +``` + +### 7.2 Create group inline input + +When `isCreating || projectGroups.length === 0`: +- Wrapper: `px-2 py-1.5` +- `Input` placeholder `projectGroupNamePlaceholder` → **"e.g. Work"** +- **Enter:** submit create + assign +- **Escape:** cancel, clear name + +### 7.3 Pending/disabled + +- `disabled={isPending || isCurrent}` on group items +- Errors: toast `somethingWentWrong` + description + +--- + +## 8. `ProjectAvatar.tsx` + +| Setting | Behavior | +|---------|----------| +| `useSidebarSettingsStore.showProjectAvatars` | default **true**, persisted `"sidebar-settings"` | +| When false | Renders **null** (no placeholder gap in button — icon slot omitted) | +| When true | `span.grid.size-4.shrink-0.place-items-center.overflow-hidden.rounded-md.bg-sidebar-accent.text-sidebar-accent-foreground` | + +**Image path:** `useProjectAvatar(projectId)` when enabled. + +**Fallback:** first grapheme of trimmed `projectName` uppercased, or `"?"`; text `text-[0.625rem] leading-none font-medium` (**10 px**). + +**Image:** `img.size-full.object-cover`, `alt={projectName}`, onError → fallback letter. + +--- + +## 9. `ProfileList.tsx` + `ProfileItem.tsx` + +### 9.1 ProfileList + +Maps `profiles` (non-default only) to `ProfileItem`, then append: + +``` +SidebarMenuSubItem +└── SidebarMenuSubButton [button, data-sidebar-item] + ├── PlusIcon weight=regular + └── span → createProfile ("New Profile") +``` + +Opens `CreateProfileDialog` for `projectId`. + +### 9.2 ProfileItem + +``` +SidebarMenuSubItem +├── ContextMenu +│ ├── ContextMenuTrigger → SidebarMenuSubButton → NavLink +│ │ ├── GitBranchIcon +│ │ ├── OverflowTooltipText(branch_name) +│ │ └── AgentStatusDot? +│ └── ContextMenuContent +│ └── deleteProfile (destructive) +└── DeleteProfileDialog +``` + +- **Route:** `/projects/{projectId}/profiles/{profile.id}` +- **Active:** `isActive` prop +- **Label:** `profile.branch_name` (not i18n — raw git branch name) + +--- + +## 10. `AgentStatusDot.tsx` + +| Property | Value | +|----------|-------| +| Size | `size-2` → **8×8 px** circle | +| `aria-hidden` | `true` (decorative) | +| `data-agent-status` | `waiting` \| `running` \| `completed` | + +| Status | Visual | +|--------|--------| +| `waiting` | `bg-yellow-400` | +| `completed` | `bg-green-500` | +| `running` | `bg-emerald-400` + pulsing shadow; class `agent-status-dot--running` | + +**Running animation** (`app.css`): `agent-status-pulse` 1.4s ease-in-out infinite; disabled under `prefers-reduced-motion`. + +--- + +## 11. `SidebarLink.tsx` + +Used only for Home link when no projects. + +``` +SidebarMenuItem +└── SidebarMenuButton → NavLink + ├── {icon} + └── span → {children} +``` + +- **Active:** `useMatch(pattern ?? to)` +- **`data-sidebar-item`:** yes + +--- + +## 12. `OverflowTooltipText.tsx` + +Used on profile branch names and default profile label. + +- Display: `span.min-w-0.truncate` (+ passed `className`) +- Tooltip: only when measured text overflows (>0.5 px); side **top**, align **start** +- Tooltip content: `max-w-[min(480px,calc(100vw-32px))] break-all whitespace-normal` +- Prop `tooltipDisabled` suppresses tooltip (not used in sidebar profile rows) + +--- + +## 13. Loading and error fallbacks — `Fallbacks.tsx` + +### 13.1 `SidebarSkeleton` + +``` +aside.w-[250px].shrink-0.border-r.bg-muted/40.p-4 +└── div.flex.flex-col.gap-3 + ├── Skeleton h-6 w-full + ├── Skeleton mt-2 h-3 w-1/2 + └── Skeleton ml-5 h-5 w-3/4 × 3 +``` + +Fixed width **250 px** (matches default sidebar width, not dynamic store width). + +### 13.2 `SidebarError` + +Same shell as skeleton (`w-[250px] … p-4`) with centered `ErrorStack`: +- Title: `somethingWentWrong` → **"Something went wrong"** +- Message: `error.message` +- Button: `tryAgain` → **"Try again"** + +--- + +## 14. Profile sidebar — `ProfileSidebar.tsx` + +Secondary left panel inside project view; not the app sidebar, but part of left-nav UX. + +### 14.1 Placement + +``` +ProfileLayout +└── flex row + ├── ProfileSidebar (this) + └── main content +``` + +Controlled by `ProfileLayout` state: `fileTreeOpen` (default **true**), `sidebarMode` (`"files"` \| `"git"` \| `"notes"`). + +### 14.2 Outer wrapper + +``` +div.h-full.shrink-0 + pointerEvents: isOpen ? auto : none + aria-hidden: !isOpen +└── motion.div + animate width: isOpen ? panelWidth : 0 + spring transition (stiffness 320, damping 34, mass 0.9) unless reduced motion or dragging + className: relative flex h-full min-w-0 flex-col + overflow: visible +``` + +### 14.3 Mode panels + +| Mode | Visibility | Content wrapper | +|------|------------|-----------------| +| `files` | `display: block/none` on wrapper | `FileTreePanel` | +| `git` | conditional render | `SidebarPanelContent` → `SidebarGitPanel` | +| `notes` | conditional render | `SidebarPanelContent` → `ProfileNotesEditor` | + +`SidebarPanelContent`: `div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden.border-r` + `AsyncBoundary` (spinner / error). + +**Files tree stays mounted** when switching modes (`display:none` when not files). + +### 14.4 Resize separator (profile panel) + +``` +div[role=separator] + aria-label=profileSidebarResizeSeparator → "Resize sidebar" + aria-valuemin=180 aria-valuemax=560 aria-valuenow={panelWidth} + className: absolute top-0 -right-1 bottom-0 z-[1] w-2 cursor-col-resize + focus-visible:outline-2 -outline-offset-2 outline-[var(--app-focus-ring)] +``` + +Same keyboard resize behavior as app sidebar (±16 px, Home/End). + +--- + +## 15. `SidebarModeSwitch.tsx` (top bar control for profile sidebar) + +Located in `ProjectTopBar` left cluster — controls profile sidebar mode, not app sidebar. + +### 15.1 Structure + +``` +Tabs value={isOpen ? mode : null} +└── TabsList.h-7 + └── ×3 Tooltip + └── TabsTrigger.px-2 aria-label={label()} + ├── Icon className=size-3.5 (14px) + └── [git only] +{additions} text-green-500, -{deletions} text-red-500 +``` + +### 15.2 Tabs + +| value | Icon | i18n | English | +|-------|------|------|---------| +| `files` | `FolderSimpleIcon` | `sidebarFilesTab` | Files | +| `git` | `GitBranchIcon` | `sidebarGitTab` | Git | +| `notes` | `NoteIcon` | `notes` | Notes | + +**Git badge:** `useGitDiffStats(profileId, isActive)` when tab is git; shows `+N` / `-M` in `text-xs`. + +**Behavior (ProfileLayout):** Re-clicking active mode while open closes sidebar; switching mode opens sidebar. + +### 15.3 TabsList / TabsTrigger styling (from `tabs.tsx`) + +- List: `inline-flex … rounded-lg p-[3px] bg-muted`, height overridden to **28 px** (`h-7`) +- Trigger: `text-sm`, active gets `data-active:bg-background` + shadow + +--- + +## 16. Profile sidebar — Files mode (`FileTreePanel.tsx`) + +### 16.1 Container + +``` +div.flex.h-full.min-h-0.min-w-0.overflow-hidden +└── motion.div (opacity/x slide when isOpen) + └── div.relative.min-h-0.min-w-0.flex-1.border-r.px-1.5.py-1 + ├── FileTree (@pierre/trees) + context menus + ├── FileTreeRootContextMenu (positioned) + └── [error overlay] absolute inset-0 centered text-xs muted +``` + +### 16.2 File tree visual tokens (`FILE_TREE_HOST_STYLE`) + +| CSS variable override | Value | +|-----------------------|-------| +| `--trees-font-size-override` | **13px** | +| `--trees-border-radius-override` | **4px** | +| `--trees-level-gap-override` | **12px** | +| `--trees-item-margin-x-override` | **4px** | +| `--trees-item-padding-x-override` | **4px** | +| `--trees-padding-inline-override` | **4px** | +| Colors | map to `--muted`, `--muted-foreground`, `--foreground` | + +Density: `"compact"`, icons: `"complete"`, initial expansion: `"closed"`. + +### 16.3 Animation + +- Opacity 0→1, x -12→0 when opening; duration **0.18** s (same easing as project group) + +### 16.4 Context menus (sidebar-relevant) + +**Root menu items (i18n keys):** `fileTreeContextMenuNewFile`, `NewFolder`, `Refresh`, `RevealInFileManager`, `CopyRelativePath`, `CopyAbsolutePath` + +**Item menu adds:** Open, OpenInDefaultApp, Rename, Delete, etc. + +Popup: `min-w-40 rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10` + +### 16.5 Error state + +Absolute overlay, `pointer-events-none`, shows `getErrorMessage(treePathsError)` in `text-xs text-muted-foreground`. + +--- + +## 17. Profile sidebar — Git mode (`SidebarGitPanel.tsx`) + +Vertical stack: + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden +├── ChangesFileList (tooltipsDisabled, emptyMessage=noChangesDetected, onMaximize) +└── CommitComposer +``` + +Plus modal `GitDiffDialog` (not inline in panel). + +### 17.1 ChangesFileList header (sticky) + +``` +div.sticky.top-0.z-[1].flex.items-center.gap-2.border-b.bg-background.px-3.py-2.5 +├── Checkbox (include all/none) aria-label=gitCommitIncludeAll → "All" +├── p.text-xs.text-muted-foreground → changedFiles({ count }) +└── [optional] Button size=xs ghost ml-auto size-6 → ArrowsOutSimpleIcon (gitOpenDiffView) +``` + +**Empty state:** centered `p-6`, `text-xs text-muted-foreground` → **"No changes detected"** + +### 17.2 File row (`FileListItem`) + +``` +div.flex.w-full.items-start.gap-2.px-3.py-2 +├── Checkbox.mt-0.5 +├── OverflowTooltipText basename (text-sm, font-medium if active) +├── optional parent path (text-xs muted) +└── Badge size-4 (change type letter) +``` + +- **Active row:** `bg-muted` +- **Inactive hover:** `hover:bg-muted/70` +- **Excluded:** `opacity-70` +- **Double-click:** opens `GitDiffDialog` focused on file +- **Context menu:** discard action (`gitDiscardFileAction`) + +Context menu width constant: **200** px. + +### 17.3 CommitComposer footer + +``` +div.shrink-0.border-t.px-2.5.py-2 +├── p.text-xs.font-medium.uppercase.text-muted-foreground → gitCommitSectionTitle ("Commit") +├── Field: gitCommitSummary + Input h-7 text-xs +├── Field: gitCommitBody + Textarea min-h-[4.5rem] text-xs +└── flex justify-end gap-2 + ├── [if no files] Push button (UploadSimpleIcon, optional ahead count) + └── [else] Commit button (Spinner when pending) +``` + +**Commit shortcut:** Cmd/Ctrl+Enter when `canSubmit`. + +--- + +## 18. Profile sidebar — Notes mode + +`ProfileNotesEditor` → `MarkdownEditor` full flex area inside `SidebarPanelContent`. + +| UI element | i18n | +|------------|------| +| Placeholder | `notesPlaceholder` | +| Save failed toast | `notesSaveFailedTitle` | +| Editor save status labels | `notesSaved`, `notesSaveFailedShort` (in MarkdownEditor) | + +--- + +## 19. `WindowControls.tsx` (Windows platform) + +Not part of sidebar column but affects top-bar padding when app sidebar collapsed. + +``` +div.fixed.top-0.right-0.flex.h-7 [data-window-controls] +├── Minimize (MinusIcon 12px) aria-label="Minimize" +├── Maximize/Restore (SquareIcon | CopySimpleIcon 12px) +└── Close (XIcon 12px) aria-label="Close" +``` + +| Button | Size | Hover | +|--------|------|-------| +| Minimize / Maximize | `h-7 w-9` (28×36 px) | `hover:bg-muted` | +| Close | same | `hover:bg-[#c42b1c] hover:text-white` | + +`[-webkit-app-region:no-drag]` on buttons. + +`ProjectTopBar` adds `pr-[118px]` on Windows to clear controls. + +--- + +## 20. Platform differences + +### 20.1 macOS + +| Location | Adjustment | +|----------|------------| +| `AppSidebar` `SidebarHeader` | `pt-8` (**32 px**) — comment: clear traffic lights at y:26+12px | +| `ProjectTopBar` | When app sidebar collapsed: `pl-[84px]` to clear traffic lights | +| Window controls | Native overlay (not `WindowControls` component) | +| Header drag | `data-tauri-drag-region` on sidebar header + top bar | + +### 20.2 Windows + +| Location | Adjustment | +|----------|------------| +| `AppSidebar` header | `pt-2` (**8 px**) | +| `WindowControls` | Rendered fixed top-right | +| `ProjectTopBar` | `pr-[118px]` for custom window buttons | + +### 20.3 Linux / other + +Same as Windows for header padding (`pt-2`); no `WindowControls` unless Windows platform check passes. + +--- + +## 21. Keyboard shortcuts summary (sidebar-related) + +| Context | Shortcut | Action | +|---------|----------|--------| +| App | Cmd/Ctrl+, | Open settings window | +| App sidebar | ArrowUp/Down | Focus prev/next `[data-sidebar-item]` | +| App sidebar resize | ArrowLeft/Right | ±16 px width | +| App sidebar resize | Home / End | Min / max width | +| ProjectTopBar (active profile) | Cmd/Ctrl+E | Toggle profile sidebar open/closed | +| ProjectTopBar (active profile) | Cmd/Ctrl+G | Open git diff dialog (not sidebar panel) | +| Profile panel resize | ArrowLeft/Right, Home, End | Same as app sidebar | +| Commit composer | Cmd/Ctrl+Enter | Commit when valid | +| Project group menu input | Enter / Escape | Create / cancel | +| shadcn SidebarProvider | Cmd/Ctrl+B | Toggle shadcn sidebar — **not wired** for AppSidebar collapse | + +--- + +## 22. Persisted client state + +| Store | Key | Fields | +|-------|-----|--------| +| `useAppSidebarStore` | `app-sidebar-width` | `width`, `isCollapsed`, `collapsedProjectGroupIds` | +| `useSidebarSettingsStore` | `sidebar-settings` | `showProjectAvatars` (default true) | +| Profile panel width | `file-tree-panel` | `{ state: { panelWidth }, version: 2 }` | + +--- + +## 23. Complete i18n key → English map (sidebar scope) + +| Key | English text | +|-----|--------------| +| `home` | Home | +| `sideNavLabel` | Side navigation | +| `collapseSidebar` | Collapse sidebar | +| `expandSidebar` | Expand sidebar | +| `settings` | Settings | +| `pinnedProjects` | Pinned | +| `sidebarProjectsSection` | Projects | +| `editProjectOrder` | Edit project order | +| `doneEditingProjectOrder` | Done editing project order | +| `newProject` | New Project | +| `pinProject` | Pin project | +| `unpinProject` | Unpin project | +| `dropHereToPin` | Drop here to pin | +| `dropProjectIntoFolder` | Drop project into folder | +| `dropHereToUnpinOrMoveOut` | Drop here to unpin or move out | +| `sidebarOrderUpdateFailed` | Failed to update sidebar order | +| `toggleProjectGroup` | Toggle project group {name} | +| `defaultProfile` | Default | +| `createProfile` | New Profile | +| `projectSettings` | Project Settings | +| `renameProject` | Rename | +| `deleteProject` | Delete Project | +| `deleteProfile` | Delete Profile | +| `addToProjectGroup` | Add to Project Group | +| `noProjectGroups` | No project groups yet. | +| `removeFromProjectGroup` | Remove from Project Group | +| `projectGroupNamePlaceholder` | e.g. Work | +| `createProjectGroup` | Create Project Group | +| `somethingWentWrong` | Something went wrong | +| `tryAgain` | Try again | +| `sidebarFilesTab` | Files | +| `sidebarGitTab` | Git | +| `notes` | Notes | +| `profileSidebarResizeSeparator` | Resize sidebar | +| `noChangesDetected` | No changes detected | +| `changedFiles` | {count} changed file(s) | +| `gitOpenDiffView` | Open diff view | +| `gitCommitIncludeAll` | All | +| `gitCommitSectionTitle` | Commit | +| `gitCommitSummary` | Summary | +| `gitCommitSummaryPlaceholder` | Describe the changes you're committing | +| `gitCommitBody` | Description | +| `gitCommitBodyPlaceholder` | Add an optional extended description | +| `gitCommitButton` | Commit | +| `gitPushButton` | Push | +| `gitDiscardFileAction` | Discard changes to this file | +| `notesPlaceholder` | (see messages/en.json) | +| `notesSaveFailedTitle` | Notes save failed | + +**Hardcoded non-i18n strings in sidebar UI:** `"2Code"` (brand), resize separator `aria-label="Resize sidebar"` on app sidebar, Windows control labels `"Minimize"`, `"Maximize"`, `"Restore"`, `"Close"`. + +--- + +## 24. Dialogs and menus launched from sidebar surfaces + +| Trigger location | UI | Component | +|------------------|-----|-----------| +| Projects `+` | New project | `CreateProjectDialog` | +| Project context menu | Settings / Rename / Delete | respective dialogs | +| Project context submenu | Group assign/create | inline + API | +| Single-profile `+` action | New profile | `CreateProfileDialog` | +| ProfileList footer | New profile | `CreateProfileDialog` | +| Profile context menu | Delete profile | `DeleteProfileDialog` | +| Settings footer | Settings | `openSettingsWindow()` | +| Git panel maximize | Full diff | `GitDiffDialog` | +| File tree context menus | File operations | `@pierre/trees` + dropdown items | + +--- + +## 25. Interactive state matrix (app sidebar) + +| State | Visual evidence in code | +|-------|-------------------------| +| Nav item hover | `SidebarMenuButton` / `SubButton` accent background | +| Nav item active/route | `data-active` / `isActive` → accent bg + medium font (project name) | +| Project row hover (single profile) | Swap agent dot ↔ plus icon | +| Reorder dragging | opacity **0.45**, optional `bg-sidebar-accent` on button | +| Drop zone hover | dashed border `border-foreground/30`, `bg-muted` | +| Reorder saving | actions `disabled={isSidebarLayoutSaving}` | +| Group collapsed | caret right, animated height 0 | +| Project expanded (multi) | caret down + `SidebarMenuSub` visible | +| Sidebar collapsed | entire `AppSidebar` unmounted | +| Loading projects | `SidebarSkeleton` 250 px | +| Load error | `SidebarError` with retry | + +--- + +## 26. Data model driving layout (`sidebarLayout.ts`) + +**Pinned:** projects with `pinned_order != null`, sorted by pinned_order. + +**Top entries:** merge of `ProjectGroup` entries (with nested grouped projects) and ungrouped top-level projects; sorted by `sort_order` / `created_at`. + +**Reorder persistence:** `createSidebarLayoutUpdates` emits sort orders in steps of **1000** (`SIDEBAR_ORDER_STEP`). + +Drop zone IDs: +- `sidebar-drop:pinned` +- `sidebar-drop:top-level` +- `sidebar-drop:group:{groupId}` + +Entry IDs: `project:{id}`, `group:{id}`. + +--- + +## 27. Parent → child index (quick reference) + +``` +AppSidebar +├── Header: brand + collapse +├── Content +│ ├── [empty] Home link +│ ├── [normal] Pinned → ProjectMenuItem* +│ ├── [normal] Projects → ProjectGroupSection* | ProjectMenuItem* +│ └── [reorder] Pinned DnD + Projects DnD +├── Footer: Settings +└── Resize handle + +ProjectMenuItem +├── Project row (avatar, name, context menu) +├── Action (plus+dot | caret) +└── Sub: Default row + ProfileList + dialogs + +ProjectGroupSection +├── Group row (caret, name, badge) +└── Sub: ProjectMenuItem* + +ProfileSidebar +├── FileTreePanel | SidebarGitPanel | ProfileNotesEditor +└── Resize handle + +ProjectTopBar (when app sidebar collapsed) +└── Expand sidebar button (SidebarSimpleIcon) +``` + +This document is intended as the single source of truth for reimplementing the sidebar UI in a non-React/non-web stack while preserving layout, spacing, states, and behavior. diff --git a/docs/ui-inventory-home-project.md b/docs/ui-inventory-home-project.md new file mode 100644 index 00000000..c9c01dd2 --- /dev/null +++ b/docs/ui-inventory-home-project.md @@ -0,0 +1,948 @@ +# 2code UI Inventory — Home, Project Detail, Profile Layout, File Tree/Viewer, Notes, Dialogs + +Framework-agnostic inventory derived from source files listed in the exploration task. Numeric sizes and class names are taken directly from code. English labels reference `messages/en.json` keys and resolved text. + +--- + +## 1. Global routing context + +| Route | Page component | Notes | +|-------|----------------|-------| +| `/` | `HomePage` | Auto-redirects to first project's default profile when projects exist | +| `/projects/:id/profiles/:profileId` | `ProjectDetailPage` + persistent `TerminalLayer` overlay | Invalid/missing profile redirects to default profile or `/` | + +**Project detail rendering split:** + +- **`TerminalLayer`** (absolute overlay, `inset: 0`, `display: flex/none` per active profile): renders `ProfileLayout` + `TerminalTabs` for every profile that has open terminal or file tabs. +- **`ProjectDetailPage`**: only mounts `ProfileLayout` + `TerminalTabs` when **both** terminal tabs and file tabs are empty (`shouldRenderEmptyState`), showing the empty-terminal CTA. Once any tab opens, `TerminalLayer` owns the view; `ProjectDetailPage` renders `null`. + +--- + +## 2. Home Page (`HomePage.tsx`) + +### 2.1 Component tree + +``` +HomePage (root: div.h-full) +├── header (data-tauri-drag-region) +│ ├── FolderIcon (Phosphor, className: size-4 text-muted-foreground) +│ └── h1 (select-none text-sm font-semibold) → i18n: home ("Home") +├── [conditional: hasNoProjects] +│ └── div (flex h-[calc(100%-52px)] items-center justify-center) +│ └── Empty +│ └── EmptyHeader +│ ├── EmptyMedia variant="icon" → FolderPlusIcon +│ ├── EmptyTitle → emptyProjectsTitle ("No projects yet") +│ └── EmptyDescription → emptyProjectsDesc +└── TourOnboarding (renders null; driver.js overlay when enabled) +``` + +### 2.2 Layout regions + +| Region | Dimensions / classes | Behavior | +|--------|---------------------|----------| +| Page root | `h-full` | Fills main content area beside app sidebar | +| Header bar | `h-[52px]`, `border-b`, `px-5`, `flex items-center gap-2` | macOS draggable title region | +| Empty state area | `h-[calc(100%-52px)]`, centered flex | Only when `projects.length === 0` | +| Content when projects exist | Header only visible briefly | `useEffect` navigates to `/projects/{id}/profiles/{defaultProfileId}` with `replace: true` | + +### 2.3 Interactive states + +| State | UI | +|-------|-----| +| No projects | Centered empty state with folder-plus icon | +| Has projects | Immediate redirect; user rarely sees Home content except header flash | +| Onboarding tour active | driver.js popover anchored to `#add-project-button` in app sidebar (not in HomePage DOM) | + +### 2.4 TourOnboarding (`TourOnboarding.tsx`) + +- **Trigger:** `isEnabled={hasNoProjects}` on HomePage. +- **Library:** driver.js (`driver-popover-theme` class). +- **Single step:** targets `#add-project-button` (in `AppSidebar.tsx`). +- **Popover:** side `right`, align `start`; title `onboardingTourTitle`, description `onboardingTourDesc`; buttons: close only. +- **Dismiss:** clicking add-project button destroys tour. +- **Delay:** 300ms before `drive()`. +- **Renders:** `null` (no visible React output). + +### 2.5 Keyboard shortcuts + +None defined on HomePage. + +--- + +## 3. Project Detail Page (`ProjectDetailPage.tsx`) + +### 3.1 Component tree (empty state path) + +``` +ProjectDetailPage +└── [when !hasTabs && !hasFileTabs] + └── ProfileLayout + └── TerminalTabs + ├── Tab bar (empty) + └── emptyFallback → emptyTerminalState + └── Empty (centered) + ├── EmptyHeader + │ ├── EmptyMedia icon → TerminalWindowIcon + │ ├── EmptyTitle → noTerminalsOpen + │ └── EmptyDescription → noTerminalsOpenDescription + └── EmptyContent + └── div.flex + ├── Button (New Terminal) [+ PlusIcon, newTerminal] + └── [optional] DropdownMenu (template picker) + ├── DropdownMenuTrigger → Button size="icon" [-ml-px rounded-l-none, aria-label="Choose template"] + │ └── CaretDownIcon + └── DropdownMenuContent (min-w-56 p-1) + └── TerminalTemplateDropdownContent +``` + +### 3.2 Redirect logic (no visible UI) + +- Missing project → `` +- Missing profile → redirect to default profile or first profile, else `/` + +### 3.3 Empty terminal CTA buttons + +| Control | Label (i18n) | Classes / props | Disabled when | +|---------|--------------|-----------------|---------------| +| Primary | `newTerminal` ("New Terminal") | `Button`; if templates exist: `rounded-r-none` | `createTab.isPending` | +| Template split | (icon only) | `Button` `size="icon"`, `-ml-px rounded-l-none`, `aria-label="Choose template"` | same | + +### 3.4 Keyboard shortcuts (via `TerminalLayer`, active profile) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+T` | Create new terminal tab | +| `Cmd/Ctrl+W` | Close active terminal tab | + +--- + +## 4. Profile Layout (`ProfileLayout.tsx`) + +### 4.1 Component tree + +``` +ProfileLayout (div.flex.h-full.flex-col) +├── CommandPalette (modal, always mounted) +├── div.border-b +│ └── ProjectTopBar +└── div.flex.min-h-0.min-w-0.flex-1 + ├── ProfileSidebar + └── div.min-h-0.min-w-0.flex-1 + └── {children} → TerminalTabs +``` + +### 4.2 Local state + +| State | Default | Purpose | +|-------|---------|---------| +| `fileTreeOpen` | `true` | Sidebar panel visible | +| `sidebarMode` | `"files"` | `"files" \| "git" \| "notes"` | + +### 4.3 Sidebar toggle behavior + +- **`onToggleFileTree`:** flips `fileTreeOpen`. +- **`onSidebarModeChange(mode)`:** + - If sidebar open AND same mode clicked → close sidebar. + - Else set mode and open sidebar. + +### 4.4 File open handler + +`openFileTab(profile.id, filePath)` → `useFileViewerTabsStore.openFile`. + +--- + +## 5. Profile Sidebar (`ProfileSidebar.tsx`) + +### 5.1 Component tree + +``` +ProfileSidebar (div.h-full.shrink-0) +└── motion.div (animated width) + ├── [files mode, display block/none] FileTreePanel + ├── [mode === git] SidebarPanelContent → SidebarGitPanel + ├── [mode === notes] SidebarPanelContent + │ └── div → ProfileNotesEditor (lazy) + └── [isOpen] resize separator (role="separator") +``` + +### 5.2 Width & animation constants + +| Constant | Value | +|----------|-------| +| `SIDEBAR_PANEL_MIN_WIDTH` | 180px | +| `SIDEBAR_PANEL_MAX_WIDTH` | 560px | +| `DEFAULT_SIDEBAR_PANEL_WIDTH` | 208px | +| `SIDEBAR_PANEL_STORAGE_KEY` | `"file-tree-panel"` (localStorage JSON `{ state: { panelWidth }, version: 2 }`) | +| Spring transition | stiffness 320, damping 34, mass 0.9 | +| Closed width | 0 (animate) | +| Reduced motion | `{ duration: 0 }` | + +### 5.3 Resize handle + +| Property | Value | +|----------|-------| +| `aria-label` | `profileSidebarResizeSeparator` ("Resize sidebar") | +| `aria-orientation` | `vertical` | +| `aria-valuemin/max/now` | 180 / 560 / current width | +| `tabIndex` | 0 | +| Classes | `absolute top-0 -right-1 bottom-0 z-[1] w-2 cursor-col-resize focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--app-focus-ring)]` | +| Pointer | `useHorizontalResize` drag | +| Keyboard | ArrowLeft/Right ±16px; Home=min; End=max | + +### 5.4 Pointer events when closed + +- Container: `pointerEvents: none`, `aria-hidden={true}` when closed. + +### 5.5 SidebarPanelContent wrapper + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden.border-r +└── AsyncBoundary + ├── fallback: LoadingSpinner + ├── error: LoadingError + └── children +``` + +### 5.6 Git mode panel (`SidebarGitPanel` — child, not in read list but mounted here) + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden +├── ChangesFileList (scrollable file list + commit inclusion) +└── CommitComposer (summary, body, commit/push) +``` + +**ChangesFileList header (sticky):** + +- Master checkbox (include all/none) → `gitCommitIncludeAll` aria-label +- Text: `changedFiles({ count })` +- Optional maximize button → `gitOpenDiffView`, `ArrowsOutSimpleIcon`, `size-6` +- Empty: `noChangesDetected` + +**Keyboard:** none specific to sidebar git panel. + +--- + +## 6. Sidebar Mode Switch (`SidebarModeSwitch.tsx`) + +### 6.1 Component tree + +``` +Tabs (value = isOpen ? mode : null) +└── TabsList (h-7) + └── [for each mode: files, git, notes] + └── Tooltip + └── TooltipTrigger → TabsTrigger (px-2, aria-label=label) + ├── Icon (size-3.5): FolderSimpleIcon | GitBranchIcon | NoteIcon + └── [git only, if diffStats] +{additions} (text-green-500) -{deletions} (text-red-500) +``` + +### 6.2 Mode labels (i18n) + +| Mode | Key | English | +|------|-----|---------| +| files | `sidebarFilesTab` | Files | +| git | `sidebarGitTab` | Git | +| notes | `notes` | Notes | + +### 6.3 Interaction + +- Click tab → `onModeChange(value)` (also wired to Tabs `onValueChange`). +- Git tab shows live diff stat badges when `useGitDiffStats(profileId, isActive)` returns data. +- When sidebar closed, `Tabs` value is `null` (no tab appears selected). + +--- + +## 7. File Tree Panel (`FileTreePanel.tsx`) + +### 7.1 Component tree + +``` +FileTreePanel (div.flex.h-full.min-h-0.min-w-0.overflow-hidden) +└── motion.div (opacity/x slide when isOpen) + └── div.relative.min-h-0.min-w-0.flex-1.border-r.px-1.5.py-1 + ├── FileTree (@pierre/trees) + │ └── renderContextMenu → FileTreeContextMenu + ├── [rootContextMenu] FileTreeRootContextMenu + └── [isTreePathsError] error overlay (absolute inset-0, pointer-events-none) + └── p.text-xs.text-muted-foreground (error message) +``` + +### 7.2 Pierre FileTree configuration + +| Option | Value | +|--------|-------| +| `density` | `"compact"` | +| `flattenEmptyDirectories` | `false` | +| `icons` | `"complete"` | +| `initialExpansion` | `"closed"` | +| `stickyFolders` | `true` | +| Drag-and-drop | move within tree; also terminal drop payload | +| Renaming | inline; draft create uses `startRenaming(..., { removeIfCanceled: true })` | + +### 7.3 CSS variables (`FILE_TREE_HOST_STYLE`) + +| Variable | Value | +|----------|-------| +| `--trees-font-size-override` | `13px` | +| `--trees-level-gap-override` | `12px` | +| `--trees-border-radius-override` | `4px` | +| `--trees-item-margin-x-override` | `4px` | +| `--trees-item-padding-x-override` | `4px` | +| `--trees-padding-inline-override` | `4px` | +| `--trees-bg-override` | `transparent` | +| `--trees-selected-bg-override` | `var(--muted)` | +| Host padding | `px-1.5 py-1` on wrapper | + +### 7.4 Animation + +| Property | Open | Closed | +|----------|------|--------| +| opacity | 1 | 0 | +| x | 0 | -12 | +| duration | 0.18s ease `[0.22, 1, 0.36, 1]` | same / 0 if reduced motion | + +### 7.5 Tree item interaction + +| Action | Behavior | +|--------|----------| +| Click file | Opens in file viewer tab (`onOpenFile`) | +| Click directory | Expand/collapse | +| Selection change (keyboard) | Opens single selected file unless suppressed | +| MouseDown | Suppresses auto-open on selection (multi-select) | +| Meta/Ctrl/Shift click | Does not auto-open on selection | +| Drag start | Writes terminal drop payload; suppresses click-open 500ms | +| KeyUp on focused dir | Sync expand/collapse state | + +**Draft create names:** `"New File"`, `"New Folder"` (English hardcoded in `FILE_TREE_CREATE_NAMES`). + +### 7.6 Context menu — item (`FileTreeContextMenu`) + +Rendered via `FileTreeMenu` → Base UI Menu portal. + +| Item | i18n key | Enabled when | +|------|----------|--------------| +| Open | `fileTreeContextMenuOpen` | file kind + in filePathSet | +| Open in Default App | `fileTreeContextMenuOpenInDefaultApp` | path deletable/exists | +| Reveal in Finder | `fileTreeContextMenuRevealInFileManager` | same | +| — separator — | | | +| Refresh | `fileTreeContextMenuRefresh` | !isRefreshing | +| — separator — | | | +| New File | `fileTreeContextMenuNewFile` | always | +| New Folder | `fileTreeContextMenuNewFolder` | always | +| Rename | `rename` | single path in tree | +| — separator — | | | +| Copy Relative Path | `fileTreeContextMenuCopyRelativePath` | always | +| Copy Absolute Path | `fileTreeContextMenuCopyAbsolutePath` | always | +| — separator — | | | +| Delete | `delete` | all selected deletable; variant destructive | + +**Popup classes:** `z-50 max-h-[var(--available-height)] min-w-40 overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10`. + +### 7.7 Context menu — empty/root area (`FileTreeRootContextMenu`) + +| Item | i18n | +|------|------| +| New File | `fileTreeContextMenuNewFile` | +| New Folder | `fileTreeContextMenuNewFolder` | +| Refresh | `fileTreeContextMenuRefresh` | +| Reveal in Finder | `fileTreeContextMenuRevealInFileManager` | +| Copy Relative Path (`.`) | `fileTreeContextMenuCopyRelativePath` | +| Copy Absolute Path (root) | `fileTreeContextMenuCopyAbsolutePath` | + +### 7.8 Loading / error states + +| State | UI | +|-------|-----| +| Tree paths loading | Pierre tree empty until data (no explicit spinner in panel) | +| Tree paths error | Centered overlay text: error message, `text-xs text-muted-foreground` | +| Create failure toast | `fileTreeCreateErrorTitle` + description | +| Delete failure toast | `fileTreeDeleteErrorTitle` + description | +| Refresh failure toast | `somethingWentWrong` | + +### 7.9 Git status integration + +- Status-only paths appear in tree (including deleted, ignored). +- Deleted files: visible but not openable on click. +- Status badges rendered by Pierre model via `setGitStatus`. + +### 7.10 Query gating + +When `!isOpen || !isActive`: file tree and git status queries disabled. + +--- + +## 8. File Viewer (`FileViewerPane.tsx` + tab integration in `TerminalTabs.tsx`) + +### 8.1 Tab bar integration + +File tabs share the horizontal tab strip with terminal tabs. + +**File tab trigger (`TabsTrigger`):** + +``` +TabsTrigger (max-w-56 flex-none justify-start, nativeButton=false, render=
) +├── FileTreeFileIcon (size=14) +├── span.truncate (filename title) +├── [if dirty] span.size-2.rounded-full.bg-muted-foreground (unsaved dot) +└── TabCloseButton (XIcon size-3, aria-label="Close {title}") +``` + +**Terminal tab trigger:** same layout class; terminal/agent icon; optional `AgentStatusDot`; close button. + +**Trailing control:** `TerminalTemplateMenu` (+ new terminal / templates dropdown). + +**Tab strip container:** `flex w-full min-w-0 items-center overflow-x-auto overflow-y-hidden border-b p-0`. + +### 8.2 FileViewerPane decision tree + +``` +FileViewerPane +├── [previewableBinaryFile] → FilePreviewPane +├── [loading && !hasLoadedFile] → Spinner in h-32 center +├── [error && !hasLoadedFile] → error text h-32 center +├── [!hasLoadedFile] → null +├── [.md/.mdx] → MarkdownEditor +└── else → Monaco Editor +``` + +### 8.3 Monaco editor pane + +| Property | Value | +|----------|-------| +| Container | `div.h-full.min-h-0.overflow-hidden` ref for save focus | +| Height | `100%` | +| Theme | `light` or `vs-dark` from terminal theme id | +| Font | terminal settings `fontFamily`, `fontSize` | +| Options | minimap off; padding top/bottom 12; ligatures; wordWrap off; no TS/JS validation | +| Loading | centered Spinner | +| Unsaved | tracked in `fileViewerDirtyStore`; 400ms draft debounce (`DRAFT_SYNC_DELAY_MS`) | + +### 8.4 Markdown file pane + +Uses `MarkdownEditor` with `editorKey={filePath}`, `saveStatus` from save mutation (`saving` \| `idle`). + +### 8.5 FilePreviewPane (binary/archive/image/pdf) + +**Header:** `min-h-9`, `border-b`, `bg-muted`, `px-3` + +| Element | Content | +|---------|---------| +| Left | filename, `truncate text-sm font-medium` | +| Right | `"Office Preview"` if kind `office-pdf`, else hardcoded `"Preview"` (not i18n) | + +**Archive:** `ArchivePreviewTree` instead of generic preview. + +**Image:** checkerboard background grid, `object-fit: contain`, max dimensions 100%. + +**PDF:** full-size iframe, white background, border 0. + +**Loading:** Spinner centered. + +**Error:** `text-sm text-muted-foreground` centered, max-w-lg. + +**Unavailable:** hardcoded `"Preview unavailable"`. + +**Previewable extensions:** images (png, jpg, …), pdf, office docs, archives (.zip, .tar, .tar.gz, .tgz, .gz). + +### 8.6 ArchivePreviewTree + +``` +div.flex.h-full.min-h-0.flex-col.overflow-hidden +├── header (min-h-9, border-b, bg-muted, px-3) +│ ├── filename (truncate text-sm font-medium) +│ └── "{fileCount} files / {directoryCount} folders" (hardcoded English) +└── FileTree (read-only, initialExpansion open, same 13px tree styles) +``` + +### 8.7 Terminal vs file content visibility + +- File viewer: conditionally rendered when `fileTabActive && activeFilePath`. +- Terminals: **never unmounted**; parent `display: none` when file tab active; each terminal `visibility hidden` except active tab. + +### 8.8 Unsaved close dialog (`UnsavedFileCloseDialog`) + +**Trigger:** closing file tab while path in `dirtyFilePathSet`. + +| Element | i18n | +|---------|------| +| Title | `closeUnsavedFileTitle` + WarningCircleIcon | +| Body | `closeUnsavedFileDescription({ file })` | +| Cancel | `cancel` | +| Discard | `discardChanges` (destructive) | + +### 8.9 Keyboard shortcuts (file editor) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+S` | Save (window listener + Monaco command) | + +--- + +## 9. Markdown Editor (`MarkdownEditor.tsx`) + +Used by profile notes and `.md`/`.mdx` file tabs. + +### 9.1 Component tree + +``` +MarkdownEditor (div.milkdown-wrapper.h-full.overflow-hidden) +└── MilkdownProvider + └── MilkdownEditor (div.flex.h-full.min-h-0.flex-col) + ├── MarkdownToolbar + └── div.min-h-0.flex-1.overflow-y-auto.p-4 + └── Milkdown (ProseMirror) +``` + +### 9.2 Toolbar layout (`MarkdownToolbar`) + +Container: `flex shrink-0 items-center gap-1 overflow-x-auto border-b bg-background px-2 py-1`. + +**Sections left → right:** + +1. **Command menu dropdown** (`notesCommandMenu`, ListIcon) — block types, quote, code block, table, divider. +2. **Separator** vertical `h-5`. +3. **Inline formatting buttons** (ghost icon-xs, active: `bg-muted text-foreground`): + - Bold (`notesFormatBold` + hint `⌘B`) + - Italic (`notesFormatItalic` + `⌘I`) + - Code (`notesFormatCode` + `⌘E`) + - Strike (`notesFormatStrike`, renders "S" with line-through) + - Link (`notesFormatLink`) +4. **Separator** +5. **List/quote buttons:** bullet list, ordered list (shows "1."), quote. +6. **Separator** +7. **Table menu** (`notesTableMenu`, TableIcon): insert table, add row/col, delete cells. +8. **Link editor inline** (when open): Input placeholder `https://`, apply (CheckIcon), remove (XIcon). +9. **SaveStatusIndicator** (flex-1 justify-end): Badge with Saving/Saved/Failed. + +### 9.3 Save status badge + +| Status | Icon | Label | Badge variant | +|--------|------|-------|---------------| +| saving | FloppyDiskIcon | `notesSaving` | secondary | +| saved | CheckIcon | `notesSaved` | default | +| failed | WarningCircleIcon | `notesSaveFailedShort` | destructive | +| idle | (spacer flex-1) | — | — | + +### 9.4 Slash command menu (typing `/`) + +Floating menu class `markdown-editor-slash-menu` with items: Paragraph, H1–H3, bullet/ordered list, quote, code block, table (3×3), divider. + +### 9.5 Placeholder + +CSS `data-placeholder` on empty paragraph; default i18n `notesPlaceholder` ("Write notes for this profile…") or prop override. + +### 9.6 Auto-save timing + +- Markdown change debounce: **650ms** before `onMarkdownChange`. +- Profile notes: saves via API on change (see ProfileNotesEditor). + +### 9.7 Keyboard + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+S` | `onRequestSave` when pane focused (file tabs); notes editor omits handler unless passed | + +--- + +## 10. Profile Notes (`ProfileNotesEditor.tsx`) + +### 10.1 Component tree + +``` +ProfileNotesEditor +└── MarkdownEditor + editorKey={profile.id} + initialMarkdown={profile.notes} + placeholder={notesPlaceholder} + saveStatus={saveStatus from local state} +``` + +### 10.2 Save behavior + +- On markdown change (after editor debounce): calls `useUpdateProfileNotes` mutation. +- Status flow: `idle` → `saving` → `saved` (1.6s) → `idle`, or `failed` on error toast `notesSaveFailedTitle`. +- Skips save if markdown equals last saved. + +### 10.3 Layout + +Fills sidebar panel: `min-h-0 flex-1 overflow-hidden` inside `SidebarPanelContent`. + +--- + +## 11. Command Palette (`CommandPalette.tsx`) + +### 11.1 Component tree + +``` +Command.Dialog (cmdk) +├── [header] div.border-b.px-4.py-3 +│ └── Command.Input (placeholder, aria-label) +├── Command.List (max-h-[60vh] overflow-y-auto p-1) +│ ├── [error] CommandPaletteStatusMessage +│ ├── [empty] CommandPaletteEmptyState +│ └── CommandPaletteResultList +│ └── CommandPaletteResultItem × N +│ ├── FileTreeFileIcon (16) +│ ├── name (truncate text-sm) +│ └── parent path (truncate text-xs muted) +``` + +### 11.2 CSS (`app.css`) + +| Class | Styles | +|-------|--------| +| `project-command-palette__overlay` | fixed inset-0, z-index 1400, rgba(0,0,0,0.4) | +| `project-command-palette__dialog` | fixed, top 72px, centered, padding-inline 12px, z-index 1401 | +| `project-command-palette__root` | width min(100%, 40rem), max-height calc(100vh - 96px), border-radius 8px, popover background | + +### 11.3 Result item + +Classes: `flex min-w-0 select-none items-center gap-2 rounded px-3 py-2 data-[selected=true]:bg-muted`. + +### 11.4 Empty / error messages + +| Condition | Message | +|-----------|---------| +| No search, no results | `commandPaletteEmpty` | +| Search, no results | `commandPaletteNoResults` | +| API error, no cached results | error text via `getErrorMessage` | + +### 11.5 Keyboard + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+K` | Open palette (capture phase, active profile only) | +| Arrow keys | Navigate results (cmdk `loop`) | +| Enter | Open selected file, close palette | +| Escape | Close (via onOpenChange) | + +**Note:** i18n keys `commandPaletteHint`, `commandPaletteOpenHint`, `commandPaletteFooterHint`, `commandPaletteResultCount` exist in en.json but are **not rendered** in current CommandPalette UI. + +--- + +## 12. Project Top Bar (`ProjectTopBar.tsx`) + +### 12.1 Component tree + +``` +ProjectTopBar +├── div[data-tauri-drag-region] (relative flex min-h-[44px] items-end justify-between px-4 pt-1 pb-1.5) +│ ├── leftContent +│ ├── titleContent (absolute centered) +│ └── controlsContent +├── ProjectSettingsDialog +├── SwitchBranchDialog +└── GitDiffDialog (branch-aware for default profile) +``` + +### 12.2 Bar padding (platform) + +| Platform / state | Class | +|------------------|-------| +| Windows | `pr-[118px]` | +| Default | `pr-5` | +| macOS + collapsed app sidebar | `pl-[84px]` | + +### 12.3 Left region + +| Element | When | Details | +|---------|------|---------| +| Expand sidebar button | App sidebar collapsed | `SidebarSimpleIcon`, ghost icon button, `expandSidebar` | +| SidebarModeSwitch | Always when props provided | see §6 | + +### 12.4 Center title region + +Classes: `pointer-events-none absolute inset-x-0 bottom-1.5 flex min-w-0 items-center justify-center gap-2 px-32`. + +| Element | Content | +|---------|---------| +| Project name button | `truncate font-semibold`; click → reveal worktree in file manager; tooltip shows `profile.worktree_path` | +| Branch button | `switchBranchTitle` aria-label | + +**Branch display rules:** + +- **Default profile + active:** live `GitBranchLabel` (fetched branch name) with GitBranchIcon. +- **Default profile + inactive:** no branch label shown. +- **Non-default profile:** static `profile.branch_name` with GitBranchIcon. + +### 12.5 Right controls + +- Dynamic topbar controls from registry (`visibleActiveControls`). +- Settings gear: `projectSettings`, secondary icon button. + +### 12.6 Topbar control components (`controls.tsx`) + +| Control ID | Button | Label pattern | Action | +|------------|--------|---------------|--------| +| github-desktop | icon-sm secondary | `topbarGithubDesktop` | Open GitHub Desktop at worktree | +| editor | icon-sm secondary | `topbarEditor` · {app name} | Open configured editor app | +| terminal | icon-sm secondary | `topbarTerminal` · {app name} | Open configured terminal app | +| pr-status | xs secondary with text | `#N {state}` | Open PR URL; tooltip `topbarPrTooltip` | + +**PR states:** Draft, Open, Merged, Closed (`topbarPrDraft/Open/Merged/Closed`). + +### 12.7 Keyboard shortcuts (active profile) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+G` | Open Git diff dialog (changes tab) | +| `Cmd/Ctrl+E` | Toggle file tree sidebar (`onToggleFileTree`) | + +--- + +## 13. Top Bar Settings UI (`topbar/*.tsx`) + +Configured in app Settings page, not on project detail directly. + +### 13.1 TopBarSettings + +``` +div.flex.max-w-2xl.flex-col.gap-6 +├── hint text (topbarDragHint) +├── DndContext +│ ├── TopBarPreview +│ ├── AvailableControls +│ └── DragOverlay → DraggableControl +├── Editor app NativeSelect (if apps detected) +├── Terminal app NativeSelect +└── Reset button (topbarResetDefaults) +``` + +**Loading:** `topbarDetectingApps`. + +### 13.2 TopBarPreview + +Mock bar: "My Project" + branch "main" + draggable active controls or `topbarNoControls`. + +### 13.3 AvailableControls + +Dashed border drop area; inactive controls or `topbarAllControlsActive`. + +### 13.4 DraggableControl + +`rounded-md border bg-muted p-2`; grab cursor; DotsSixVerticalIcon + control icon (16px); dragging opacity 0.4. + +--- + +## 14. Dialogs — Projects + +### 14.1 CreateProjectDialog + +**Open triggers:** App sidebar "New Project" button (`#add-project-button`). + +| Element | Details | +|---------|---------| +| Title | FolderPlusIcon + `createProject` | +| Empty folder state | Dashed border button, FolderIcon size-6, `chooseFolder` | +| Selected folder | Label `folder`, outline xs button to re-pick, `code` block with path | +| Field | `projectName` input, placeholder `projectNamePlaceholderFolder` | +| Description | Dynamic hint from folder/name state | +| Footer | Cancel + Create (Spinner when pending) | + +**Validation:** Create disabled until folder selected. + +**Success:** navigates to new project's first profile. + +**Enter key:** submits from name field. + +### 14.2 DeleteProjectDialog + +**Open trigger:** Project context menu in sidebar (`ProjectMenuItem`). + +| Element | i18n | +|---------|------| +| Title | TrashIcon + `deleteProject` | +| Body | `confirmDeleteProject` | +| Cancel | `cancel` | +| Delete | `delete` (destructive, Spinner when pending) | + +### 14.3 RenameProjectDialog + +**Open trigger:** Project context menu → Rename. + +| Element | i18n | +|---------|------| +| Title | PencilSimpleIcon + `renameProject` | +| Field | `newName` | +| Submit disabled | empty or unchanged name | +| Buttons | `cancel`, `rename` | + +**Focus:** `[data-rename-input]` on open. + +### 14.4 ProjectSettingsDialog + +**Open trigger:** Project context menu → Settings; also gear in ProjectTopBar. + +| Element | Details | +|---------|---------| +| Content width | `sm:max-w-lg` | +| Title | GearSixIcon + `projectSettings` | +| Loading | Spinner min-h-[200px] | +| Error | DialogBodyError | + +**Tabs:** + +| Tab | Icon | Content | +|-----|------|---------| +| scripts (default) | CodeIcon | Worktree dir + init/setup/teardown script textareas | +| templates | TerminalWindowIcon | ProjectTemplatesEditor | + +**Scripts tab fields:** + +| Field | Label | Description key | +|-------|-------|-----------------| +| worktreeDir | `projectWorktreeDir` | `projectWorktreeDirDesc`, placeholder `projectWorktreeDirPlaceholder` | +| initScript | `initScript` | `initScriptDesc`, 4-row mono textarea | +| setupScript | `setupScript` | `setupScriptDesc` | +| teardownScript | `teardownScript` | `teardownScriptDesc` | + +**Footer:** Cancel + Save (Spinner when pending). + +### 14.5 ProjectTemplatesEditor (`components/ProjectTemplatesEditor.tsx`) + +| Element | i18n | +|---------|------| +| Section title | `projectTerminalTemplates` | +| Description | `projectTerminalTemplatesDescription` | +| Add button | `addTerminalTemplate` | +| Empty | `noTerminalTemplates` | +| Row actions | edit (`editTerminalTemplate`), delete (`deleteTerminalTemplate`) | +| Row shows | name, command preview (mono), optional cwd | + +**Sub-dialog:** `TerminalTemplateDraftDialog` (from terminal feature) for create/edit. + +### 14.6 UnsavedFileCloseDialog + +See §8.8. + +--- + +## 15. Dialogs — Profiles + +### 15.1 CreateProfileDialog + +**Open triggers:** + +- `ProfileList` "New Profile" control. +- `ProjectMenuItem` when project has only default profile. + +| Element | i18n | +|---------|------| +| Title | GitBranchIcon + `createProfile` ("New Profile") | +| Field | `branchName`, placeholder `branchNamePlaceholder` | +| Footer | `cancel`, `create` | + +**Enter:** submits if not pending. + +**Success:** navigate to new profile route. + +### 15.2 DeleteProfileDialog + +**Open trigger:** Profile item context menu (`ProfileItem`). + +| Element | i18n | +|---------|------| +| Title | TrashIcon + `deleteProfile` | +| Body | `confirmDeleteProfile` | +| Checking state | Spinner + `deleteProfileCheckingGitStatus` | +| Risk alert | `deleteProfileGitWarningTitle` + combined warnings | +| Git check failed alert | `deleteProfileGitCheckFailedTitle/Description` | +| Delete button | `deleteProfileAnyway` if risk, else `delete` | + +**Delete disabled while:** git check fetching or delete pending. + +--- + +## 16. Shared components + +### 16.1 FileTreeFileIcon + +- SVG icon from `@pierre/trees` symbol set based on filename extension. +- Default size **14px** (command palette uses **16**). +- Props: `fileName`, optional `size`. +- `aria-hidden="true"`, `data-icon-name`, `data-icon-token`. +- Color via CSS var `--trees-file-icon-color-{token}`. + +### 16.2 OverflowTooltipText + +- Truncating span (`min-w-0 truncate`). +- Tooltip only when text overflows (canvas measurement via `@chenglou/pretext`). +- Tooltip: side top, align start, max-width `min(480px, calc(100vw - 32px))`, `break-all whitespace-normal`. +- Prop `tooltipDisabled` suppresses tooltip. + +**Used in:** sidebar profile labels (not in scoped file list but referenced by layout). + +--- + +## 17. Terminal tab strip extras (`TerminalTemplateMenu`) + +**New terminal tab trigger:** TabsTrigger value `__new-terminal__`, PlusIcon. + +**Dropdown sections:** Project templates, global templates, default terminal; empty hint `noTemplatesDropdownHint`. + +--- + +## 18. Complete keyboard shortcut reference (scoped surfaces) + +| Shortcut | Surface | Action | +|----------|---------|--------| +| Cmd/Ctrl+K | Profile (active) | Open command palette | +| Cmd/Ctrl+S | File editor / markdown file | Save file | +| Cmd/Ctrl+G | Profile (active) | Open git diff dialog | +| Cmd/Ctrl+E | Profile (active) | Toggle sidebar open/close | +| Cmd/Ctrl+T | Profile (active) | New terminal | +| Cmd/Ctrl+W | Profile (active) | Close terminal tab | +| Arrow L/R, Home, End | Profile sidebar resize handle | Adjust width | +| Enter | Command palette | Open file | +| Escape | Command palette / dialogs | Close | +| Enter | Rename/create dialogs | Submit where wired | + +--- + +## 19. i18n key index (English) — scoped features + +Keys listed in `messages/en.json` directly referenced by inventoried components: + +**Home:** `home`, `emptyProjectsTitle`, `emptyProjectsDesc`, `onboardingTourTitle`, `onboardingTourDesc` + +**Project detail / terminal empty:** `noTerminalsOpen`, `noTerminalsOpenDescription`, `newTerminal` + +**Sidebar:** `sidebarFilesTab`, `sidebarGitTab`, `notes`, `profileSidebarResizeSeparator`, `expandSidebar` + +**File tree menus:** `fileTreeContextMenuOpen`, `fileTreeContextMenuOpenInDefaultApp`, `fileTreeContextMenuRevealInFileManager`, `fileTreeContextMenuRefresh`, `fileTreeContextMenuNewFile`, `fileTreeContextMenuNewFolder`, `fileTreeContextMenuCopyRelativePath`, `fileTreeContextMenuCopyAbsolutePath`, `rename`, `delete`, `fileTreeCreateErrorTitle`, `fileTreeDeleteErrorTitle`, `somethingWentWrong` + +**Command palette:** `commandPaletteTitle`, `commandPalettePlaceholder`, `commandPaletteEmpty`, `commandPaletteNoResults`, `commandPaletteRoot` + +**File close:** `closeUnsavedFileTitle`, `closeUnsavedFileDescription`, `cancel`, `discardChanges` + +**Notes / markdown:** `notes`, `notesPlaceholder`, `notesSaving`, `notesSaved`, `notesSaveFailedShort`, `notesSaveFailedTitle`, all `notesFormat*`, `notesInsert*`, `notesTable*`, `notesCodeBlock*`, `notesCommandMenu`, `notesApplyLink`, `notesRemoveLink`, `preview` + +**Project dialogs:** `createProject`, `chooseFolder`, `folder`, `projectName`, `projectNamePlaceholderFolder`, `createProjectChooseFolderHint`, `createProjectHintFolderEmpty`, `createProjectHintFolderNamed`, `create`, `cancel`, `deleteProject`, `confirmDeleteProject`, `renameProject`, `newName`, `rename`, `projectSettings`, `scripts`, `templates`, `projectWorktreeDir*`, `initScript*`, `setupScript*`, `teardownScript*`, `scriptPlaceholder`, `save`, `projectTerminalTemplates*`, `addTerminalTemplate`, `editTerminalTemplate`, `deleteTerminalTemplate`, `noTerminalTemplates`, `terminalTemplate` + +**Profile dialogs:** `createProfile`, `branchName`, `branchNamePlaceholder`, `deleteProfile`, `confirmDeleteProfile`, `deleteProfileCheckingGitStatus`, `deleteProfileGitWarningTitle`, `deleteProfileLocalChangesWarning`, `deleteProfileUnpushedCommitsWarning`, `deleteProfileTotalDiffWarning`, `deleteProfileGitCheckFailedTitle`, `deleteProfileGitCheckFailedDescription`, `deleteProfileAnyway` + +**Top bar:** `projectSettings`, `switchBranchTitle`, `topbarGithubDesktop`, `topbarEditor`, `topbarTerminal`, `topbarPrStatus`, `topbarPr*`, `topbarPreview`, `topbarAvailable`, `topbarDragHint`, `topbarResetDefaults`, `topbarDetectingApps`, `topbarNoControls`, `topbarAllControlsActive`, `topbarEditorApp`, `topbarTerminalApp` + +**Git sidebar:** `noChangesDetected`, `changedFiles`, `gitCommitIncludeAll`, `gitOpenDiffView`, (+ commit composer keys) + +--- + +## 20. Dialog open trigger summary + +| Dialog | Trigger location | +|--------|------------------| +| CreateProjectDialog | AppSidebar new project button | +| DeleteProjectDialog | ProjectMenuItem context menu | +| RenameProjectDialog | ProjectMenuItem context menu | +| ProjectSettingsDialog | ProjectMenuItem context menu; ProjectTopBar gear | +| CreateProfileDialog | ProfileList; ProjectMenuItem (single-profile projects) | +| DeleteProfileDialog | ProfileItem context menu | +| UnsavedFileCloseDialog | Close dirty file tab in TerminalTabs | +| CommandPalette | Cmd/Ctrl+K | +| GitDiffDialog | Cmd/Ctrl+G; git sidebar maximize; double-click changed file | +| SwitchBranchDialog | Click branch in ProjectTopBar | + +--- + +*Generated from codebase exploration. Hardcoded English strings not in en.json are called out explicitly (e.g. "Preview unavailable", archive file counts, "Choose template", draft "New File"/"New Folder").* diff --git a/docs/ui-inventory-settings-terminal-git-debug-updater.md b/docs/ui-inventory-settings-terminal-git-debug-updater.md new file mode 100644 index 00000000..23059977 --- /dev/null +++ b/docs/ui-inventory-settings-terminal-git-debug-updater.md @@ -0,0 +1,1228 @@ +# 2code UI Inventory — Settings, Terminal, Git, Debug, Updater & Dialogs + +Framework-agnostic inventory derived from source code in `/workspace`. Intended for reimplementing the UI without the current frontend stack. + +**Scope:** Settings window, Terminal UI, Git UI, Debug UI, Updater UI, and all remaining modal dialogs/overlays. + +**Out of scope (referenced only where they attach):** Home page, sidebar, file tree, file viewer, command palette, notes editor, onboarding tour. + +--- + +## Table of Contents + +1. [Window Architecture](#1-window-architecture) +2. [Settings Window](#2-settings-window) +3. [Terminal UI](#3-terminal-ui) +4. [Git UI](#4-git-ui) +5. [Debug UI](#5-debug-ui) +6. [Updater UI](#6-updater-ui) +7. [Remaining Dialogs & Overlays](#7-remaining-dialogs--overlays) +8. [Global Toast System](#8-global-toast-system) +9. [Cross-Cutting Platform Differences](#9-cross-cutting-platform-differences) +10. [Keyboard Shortcuts Summary](#10-keyboard-shortcuts-summary) + +--- + +## 1. Window Architecture + +### 1.1 Main Window (`tauri.conf.json`) + +| Property | Value | +|----------|-------| +| Title (internal) | `2code` | +| Visible title bar text | Hidden (`hiddenTitle: true`) | +| Decorations | Enabled | +| Title bar style | **Overlay** (macOS-style integrated title bar) | +| Traffic light position (macOS) | x=16, y=24 | +| Default size | 1440 × 900 px, centered | +| Maximizable | Yes | + +**Layout (main window root):** + +``` +MainWindow +├── StartupUpdateCheck (invisible — side effect only) +├── Horizontal split +│ ├── AppSidebar (left, collapsible) +│ └── Main content area (relative, scrollable card background) +│ ├── Routes (Home / Project detail placeholder) +│ └── TerminalLayer (absolute inset-0 overlay; shown on profile routes) +├── DebugFloat (fixed bottom-right FAB, conditional) +└── WindowControls (Windows only — custom min/max/close) +``` + +**Drag region:** Project top bar uses `data-tauri-drag-region` for window dragging on macOS overlay chrome. + +### 1.2 Settings Window (second webview) + +Created by Tauri command `open_settings_window` when not already open. + +| Property | Value | +|----------|-------| +| Label | `settings` | +| Title | `Settings` | +| URL | `index.html` (same SPA entry; frontend branches on window label) | +| Size | 880 × 640 px (default), min 600 × 420 px | +| Centered | Yes | +| Decorations | System default (no overlay title bar config) | + +**Frontend branch (`main.tsx`):** If `getCurrentWebviewWindow().label === "settings"`, render `SettingsWindow` instead of `App`. Settings window does **not** mount: file watcher, terminal layer, sidebar, or startup profiling sync. + +**Settings URL tabs:** Query param `?tab=` selects settings tab. `open_update_page` opens/reuses settings window with `?tab=about`. + +**Cross-window sync:** Settings edited in the settings window sync to the main window via broadcast store sync (`crossWindowSync.ts`). + +### 1.3 Global Providers (both windows) + +Outer → inner: QueryClient → ThemeProvider → TooltipProvider → BrowserRouter → content → **Toaster** (Sonner toast stack). + +--- + +## 2. Settings Window + +### 2.1 Root: `SettingsWindow` + +``` +SettingsWindow +└── div (full height, card background, foreground text) + └── SettingsPage +``` + +No sidebar, no terminal layer, no file watcher. + +### 2.2 `SettingsPage` — Tab Shell + +**Layout:** Full-height column. + +``` +SettingsPage +├── Tabs (flex-1 column, min-height 0) +│ ├── TabsList (horizontal, margin 20px top, scroll-x if overflow) +│ │ └── 6 × TabsTrigger (icon + label each) +│ └── Scrollable content area (padding 20px, flex-1 overflow auto) +│ └── TabsContent (one visible at a time) +``` + +**Tabs (left → right):** + +| Tab ID | Icon | English label (`en.json`) | +|--------|------|---------------------------| +| `general` | Gear | General | +| `terminal` | Terminal window | Terminal | +| `template` | Code | Terminal Templates | +| `notification` | Bell | Notification | +| `topbar` | Monitor | Top Bar | +| `about` | Info | About | + +**Tab state:** Persisted in URL search param `tab`. Default tab when absent: `general`. Changing to `general` clears the param. + +**Terminal tab split layout:** Two columns with 32px gap: +- **Left column:** max-width ~28rem (`max-w-md`), vertical stack gap 24px — pickers +- **Right column:** flex-1 — live `TerminalPreview` + +--- + +### 2.3 General Tab + +**Container:** Vertical stack, max-width ~28rem, gap 24px. + +#### 2.3.1 Language + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Language` | +| Control | Native select (small) | Options: `English` (value `en`), `中文` (value `zh`) | + +Changing value calls `setAppLocale`. + +#### 2.3.2 Theme (app light/dark) + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Theme` | +| Control | Native select (small) | `System`, `Light`, `Dark` | + +Stored in `ThemeContext` preference (separate from terminal theme). + +#### 2.3.3 BorderRadiusPicker + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Border Radius` | +| Control | Toggle group (small, single-select) | `None`, `Small`, `Medium`, `Large`, `Extra Large` | + +**Store default:** `sm` (Small). + +**Effect:** Sets CSS custom properties `--radius`, `--radius-sm/md/lg/xl` on document root. + +#### 2.3.4 WorktreeSettings + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Default Worktree Directory` | +| Description | Field description | `Used for new profiles when a project does not set worktree_dir. Relative paths are resolved from the project folder.` | +| Row | Horizontal flex | Text input (editable path) + Outline button with folder icon (`Choose Folder`) + Ghost icon button (X, clear) | + +**Placeholder:** `Default: ~/.2code/workspace` + +**Clear button:** Disabled when path empty. Aria-label: `Clear default worktree directory`. + +**Choose Folder:** Opens native directory picker dialog (Tauri). + +**Store default:** empty string. + +#### 2.3.5 Debug Mode + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Debug Mode` | +| Description | `Show backend log events in a floating panel.` | +| Control | Switch (right-aligned horizontal field) | + +**Store default:** `enabled: false`. Persisted. Toggling on starts backend debug log channel. + +#### 2.3.6 Performance Profiling + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Performance Profiling` | +| Description | `Write frontend and backend performance traces until this is turned off or the app exits.` | +| Control | Switch | + +**Store default:** `enabled: false`. Not persisted across restarts (in-memory Zustand only). + +#### 2.3.7 SidebarAppearanceSettings + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Show Project Avatars` | +| Description | `Display project avatars in the left sidebar.` | +| Control | Switch | + +**Store default:** `showProjectAvatars: true`. + +--- + +### 2.4 Terminal Tab + +**Left column (top → bottom):** + +#### TerminalThemePicker + +**When "sync theme" checked:** +- One theme select labeled `Terminal Theme` +- Preview eye button (ghost icon-xs, right of label) + +**When sync unchecked:** +- `Dark Mode Theme` select + preview button +- `Light Mode Theme` select + preview button + +**Theme select options** (display names from `themes.ts`): + +| ID | Display name | +|----|--------------| +| github-dark | GitHub Dark | +| github-light | GitHub Light | +| dracula | Dracula | +| ayu-dark | Ayu Dark | +| ayu-light | Ayu Light | +| solarized-dark | Solarized Dark | +| solarized-light | Solarized Light | +| one-dark | One Dark | +| one-light | One Light | + +**Preview button:** Aria-label `Preview`. Sets parent preview theme override. + +**Sync checkbox row:** Checkbox + label `Use same theme for both modes` + +**Store defaults:** +- `darkTerminalTheme`: `github-dark` +- `lightTerminalTheme`: `github-light` +- `syncTerminalTheme`: `false` + +#### ShellPicker (async — skeleton 70px while loading) + +| Element | Content | +|---------|---------| +| Label | `Default Shell` | +| Select | List of detected shells; default shell marked `(default)`; last option `Custom` | +| Conditional input | Shown when Custom selected — monospace input, placeholder `e.g. /usr/bin/fish` | +| Description | `Used for newly opened terminal tabs.` | + +Shell list from backend `listAvailableShells`. + +#### FontPicker (async — skeleton 70px) + +| Element | Content | +|---------|---------| +| Label | `Terminal Font` | +| Select | Mono fonts by default; each option shows font family name | +| Empty state | Select shows `Unavailable` + description `No system fonts were found on this machine.` | +| Checkbox below | `Show all fonts` (includes non-monospace when checked) | + +**Store defaults:** `fontFamily: "JetBrains Mono"`, `showAllFonts: false` + +**Platform:** `listSystemFonts` — macOS Core Text, Linux/Windows fontdb. + +#### FontSizePicker + +| Element | Content | +|---------|---------| +| Label | `Font Size` | +| Control | Number input | + +**Constraints:** min 10, max 20. **Default:** 13. + +**Right column: TerminalPreview** + +Read-only faux terminal block: +- Background/foreground from selected or auto terminal theme +- Font family/size from terminal settings store +- Padding 12px 16px, 8px border radius, 0.5px border +- Fixed sample lines: `whoami`, `ls`, `echo "Hello, 2code!"` with green `$` prompts +- Blinking block cursor on last empty prompt line + +Preview updates live when font/size/theme eye-preview changes. + +--- + +### 2.5 Template Tab (Global Terminal Templates) + +**Container:** max-width ~42rem (`max-w-2xl`). + +``` +GlobalTerminalTemplatesSettings +├── Header row +│ ├── Title: "Global Templates" (semibold) +│ ├── Description: "These templates are available in every project." +│ └── Button (outline, sm): "Add Template" +├── Empty state OR template list +└── TerminalTemplateDraftDialog (modal) +``` + +**Empty state:** Bordered rounded box, muted text: `No templates yet.` + +**Template list item (each):** +- Bordered rounded row, horizontal flex +- Left: template name (truncate, medium weight) + command preview (monospace, muted, truncate) +- Right: Ghost icon buttons — Edit (pencil), Delete (trash, destructive color) +- Disabled while save pending + +**Store default:** empty templates array (persisted via Tauri storage). + +--- + +### 2.6 Notification Tab + +**Container:** max-width ~28rem, gap 24px. + +#### Enable toggle + +| Label | `Enable Notifications` | +| Description | `Allow this app to send system notifications.` | +| Control | Switch | + +On enable: requests OS notification permission; if denied, toggle reverts. + +**Store default:** `enabled: false` + +#### SoundPicker (async) + +| Label | `Sound` | +| Preview button | Speaker icon, aria-label `Preview` — disabled if notifications off, no sound, or no sounds | +| Select | `None` + system sound names; disabled when notifications off or no sounds | +| Empty | Shows `No system sounds` + description | + +**Store default:** `sound: "Ping"` + +**Platform:** `listSystemSounds` / `playSystemSound` — macOS `/System/Library/Sounds`, Windows `C:\Windows\Media`, Linux XDG dirs. + +--- + +### 2.7 Top Bar Tab (`TopBarSettings`) + +**Container:** max-width ~42rem, gap 24px. + +``` +TopBarSettings +├── Hint text: "Drag controls to reorder or move between areas." +├── DndContext +│ ├── TopBarPreview ("Current Controls") +│ ├── AvailableControls ("Available Controls") +│ └── DragOverlay (floating copy while dragging) +├── Optional app selects (if apps detected) +│ ├── Editor Application → native select +│ └── Terminal Application → native select +└── Button (outline, sm): "Reset to Defaults" +``` + +**Loading:** `Detecting installed apps...` + +**Error:** Shows error message text. + +**TopBarPreview:** Mock bar showing "My Project" + branch icon "main" + draggable active controls area. + +**AvailableControls:** Dashed border drop zone for inactive controls. + +**Drag behavior:** +- Reorder within preview +- Drag from preview to available → remove from bar +- Drag from available to preview → add to bar + +--- + +### 2.8 About Tab (`AboutSettings`) + +**Container:** max-width ~42rem, vertical gap 32px. + +#### App identity section + +| Element | Content | +|---------|---------| +| App icon | 80×80 px, drop shadow, alt "2code" | +| Title | `2code` (2xl semibold) | +| Version badge | `Version {version}` — clickable copies to clipboard; skeleton while loading | +| Tagline | `The Vibe Coding Workstation — A desktop workspace where terminal, AI agents, and Git live together for uninterrupted flow state.` | + +Non-Tauri (web dev): version shows `dev`. + +#### External links row + +Two outline small buttons: +- GitHub icon + `Repository` + external arrow → `https://github.com/AkaraChen/2code` +- Tag icon + `Releases` + external arrow → releases URL + +#### Update section (bordered card) + +**Header bar:** Title `Update` + optional badge: +- If update available: `Update {version} is available` +- If checked and up to date: `Already up to date` (outline muted badge) + +**Body:** +- Switch: `Accept Beta Updates` / `Check prerelease builds from the beta channel.` +- Status text (one of): + - Update available description with current/latest versions + - Release date line: `Released {date}` (locale-formatted) + - Not available / idle descriptions + - Error text (destructive color) if update error +- Buttons: + - `Check for Updates` (outline) — shows spinner while checking + - `Install {version}` (primary) — shown when update available; spinner while downloading + +**Updater settings default:** `acceptBetaUpdates: false` + +#### Contributors + +Heading `Contributors` + clickable card: +- Avatar 36px round +- Name `AkaraChen` +- Subtitle `Project maintainer and primary contributor.` +- Hover: external arrow icon + +#### Footer + +`© {currentYear} AkaraChen` (xs muted) + +--- + +### 2.9 TerminalTemplateDraftDialog (shared modal) + +Used from Global Templates settings (and project templates elsewhere). + +| Property | Value | +|----------|-------| +| Max width | ~32rem (`sm:max-w-lg`) | +| Title icon | Terminal window | +| Title | `Add Template` or `Edit Template` | + +**Fields:** +1. **Template Name** — text input, placeholder `e.g. Dev Server` +2. **Working Directory** — optional (`showCwd` prop); only in project context +3. **Commands** — textarea 8 rows, monospace, placeholder `One command per line…`; description `Runs after the terminal starts. One command per line.` + +**Footer (space-between):** +- Left: `Cancel` (outline) + `Delete` (destructive, edit mode only, spinner if pending) +- Right: `Save` (disabled if name empty or pending) + +--- + +## 3. Terminal UI + +### 3.1 Layer Architecture + +``` +TerminalLayer (absolute inset-0, one visible profile at a time) +├── For each active profile ID: +│ └── div (absolute inset-0; display flex/none) +│ └── ProfileLayout +│ ├── CommandPalette +│ ├── ProjectTopBar (see Git section) +│ ├── ProfileSidebar (files/git/notes — out of scope) +│ └── TerminalTabs +└── TerminalFileLinkPickerDialog (global, one instance) +``` + +**Persistence rule:** Terminals never unmount. Inactive profiles/tabs use CSS `display: none` or `visibility: hidden`. + +**Global shortcuts (TerminalLayer, active profile only):** +- **⌘T** (metaKey + t): Create new terminal tab in active profile +- **⌘W** (metaKey + w): Close active terminal tab + +Note: These check `e.metaKey` only (macOS-oriented). + +--- + +### 3.2 ProfileLayout → Terminal Area + +Below top bar + optional sidebar: + +``` +TerminalTabs (full width/height column) +├── Tab bar row (border-bottom, horizontal scroll) +│ └── TabsList (line variant) +│ ├── Terminal tab triggers (one per session) +│ ├── File tab triggers (one per open file) +│ └── TerminalTemplateMenu ("+ New Terminal") +├── File viewer pane (flex-1, conditional — only when file tab active) +└── Terminal area (flex-1, relative) + ├── Per-tab absolute layers (visibility hidden when inactive) + └── emptyFallback when no tabs +``` + +When file tab active: terminal area `display: none` (but terminals stay mounted). + +--- + +### 3.3 Tab Bar + +**Tab trigger layout class:** max-width 14rem (`max-w-56`), flex-none, left-aligned content. + +#### Terminal tab trigger (each) + +Left → right: +1. **Icon** — 14×14 px: + - Default: terminal window icon + - Agent keyword match (case-insensitive in title): claude, codex, gemini, kimi, cline, openclaw, opencode, qoder → respective brand SVG +2. **Title** — truncated text (from PTY OSC title or default) +3. **AgentStatusDot** (optional) — see §3.8 +4. **Close button** — 16×16 hit target, X icon, aria-label `Close {title}` + +**Drop target:** Terminal tabs accept file-tree drag-drop of paths (writes formatted paths to PTY). + +#### File tab trigger (each) + +1. File type icon (14px) +2. Truncated filename +3. Dirty indicator — 8px filled circle (muted) if unsaved +4. Close button + +Closing dirty file → `UnsavedFileCloseDialog`. + +#### TerminalTemplateMenu (trailing) + +Rendered as last item in tab list. + +**Primary click / Enter / Space on trigger:** Creates default empty terminal (does not open dropdown). + +**Hover-open dropdown** (120ms close delay, non-modal): +- Empty: message `No templates yet.` + hint `Add project templates in Project Settings, or global templates in Settings.` +- **Project Templates** group (if any) — name + optional cwd subtitle +- Separator (if both groups) +- **Global Templates** group (if any) + +Selecting template creates terminal running template commands. + +**Button label:** `New Terminal` + +--- + +### 3.4 Terminal Component (xterm.js host) + +**Outer shell:** +- Flex column, relative, 100% size +- Padding: 8px top, 0 right/bottom, 8px left +- Background: terminal theme background color +- Overflow hidden + +**Inner:** Flex-1 min-size-0 div — xterm mounts here in a 100% wrapper div. + +**Search overlay (when open):** `TerminalSearchBar` positioned absolute top-right inside shell. + +#### xterm visual configuration + +| Setting | Value | +|---------|-------| +| Cursor | Bar, width 4px, blinking | +| Cursor inactive | Outline style | +| Scrollback | 5000 lines | +| Scrollbar | Hidden | +| macOptionIsMeta | false | + +#### TerminalSearchBar + +Fixed overlay: top-right (12px inset), z-index 20. + +| Element | Details | +|---------|---------| +| Search input | width ~14rem, height 28px, autofocus; placeholder `Search terminal` | +| Result count | `No results` or `{current}/{total}` | +| Previous button | Caret up, aria `Previous match` | +| Next button | Caret down, aria `Next match` | +| Close button | X, aria `Close search` | + +**Match highlight colors:** gold/brown inactive, blue active (hardcoded hex). + +**Keyboard in search:** +- Escape → close, refocus terminal +- Enter → next match (Shift+Enter → previous) +- Typing → incremental search + +#### Link handling + +- Web links and file links open confirmation unless Ctrl/Cmd+click bypass +- **TerminalLinkConfirmDialog** for external URLs +- Ambiguous file paths → **TerminalFileLinkPickerDialog** + +#### Agent detection + +Polls every 250ms (2s when tab hidden). Publishes `running` | `waiting` | `idle` to store. Waiting triggers notification sound + optional system notification. + +#### Process exit + +Appends gray line: `[Process exited]` + +--- + +### 3.5 Terminal Keyboard Shortcuts (in-terminal focus) + +| Shortcut | Platform | Action | +|----------|----------|--------| +| ⌘F | macOS | Open search | +| Ctrl+Shift+F | Windows/Linux | Open search | +| ⌘= / ⌘+ | macOS | Increase font size | +| ⌘- | macOS | Decrease font size | +| Ctrl+L | All | Clear screen (+ PTY clear) | +| Ctrl+C | Windows/Linux | Copy selection, or SIGINT if no selection | +| Ctrl+V | Windows/Linux | Paste clipboard to PTY | +| ⌘← / ⌘→ | macOS | Send Home / End | +| Alt+← / Alt+→ | All | Word left / word right | +| Shift+Enter | All | Send newline | + +Selection auto-copies to clipboard with toast **"Text copied"** (hardcoded English, not i18n). + +Font size changes persist to terminal settings store (clamped 10–20). + +--- + +### 3.6 TerminalLinkConfirmDialog + +| Title | `Open Link` (link icon) | +| Description | `Choose where to open this link. Hold Ctrl and click to open directly in the default browser next time.` | +| URL label | `Link` | +| URL value | Monospace, break-all | +| Cancel | `Cancel` | +| Primary split button | `Open in Default Browser` + dropdown caret | +| Dropdown | List installed browsers (`listInstalledBrowsers`); disabled if empty; aria `Open With` | + +--- + +### 3.7 TerminalFileLinkPickerDialog + +| Max width | ~36rem | +| Title | `Choose File` (file icon) | +| Description | `The exact file path was not found. Select one of the matching files to open.` | +| List | Scrollable max 50vh, bordered | + +**Each candidate row (min-height 44px):** +- File icon 16px +- Name (truncate, medium) + relative path (monospace xs muted) +- FileText icon trailing +- Click → opens file in file viewer tab, closes dialog + +--- + +### 3.8 AgentStatusDot + +8px circle (`size-2`), rounded full: + +| Status | Appearance | +|--------|------------| +| `running` | Emerald green + pulsing shadow animation | +| `waiting` | Yellow (`yellow-400`) | +| `completed` | Green (`green-500`) — shown as dismissible notification dot on tab when no active status | + +Dismiss completion: click dot, aria-label `Dismiss completion notification`. + +--- + +### 3.9 TerminalPreview (settings only) + +Static HTML/CSS mock terminal — not a live PTY. See §2.4. + +--- + +## 4. Git UI + +### 4.1 ProjectTopBar + +**Height:** min 44px. **Padding:** platform-dependent right padding (Windows: extra 118px for system buttons). + +**Three zones (horizontal):** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Left controls] [Center title - absolute centered] [Right controls] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Left zone + +- **Expand sidebar** button (only when app sidebar collapsed) — ghost icon, tooltip `Expand sidebar` +- **SidebarModeSwitch** (when sidebar mode props provided) — toggles Files / Git / Notes sidebar + +#### Center zone (pointer-events split) + +- **Project name** — semibold truncate, clickable → reveals worktree path in Finder/file manager (tooltip shows full path) +- **Branch control** — git branch icon + branch name, clickable → opens SwitchBranchDialog + - Default profile + active: live git branch from repo + - Non-default profile: shows `profile.branch_name` static + +#### Right zone + +- Dynamic **top bar controls** from registry (GitHub Desktop, editor launcher, terminal launcher, PR status, etc.) +- **Project settings** gear button (secondary icon) — tooltip `Project Settings` → ProjectSettingsDialog + +**Keyboard shortcuts (active profile):** +- **⌘G / Ctrl+G:** Open Git diff dialog (Changes tab) +- **⌘E / Ctrl+E:** Toggle file tree sidebar + +**Mounted dialogs from top bar:** +- `ProjectSettingsDialog` +- `SwitchBranchDialog` +- `GitDiffDialog` (branch name from live git or profile) + +--- + +### 4.2 SwitchBranchDialog + +| Max width | ~28rem | +| Padding | Header/content split, no outer padding on content wrapper | + +``` +SwitchBranchDialog +├── Header (border-bottom, px-16 py-12) +│ └── Title: "Switch branch" + branch icon +├── Search input (px-12 py-8, h-32, autofocus) +│ └── placeholder: "Search branches…" +└── Scrollable list (max-height min(60dvh, 384px)) + └── BranchRow × N +``` + +**BranchRow contents:** +- Branch icon (muted) +- Branch name (truncate; bold if current) +- Badges (optional): + - `current` (secondary) — current branch, row disabled/selected bg + - `trunk` (outline) + - `used` (amber outline) — branch in use by another profile, disabled +- Ahead/behind counts: green `↑N`, red `↓N` ( monospace 11px) + +**Empty search:** `No branches found` + +**Loading:** Centered spinner + +**Checkout success toast:** `Switched to {branch}` + +--- + +### 4.3 GitDiffDialog (full-screen modal) + +| Size | width `min(88rem, 100vw - 2rem)`, height `min(82dvh, 56rem)` | +| Close button | Hidden in dialog chrome — custom close in header | +| Padding | 0 (full bleed) | + +``` +GitDiffDialog +├── GitDiffHeader +└── GitDiffContent (flex-1 min-height 0) +``` + +#### GitDiffHeader + +Horizontal bar, border-bottom, padding 16×8: + +| Zone | Content | +|------|---------| +| Title | Branch icon + truncated branch name (fallback "main") | +| Center-right | Toggle group `Preview`: `Unified` / `Split` | +| Far right | Ghost icon close button (aria "Close") | + +#### GitDiffContent — Master Layout + +``` +┌──────────────────┬─────────────────────────────────────┐ +│ Sidebar 360px │ Diff pane (flex-1) │ +│ border-right │ │ +│ │ │ +│ [Changes|History] │ File diff viewer OR empty state │ +│ │ │ +│ File/commit list │ │ +│ + commit form │ │ +└──────────────────┴─────────────────────────────────────┘ + [Review Queue FAB if comments exist] +``` + +**Sidebar:** Fixed width 360px, focusable (`tabIndex=0`) for keyboard nav. + +**Sidebar tabs:** +- `Changes` (git diff icon) +- `History` (commit icon) + +--- + +### 4.4 Changes Tab (Sidebar) + +#### Empty state +Centered: `No changes detected` + +#### ChangesFileList + +**Sticky header:** +- Tri-state checkbox (include all/none) — aria `All` +- Text: `{count} changed file(s)` +- Optional maximize button (sidebar mode only) — aria `Open diff view` + +**File rows (`FileListItem`):** +- Optional checkbox (include in commit) +- Basename (truncate) + parent path (xs muted) +- Change badge (letter in colored square): + - `A` green — added + - `D` red — deleted + - `M` blue — modified + - `R` amber — rename +- Active row: muted background +- Double-click → open file in editor tab, close diff dialog +- Right-click → context menu portal (fixed 200px): + - Destructive button: `Discard changes to this file` + +**Keyboard (sidebar focused):** +- ↑/↓ — move selection +- Space — toggle include checkbox (changes tab) +- Enter — drill into commit (history tab) +- Backspace/Escape — back from commit file list (history) + +#### CommitComposer (bottom, border-top) + +| Field | Label | Placeholder | +|-------|-------|-------------| +| Summary | `Summary` | `Describe the changes you're committing` | +| Body | `Description` | `Add an optional extended description` | + +Section title: `Commit` (uppercase xs muted) + +**Buttons (right-aligned):** +- If no changed files but commits ahead: **Push** button with ahead count + upload icon +- Else: **Commit** button (disabled unless ≥1 file included + non-empty summary) + +**Shortcut:** ⌘/Ctrl+Enter in summary/body → commit (if valid) + +**Global shortcut:** ⌘/Ctrl+Enter when no local changes but ahead commits → push + +--- + +### 4.5 Changes Tab (Diff Pane) + +**Component:** `GitDiffPane` with `@pierre/diffs` FileDiff renderer. + +**Font:** Uses terminal font family + size from settings. + +**Empty states:** +- No changes: `No changes detected` +- No selection: `Select a file to view changes` + +**File header bar:** +- Change badge + truncated path (rename shows `old → new`) +- Green `+N` / red `-N` line counts + +**Special views:** +1. **Large diff guardrail** (≥ threshold changed lines, default threshold in utils): + - Title: `Large diff hidden by default` + - Description with counts + - Button: `Load diff anyway` +2. **Rename-only:** Previous path / Current path rows +3. **Binary image:** Side-by-side Before/After preview panes with checkerboard background +4. **Normal diff:** Unified or split per header toggle + +**Review comments (changes pane only):** +- Line click/drag selection opens floating composer anchored to selection +- Composer: "Comment on {range}", textarea, "Add to queue" button +- Hardcoded English strings in composer UI + +**Review Queue FAB:** Bottom-right when comments exist — button `Review Queue` + count badge (hardcoded English label on FAB). + +--- + +### 4.6 History Tab + +#### Commit list (initial) + +Scrollable rows: +- Commit message (1 line clamp) +- Hash (mono) + author + relative time (`just now`, `Nm ago`, etc. — hardcoded English) +- Stats: N file(s), +insertions, -deletions + +Empty: `No commits found` + +#### Commit drill-in + +**Header:** Back arrow (aria `Back to commit list`) + message + hash + +**File list:** Same `FileListItem` without checkboxes + `{count} changed file(s)` + +Empty files: `No file changes` + +**Diff pane:** Shows selected commit file diff (no review comments). + +--- + +### 4.7 GitReviewQueueDialog + +| Size | width `min(56rem, 100vw-2rem)`, max-height 80vh | + +**Header:** `Review Queue` (chat icon) + +**Body:** Scrollable list of comment cards, each: +- File path (mono semibold) +- Range badge + "Selected diff" (hardcoded) +- Mini diff preview (max-height 128px) +- "Comment" label + editable textarea +- Delete button (destructive icon) + +**Footer:** +- `Copy` (outline) — hardcoded English +- `Copy and clear all` (destructive) — hardcoded English + +Success toasts: `Review comments copied` / `Review comments copied and cleared` + +--- + +### 4.8 SidebarGitPanel (compact git in profile sidebar) + +Same as Changes sidebar subset: +- `ChangesFileList` with maximize button → opens full GitDiffDialog +- `CommitComposer` below +- No history tab in sidebar mode + +Tooltips on file paths disabled in compact mode. + +--- + +## 5. Debug UI + +### 5.1 Enabling + +**Settings → General → Debug Mode** switch. + +When enabled: +- Backend debug log channel starts +- `DebugFloat` FAB appears + +### 5.2 DebugFloat + +| Property | Value | +|----------|-------| +| Visibility | Only when debug mode enabled | +| Position | Fixed, right 64px, bottom 16px, z-index 50 | +| Shape | Circular icon button | +| Icon | Wrench | +| Aria-label | `Debug Log` | + +Click → opens DebugLogDialog. + +### 5.3 DebugLogDialog + +| Max width | ~32rem (`sm:max-w-lg`) | +| Max height | 70vh | +| Title | `Debug Log` | + +``` +DebugLogDialog +├── Search input (flex-1) — placeholder "Search logs..." +├── Clear button (trash icon, aria "Clear") +├── Log list (scrollable, flex-1) +│ └── LogRow × N +└── Footer count: "{filtered} / {total}" (xs muted, right) +``` + +**LogRow columns:** +1. Timestamp `HH:MM:SS.mmm` +2. Level badge: ERROR (destructive), WARN (secondary), INFO (default) +3. Source (muted) +4. Message (break-all) + +**Auto-scroll:** Sticks to bottom unless user scrolls up (>40px from bottom). + +**Empty filtered:** `No log entries yet` + +**Search:** Filters message, source, level (case-insensitive). + +### 5.4 Debug Keyboard Shortcut + +**⌘⇧D** (macOS) / **Ctrl+Shift+D** (others): Toggle debug panel open/closed (only if debug mode enabled; otherwise no-op). + +--- + +## 6. Updater UI + +### 6.1 StartupUpdateCheck + +Invisible component mounted in main `App`. On mount: +1. Silent `checkForUpdate` +2. If update available (once per app session): Sonner **info** toast + +**Toast contents:** +- Title: `Update {version} is available` +- Description: `Current version {currentVersion}; latest version {version}.` +- Duration: 12 seconds +- Action button: `Open Update Page` → opens settings window on About tab + +Toast id: `update-available` (deduped). + +### 6.2 About Tab Update Section + +See §2.8. Full manual check/install flow with beta toggle. + +**Toasts from About actions:** +| Action | Toast | +|--------|-------| +| Copy version | `Version copied to clipboard` | +| Check — update found | info: update available title + description | +| Check — up to date | info: already up to date | +| Check — error | error: update check failed + message | +| Install — error | error: update failed + message | + +### 6.3 Updater Backend Config + +- Endpoint: GitHub releases `latest.json` +- Beta channel controlled by `acceptBetaUpdates` setting + +--- + +## 7. Remaining Dialogs & Overlays + +### 7.1 CreateProjectDialog + +| Title | `Create Project` (folder-plus icon) | + +**Flow:** +1. Initial: dashed bordered drop zone — folder icon + `Choose Folder` +2. After folder selected: folder path in monospace box + small `Choose Folder` edit button +3. Project name field — label `Project Name`, placeholder `Optional. Leave empty to use folder name` +4. Dynamic hint text (one of three i18n hints based on state) + +**Footer:** `Cancel` | `Create` (disabled without folder or while pending) + +Enter in name field submits. + +--- + +### 7.2 DeleteProjectDialog + +| Title | `Delete Project` (trash icon) | +| Body | `Are you sure you want to delete this project? This action cannot be undone.` | +| Footer | `Cancel` | `Delete` (destructive) | + +--- + +### 7.3 RenameProjectDialog + +| Title | `Rename` (pencil icon) | +| Field | `New Name` | +| Footer | `Cancel` | `Rename` (disabled if empty or unchanged) | + +Autofocuses rename input on open. + +--- + +### 7.4 ProjectSettingsDialog + +| Max width | ~32rem | +| Title | `Project Settings` (gear icon) | + +**Tabs:** +1. **Scripts** (code icon) +2. **Templates** (terminal icon) + +**Scripts tab fields:** +| Field | Label | Description | +|-------|-------|-------------| +| Worktree dir | `Worktree Directory` | Overrides global default… | +| Init script | `Init Script` | Injected into every new terminal session | +| Setup script | `Setup Script` | Runs when creating profile | +| Teardown script | `Teardown Script` | Runs when deleting profile | + +All script fields: monospace textarea 4 rows, placeholder `One command per line…` + +**Templates tab:** `ProjectTemplatesEditor` — same list/edit pattern as global templates but with cwd field in draft dialog (`showCwd=true`). + +**Footer:** `Cancel` | `Save` + +Loading: centered spinner min-height 200px. + +--- + +### 7.5 CreateProfileDialog + +| Title | `New Profile` (branch icon) | +| Field | `Branch Name`, placeholder `Optional. Leave empty to auto-generate…` | +| Footer | `Cancel` | `Create` | + +--- + +### 7.6 DeleteProfileDialog + +| Title | `Delete Profile` (trash icon) | +| Body | `Are you sure you want to delete this profile? The git worktree will be removed.` | + +**Conditional blocks while checking git:** +- Spinner + `Checking this profile's Git status…` +- Warning alert with aggregated diff/commit warnings +- Error alert if git check failed + +**Footer:** `Cancel` | `Delete` or `Delete Anyway` (if risky) + +--- + +### 7.7 UnsavedFileCloseDialog + +| Title | `Close Unsaved File?` (warning icon) | +| Body | `{file} has unsaved changes. Closing it will discard those changes.` | +| Footer | `Cancel` | `Discard Changes` (destructive) | + +Triggered from terminal tab bar when closing dirty file tab. + +--- + +### 7.8 Context Menu Overlay (ChangesFileList) + +Not a dialog — fixed portal at cursor: +- 200px wide popover +- Single destructive action: `Discard changes to this file` +- Closes on outside click, Escape, blur, resize + +--- + +### 7.9 Floating Review Comment Composer (GitDiffPane) + +Fixed-position popover (not modal) anchored to selected diff lines: +- Header: comment range + cancel +- Textarea +- "Add to queue" button + +--- + +### 7.10 Command Palette (attached to profile) + +Overlay search for files — triggered **⌘K / Ctrl+K**. Out of detailed scope but exists in `ProfileLayout`. + +--- + +## 8. Global Toast System + +**Component:** Sonner `Toaster` in both windows. + +**Used for:** Update notifications, git operations, clipboard confirmations, review queue copy, agent-adjacent feedback, file tree errors, etc. + +Standard patterns: +- `toast.success(title, { description })` +- `toast.error(title, { description })` +- `toast.info(title, { description, action: { label, onClick } })` + +--- + +## 9. Cross-Cutting Platform Differences + +| Feature | macOS | Windows | Linux | +|---------|-------|---------|-------| +| Main window title bar | Overlay + traffic lights at (16,24) | Standard decorations; extra topbar right padding 118px for window controls | Standard decorations | +| Custom window controls | No (traffic lights in overlay) | Yes (`WindowControls` component) | No | +| Settings shortcut | ⌘, | Ctrl+, | Ctrl+, | +| Debug toggle | ⌘⇧D | Ctrl+Shift+D | Ctrl+Shift+D | +| Terminal new/close tab | ⌘T / ⌘W (metaKey only in code) | Same binding checks metaKey | Same | +| Terminal search | ⌘F | Ctrl+Shift+F | Ctrl+Shift+F | +| Font listing | Core Text | fontdb | fontdb | +| Sound listing | `/System/Library/Sounds` | `C:\Windows\Media` | XDG sound dirs | +| Notification permission | Tauri notification plugin prompt on enable | Same | Same | +| File reveal | "Reveal in Finder" tooltip on project name | Platform file manager via backend | Platform file manager | + +**Settings window:** Uses standard system title bar with title text "Settings" (not overlay style). + +**Browser list for links:** From `listInstalledBrowsers` when link confirm opens. + +--- + +## 10. Keyboard Shortcuts Summary + +| Shortcut | Context | Action | +|----------|---------|--------| +| ⌘, / Ctrl+, | Global (main window) | Open settings window | +| ⌘⇧D / Ctrl+⇧D | Global | Toggle debug log dialog | +| ⌘T | Active profile | New terminal tab | +| ⌘W | Active profile | Close active terminal tab | +| ⌘G / Ctrl+G | Active profile top bar | Open git diff dialog | +| ⌘E / Ctrl+E | Active profile top bar | Toggle sidebar | +| ⌘K / Ctrl+K | Active profile | Command palette | +| ⌘F | Terminal focus (macOS) | Terminal search | +| Ctrl+Shift+F | Terminal focus (Win/Linux) | Terminal search | +| ⌘=/⌘+ / ⌘- | Terminal (macOS) | Font size | +| Ctrl+L | Terminal | Clear screen | +| ⌘←/→ | Terminal (macOS) | Home/End to PTY | +| Alt+←/→ | Terminal | Word motion | +| Shift+Enter | Terminal | Newline to PTY | +| ↑/↓/Space/Enter | Git diff sidebar | Navigate/toggle/select | +| ⌘/Ctrl+Enter | Git commit form | Commit | +| ⌘/Ctrl+Enter | Git diff (no changes, ahead) | Push | +| Escape | Terminal search / git contexts | Close/back | + +--- + +## Appendix A — Persisted Settings Defaults + +| Store | Key | Default | +|-------|-----|---------| +| terminalSettings | fontFamily | JetBrains Mono | +| terminalSettings | fontSize | 13 | +| terminalSettings | darkTerminalTheme | github-dark | +| terminalSettings | lightTerminalTheme | github-light | +| terminalSettings | syncTerminalTheme | false | +| terminalSettings | showAllFonts | false | +| terminalSettings | defaultShell | from `DEFAULT_TERMINAL_SHELL` | +| themeStore | borderRadius | sm | +| notificationStore | enabled | false | +| notificationStore | sound | Ping | +| sidebarSettings | showProjectAvatars | true | +| worktreeSettings | defaultWorktreeDir | "" (empty → ~/.2code/workspace) | +| updaterSettings | acceptBetaUpdates | false | +| debugStore | enabled | false | +| terminalTemplates | templates | [] | + +--- + +## Appendix B — Hardcoded Non-i18n Strings + +These appear in UI without `en.json` entries: + +| Location | String | +|----------|--------| +| Terminal.tsx | Toast: "Text copied" | +| GitDiffContent.tsx | FAB: "Review Queue" | +| GitReviewQueueDialog | "Copy", "Copy and clear all", "Selected diff", "Comment", "Comment on …", "Add to queue" | +| GitDiffPane comment composer | "Add to queue", "Cancel review comment" | +| GitDiffHeader | Close aria-label: "Close" | +| CommitList | Relative time: "just now", "Nm ago", "file/files" | +| TerminalTabs | "Dismiss completion notification", "Close {title}" | +| TopBarPreview | Mock text: "My Project", "main" | + +--- + +*Document generated from codebase inspection. Branch: settings/terminal/git/debug/updater components as specified in exploration task.* diff --git a/docs/ui-inventory.md b/docs/ui-inventory.md new file mode 100644 index 00000000..552e8562 --- /dev/null +++ b/docs/ui-inventory.md @@ -0,0 +1,977 @@ +# 2code UI 完整清单(换框架重写规格) + +这份文档是 2code 桌面端 **全部可见 UI 的框架无关规格**。目标:之后用任何 UI 技术栈重写时,不必再读 React / Tailwind / shadcn 源码,也能按相同产品形态还原窗口、页面、组件树、排布、尺寸、交互、空态、错误态和文案。 + +**审计日期:** 2026-09-03 +**对照代码:** 主窗口 `src/App.tsx`、`src/layout/*`、`src/features/*`、`src/components/ui/*`、`src/app.css`、`src-tauri/tauri.conf.json`、`src-tauri/src/handler/window.rs`、`messages/en.json` + +**更细的 className / 像素对照(英文附录):** + +| 附录 | 覆盖面 | +|------|--------| +| [sidebar-ui-inventory.md](./sidebar-ui-inventory.md) | 应用侧栏、项目分组、头像、Profile 次级侧栏原语 | +| [ui-inventory-home-project.md](./ui-inventory-home-project.md) | 首页、Profile 布局、文件树、文件查看器、命令面板、项目/Profile 对话框 | +| [ui-inventory-settings-terminal-git-debug-updater.md](./ui-inventory-settings-terminal-git-debug-updater.md) | 设置窗、终端、Git 大对话框、调试、更新器 | + +本文是 **主规格**:先读本文,再按需要查附录。文中尺寸、文案、树结构均来自源码,不 invent。 + +--- + +## 0. 怎么用这份文档重写 + +重写时按「窗口 → 区域 → 组件 → 状态」还原,不要按当前 React 组件名还原。 + +1. **先还原窗口壳**:两个 Webview、原生标题栏、拖拽区、Windows 自定义按钮。 +2. **再还原主窗三分区**:应用侧栏 | 主内容 | 持久终端层。 +3. **再还原 Profile 工作区**:顶栏 + 次级侧栏(Files/Git/Notes)+ 统一标签栏 + 终端/文件查看器。 +4. **最后还原叠加层**:对话框、命令面板、右键菜单、Toast、调试 FAB、新手引导。 + +**不可破坏的产品不变量:** + +- 终端实例 **不能因切标签/切路由而销毁**。隐藏用 CSS `display:none` / `visibility:hidden`,不要条件卸载。 +- 文件树在 Files/Git/Notes 切换时也 **保持挂载**(`display:none`),以免丢失展开/选中状态。 +- 设置是 **独立窗口**,不是主窗内路由页。 +- `Cmd+,` 开设置;`Cmd+Shift+D` 开调试面板;`Cmd+K` 开文件搜索;`Cmd+T`/`Cmd+W` 开/关终端标签。 +- 文案走 i18n(英/中)。品牌字 `"2Code"` 硬编码,不要翻译。 + +--- + +## 1. 产品表面总图 + +2code 是本地优先的「项目 + Profile(git worktree)+ 持久 PTY 终端」工作站。用户看到的全部表面: + +``` +操作系统 +├── 主窗口 "2code" 1440×900 叠加标题栏 +│ ├── 应用侧栏(项目列表) +│ ├── 主内容 +│ │ ├── 首页(仅无项目时停留) +│ │ └── Profile 工作区(有项目后的主界面) +│ │ ├── 顶栏 +│ │ ├── 次级侧栏:Files | Git | Notes +│ │ └── 统一标签栏 + 终端 / 文件查看器 +│ ├── 叠加层:对话框、命令面板、右键菜单、Toast +│ ├── 调试扳手按钮(可选) +│ └── Windows 右上角 min/max/close +│ +└── 设置窗口 "Settings" 880×640 + └── 六个标签:General / Terminal / Templates / Notification / Top Bar / About +``` + +**没有的表面:** 登录、账号、云同步、独立「项目列表页」(有项目会立刻跳进第一个默认 Profile)、移动端布局、汉堡菜单。 + +--- + +## 2. 窗口与原生铬 + +### 2.1 主窗口 + +| 项 | 值 | +|----|----| +| 内部 title | `2code` | +| 可见标题文字 | 隐藏(`hiddenTitle: true`) | +| 装饰 | 系统 decorations 开 | +| 标题栏样式 | **Overlay**(内容顶到标题栏下) | +| macOS 红绿灯 | 左上,坐标 **x=16, y=24** | +| 默认尺寸 | **1440 × 900**,居中 | +| 可最大化 | 是 | + +因为 overlay 标题栏,应用必须自己留出红绿灯和拖拽区: + +- 应用侧栏 **header** 标 `data-tauri-drag-region`。macOS 上 `padding-top: 32px`(`pt-8`),其它平台 `8px`(`pt-2`)。 +- 首页 header、Profile 顶栏也是拖拽区。 +- 拖拽区内的按钮/链接必须 `no-drag`,否则点不到。 +- 应用侧栏折叠后,macOS 顶栏左侧再加 `padding-left: 84px`,避开红绿灯。 +- Windows 顶栏右侧 `padding-right: 118px`,避开自定义窗口按钮。 + +### 2.2 设置窗口 + +| 项 | 值 | +|----|----| +| Webview label | `settings` | +| 标题 | `Settings` | +| URL | 同一个 `index.html`;前端看 label 决定渲染 `SettingsWindow` 而不是 `App` | +| 默认尺寸 | **880 × 640** | +| 最小尺寸 | **600 × 420** | +| 居中 | 是 | +| 装饰 | 系统默认(**不是** overlay) | + +打开方式: + +- 侧栏底部 Settings +- `Cmd+,` / `Ctrl+,` +- 更新 Toast 的 “Open Update Page” → 复用同一窗口并切到 About(`?tab=about`) + +已存在则 `show()` + `focus()`,不新建。设置窗 **不** 挂文件监视、终端层、应用侧栏、启动性能同步。设置项通过跨窗口 broadcast 同步回主窗。 + +### 2.3 Windows 自定义窗口按钮 + +仅 Windows 渲染。`fixed top-0 right-0`,高 **28px**(`h-7`),从左到右: + +1. Minimize — 减号,12px,hover 灰底 +2. Maximize / Restore — 未最大化是方框,最大化是「两份重叠」图标 +3. Close — X,hover **#c42b1c** 白字,active **#b32717** + +每个按钮 **28×36**(`h-7 w-9`),`no-drag`。 + +### 2.4 根布局(主窗) + +``` +html/body/#root = 100% × 100%,overflow:hidden +App +├── StartupUpdateCheck (无 DOM,启动时可能弹 Toast) +├── 横向 flex,flex-1,min-height:0 +│ ├── AppSidebar 或 SidebarSkeleton / SidebarError +│ └── main(relative, flex-1, overflow-y:auto, 背景 --card) +│ ├── Routes +│ │ ├── / HomePage +│ │ ├── /projects/:id/profiles/:profileId ProjectDetailPage +│ │ └── * 重定向 / +│ └── TerminalLayer(absolute inset 0 覆盖层) +├── DebugFloat(条件) +└── WindowControls(仅 Windows) +``` + +整窗背景 `--background`,文字 `--foreground`。侧栏背景 `--sidebar`,主区 `--card`。 + +**全局交互基调:** `body` 禁止文本选择、默认箭头光标。只有 `input` / `textarea` / `contenteditable` / `.xterm` / Markdown 编辑器允许选字。按钮、链接、`[data-sidebar-item]` 也是默认光标(不是 pointer),只有少数明确写了 `cursor-pointer` 的标题/分支例外。 + +--- + +## 3. 设计系统(视觉合同) + +### 3.1 字体 + +| 用途 | 字体 | +|------|------| +| UI 正文 | Inter Variable → 系统栈(-apple-system, Segoe UI, Roboto…) | +| 标题 token | Geist Variable(`--font-heading`) | +| 等宽 / 终端 / 代码 / Markdown | 设置里的终端字体,CSS 变量 `--font-mono`,默认 JetBrains Mono 栈 | +| 抗锯齿 | `-webkit-font-smoothing: antialiased`,`text-rendering: optimizeLegibility` | + +Phosphor 图标默认 **1em、duotone**。少数地方改 `weight="regular"`。 + +### 3.2 颜色 token(oklch) + +亮色 `:root`: + +| Token | 值 | 用途 | +|-------|-----|------| +| `--background` | `oklch(1 0 0)` | 窗体底 | +| `--foreground` | `oklch(0.145 0 0)` | 主文字 | +| `--card` | `oklch(1 0 0)` | 主内容区 | +| `--popover` | `oklch(1 0 0)` | 菜单/对话框/Toast | +| `--primary` | `oklch(0.205 0 0)` | 主按钮(近黑) | +| `--muted` | `oklch(0.97 0 0)` | 浅底、hover | +| `--muted-foreground` | `oklch(0.556 0 0)` | 次要文字 | +| `--destructive` | `oklch(0.577 0.245 27.325)` | 危险操作 | +| `--border` / `--input` | `oklch(0.922 0 0)` | 边框 | +| `--ring` | `oklch(0.708 0 0)` | 焦点环 | +| `--radius` | `0.625rem`(10px) | 基础圆角 | +| `--sidebar` | `oklch(0.985 0 0)` | 侧栏底(比主区略灰) | +| `--sidebar-accent` | `oklch(0.97 0 0)` | 侧栏 hover/选中 | +| `--app-focus-ring` | 系统 `Highlight` | 自定义焦点 | + +暗色 `.dark`:背景 `oklch(0.145)`,卡片/弹出层 `oklch(0.205)`,主色反转成浅色,边框半透明白。侧栏暗色底与卡片同级。 + +**语义色(不走 token):** + +- Git 增加:`text-green-500` / `text-green-600` +- Git 删除:`text-red-500` / `text-red-600` +- Agent waiting:`bg-yellow-400` +- Agent completed:`bg-green-500` +- Agent running:`bg-emerald-400` + 1.4s 脉冲光晕 +- 分支 “used” 徽章:琥珀底/字 +- Windows 关闭按钮:`#c42b1c` + +焦点环优先用系统 `Highlight` / `HighlightText`(选择色也是)。 + +### 3.3 圆角档位 + +设置里的 Border Radius 改 `--radius`。派生: + +| 档 | 显示名 | 相对 `--radius` | +|----|--------|-----------------| +| none | None | 0 | +| small | Small | ×0.6 | +| medium | Medium | 默认 0.625rem | +| large | Large | ×1.4 | +| extra large | Extra Large | ×1.8 | + +常用:按钮 `rounded-lg`,对话框 `rounded-xl`,头像 `rounded-md`,菜单 `rounded-lg`。 + +### 3.4 按钮规格 + +| size | 高 | 用途 | +|------|----|------| +| default | 32px(`h-8`) | 对话框主操作 | +| xs | 24px | 紧凑(改文件夹、重试) | +| sm | 28px | 次要 | +| lg | 36px | 少用 | +| icon | 32×32 | 顶栏齿轮、调试 FAB | +| icon-xs | 24×24 | 搜索条箭头 | +| icon-sm | 28×28 | 对话框关闭 | + +| variant | 外观 | +|---------|------| +| default | 实心 primary | +| outline | 边框 + 白底,hover muted | +| secondary | 浅底 | +| ghost | 无底,hover muted | +| destructive | 浅红底 + 红字(不是大红实心) | +| link | 下划线链接 | + +禁用:`opacity: 50%`,不可点。激活时轻微下移 1px。 + +### 3.5 标准对话框壳 + +默认对话框: + +- 遮罩:全屏 `bg-black/10`,支持 backdrop 时轻微模糊,z-index 50 +- 面板:水平垂直居中,默认 `max-w-sm`,内边距 16px,`rounded-xl`,`bg-popover`,细环 `ring-foreground/10` +- 打开:淡入 + 缩放到 95% +- 右上角默认有 ghost 关闭按钮(Git 大对话框关掉这个默认按钮,自己画关闭) +- 结构:Title(常带 16px 图标)→ 正文 gap 12–20px → Footer 右对齐(Cancel outline + 主按钮) + +标准小对话框(创建/删除/重命名/未保存关闭)都用这套壳。Git Diff 是例外:`min(88rem, 100vw-2rem)` × `min(82dvh, 56rem)`,无内边距。 + +### 3.6 Toast + +Sonner,跟主题走。图标 16px Phosphor regular:成功勾、信息 i、警告三角、错误叉、加载转圈。背景 `--popover`。启动更新 Toast 持续 **12 秒**,带 “Open Update Page” 动作。 + +### 3.7 空状态 + +居中 `Empty`:上方圆形图标容器 → 标题 → 灰色说明 → 可选操作按钮。用于:无项目、无终端、设置/模板空列表。 + +### 3.8 加载 / 错误 + +| 表面 | 加载 | 错误 | +|------|------|------| +| 应用侧栏 | 250px 宽骨架:一条标题 + 半宽标签 + 三条缩进行 | 同宽栏,红标题 “Something went wrong” + 消息 + Try again | +| 主页面 | 左上骨架:48×192 标题 + 两行 | 同样错误栈,padding 32px | +| 面板内部 | 居中 Spinner 16px | 居中错误栈 | +| 行内 | — | 左红标题+截断消息,右 xs Try again | +| 对话框体 | Spinner | 最小高 200px 居中错误栈 | + +### 3.9 Agent 状态点 + +**8×8** 圆点,不可聚焦。 + +| 状态 | 颜色 | 动画 | +|------|------|------| +| waiting | 黄 `#facc15` | 无 | +| running | 翠绿 | 1.4s 脉冲光晕(`prefers-reduced-motion` 时关掉) | +| completed | 绿 `#22c55e` | 无 | + +出现位置:侧栏默认 Profile / 非默认 Profile、终端标签。完成点可点消失(仅标签栏)。 + +--- + +## 4. 应用侧栏(左列) + +### 4.1 尺寸与折叠 + +| 常量 | 值 | +|------|-----| +| 默认宽 | **250px** | +| 最小 / 最大 | **220 / 420** | +| 键盘步进 | 16px | +| 持久化 | localStorage `app-sidebar-width`(宽、是否折叠、折叠的分组 id) | + +折叠时侧栏 **整棵不渲染**。展开按钮只出现在 Profile 顶栏左侧(首页折叠后没有展开钮)。 + +右缘有 **8px** 宽竖直 resize 热区,中线 1px。拖时中线变 `foreground/30`,hover/焦点时显示 border 色。 + +### 4.2 结构(上 → 下) + +``` +Sidebar(role=navigation, aria-label="Side navigation") +├── Header(拖拽区) +│ ├── 左:不可点品牌 "2Code"(semibold) +│ └── 右:折叠按钮 SidebarSimple 图标 +├── Content(竖向滚动,stable scrollbar gutter) +│ ├── [无项目] Home 行(房子图标 + "Home") +│ ├── [有置顶] 分组 "Pinned" +│ │ └── 项目行… +│ └── 分组 "Projects" +│ ├── 标签右侧:铅笔(排序)+ 加号(新建项目)#add-project-button +│ └── 顶层条目:项目 或 分组 +└── Footer + └── Settings 行(齿轮 + "Settings") +``` + +无项目时 Content 里只有 Home。有项目时 Home 不出现(首页会立刻跳走)。 + +键盘:在侧栏内 ↑/↓ 在所有 `[data-sidebar-item]` 间循环。 + +### 4.3 普通模式:项目行 + +一行从左到右: + +1. **头像 16×16**(可在设置关掉) + - 有 GitHub avatar URL:封面图 + - 失败/无图:侧栏 accent 底 + 项目名第一个大写字母,10px + - 空名:`?` +2. **项目名** medium、截断 +3. 右侧动作: + - **只有默认 Profile:** hover/焦点时显示 `+`(新建 Profile);平时若有 agent 状态则显示状态点(hover 时点隐藏、`+` 出现) + - **有额外 Profile:** 展开/折叠 caret(默认展开) + +点击项目名 → 进默认 Profile。 + +**右键菜单:** + +1. Add to Project Group ▸ + - 已有分组列表(当前分组打勾、禁用) + - 若已在分组:Remove from Project Group + - Create Project Group,或直接出现输入框(无分组时) + - 输入占位 `e.g. Work`,Enter 创建并加入,Esc 取消 +2. Project Settings +3. Rename +4. — 分隔 — +5. Delete Project(destructive) + +### 4.4 展开后的 Profile 子列表 + +缩进子菜单: + +1. 第一行永远是 **Default**(终端窗图标 + 文案 “Default” + 可选状态点) +2. 其余 Profile:Git 分支图标 + `branch_name`(溢出出 tooltip)+ 状态点 +3. 最后一行:`+` + “New Profile” + +非默认 Profile 右键只有 **Delete Profile**(destructive)。 + +### 4.5 项目分组 + +分组行:caret + 分组名 + 右侧数字徽章(项目数)。点击折叠。展开用 180ms 高度/透明度动画(`[0.22,1,0.36,1]`),尊重 reduced motion。 + +分组内项目与顶层项目同一套行/菜单。 + +### 4.6 排序模式 + +点铅笔进入。项目/分组变成可拖行: + +- 左:六点拖手(`cursor-grab`) +- 中:头像或文件夹图标 + 名 +- 右:星标钉/取消钉 + +分区: + +1. **Pinned** — 已钉项目 + 虚线投放区 “Drop here to pin” +2. **Projects** — 顶层项目与分组交错;分组下再嵌套项目 + “Drop project into folder”;底部 “Drop here to unpin or move out” + +保存中禁用拖拽。标签右侧铅笔换成 **勾**(完成)+ 加号。拖拽中行透明度 0.45,背景 sidebar-accent。 + +### 4.7 侧栏骨架 / 错误 + +宽固定 250px,右边框,`bg-muted/40`,内边距 16px。错误时居中错误栈。 + +--- + +## 5. 首页 + +有项目时 **立刻 replace 导航** 到第一个项目的默认 Profile,用户几乎看不到首页内容。 + +无项目时: + +``` +HomePage(满高) +├── header 高 52px,底边框,左右 padding 20px,拖拽区 +│ ├── Folder 图标 16px muted +│ └── "Home" semibold 14px +├── 剩余高度居中 Empty +│ ├── FolderPlus 图标 +│ ├── "No projects yet" +│ └── "Create your first project from the sidebar to get started." +└── TourOnboarding(无 React DOM) +``` + +新手引导:300ms 后用 driver.js 指向侧栏 `#add-project-button`。气泡在右侧、上对齐。标题 “Create your first project”,说明 “Click here to add a project folder and get started.” 只有关闭按钮。点加号会销毁引导。 + +--- + +## 6. Profile 工作区(主界面) + +### 6.1 谁在画这块 UI + +两条路径,外观必须一致: + +| 条件 | 谁渲染 ProfileLayout | +|------|----------------------| +| 该 Profile **没有任何** 终端标签和文件标签 | `ProjectDetailPage` 画空态 CTA | +| 该 Profile **至少有一个** 标签 | `TerminalLayer` 用 absolute overlay 画完整工作区;`ProjectDetailPage` 返回 null | + +`TerminalLayer` 为 **每一个曾经开过标签的 Profile** 各挂一份 `ProfileLayout`,非当前路由的用 `display:none`。切项目/Profile 不卸载终端。 + +无效 project/profile → 回默认 Profile 或 `/`。 + +### 6.2 布局 + +``` +ProfileLayout 竖向满高 +├── CommandPalette(始终挂着,关时不可见) +├── 底边框 +│ └── ProjectTopBar 最小高 44px +└── 横向 flex-1,min-width/height 0 + ├── ProfileSidebar 默认 208px,可 180–560 + └── 主列 flex-1 + └── TerminalTabs + ├── 标签栏(终端标签 + 文件标签 + 新建) + ├── 文件查看器(仅文件标签激活时) + └── 终端层(永不卸载,文件标签激活时 display:none) +``` + +次级侧栏关时宽度动画到 0,`pointer-events:none`,`aria-hidden`。弹簧:stiffness 320, damping 34, mass 0.9。宽度存在 localStorage `file-tree-panel`。 + +--- + +## 7. 顶栏(ProjectTopBar) + +三区叠在同一条 **最小 44px** 高的条上,底对齐,左右 padding 16px,上 4px 下 6px,整条拖拽区。 + +``` +[ 左:展开侧栏? + Files/Git/Notes ] [ 绝对居中:项目名 + 分支 ] [ 右:可配置控件 + 齿轮 ] +``` + +居中块 `pointer-events:none`,内部按钮再打开;左右各留 128px(`px-32`)以免和两侧重叠。 + +### 7.1 左 + +1. 应用侧栏折叠时:ghost icon 按钮,SidebarSimple,tooltip “Expand sidebar” +2. **SidebarModeSwitch** — 高 28px 的三段 tab + - Files:FolderSimple 14px + - Git:GitBranch 14px + 可选 `+N` 绿 / `-M` 红 + - Notes:Note 14px + - 只有图标,文字在 tooltip + - 侧栏关闭时三段都未选中 + - **再点当前模式 = 关闭侧栏**;点其它模式 = 切模式并打开 + +快捷键(仅当前 Profile):`Cmd/Ctrl+E` 开关侧栏,`Cmd/Ctrl+G` 开 Git Diff 大对话框。 + +### 7.2 中 + +- **项目名** semibold,可点,tooltip 显示 worktree 绝对路径;点击在系统文件管理器中显示该目录 +- **分支**:Git 图标 + 名 + - 默认 Profile:拉当前 git 分支;非活动 overlay 不拉 + - 非默认 Profile:显示 `profile.branch_name` + - 点击打开 Switch Branch 对话框 + - muted,hover 变主色 + +### 7.3 右 + +用户在设置 Top Bar 里配置的控件(只显示本机已安装的): + +| id | 外观 | 行为 | +|----|------|------| +| `github-desktop` | GitHub 标 | 用 GitHub Desktop 打开当前 worktree | +| `editor` | Code 标 | 用所选编辑器打开(VS Code / Cursor / Windsurf / Zed / Sublime) | +| `terminal` | 终端窗标 | 用所选终端打开(Ghostty / iTerm2 / kitty / Warp) | +| `pr-status` | PR 标 + 状态 | Checking / No PR / Open / Draft / Merged / Closed;tooltip 带号和标题 | + +最右固定 **齿轮** secondary icon,开 **项目设置对话框**(不是应用设置窗)。 + +--- + +## 8. 次级侧栏三模式 + +右缘 8px resize,aria “Resize sidebar”,键盘 ±16 / Home / End。 + +### 8.1 Files — 文件树 + +Pierre FileTree,compact,完整图标,粘性文件夹,默认全收起。 + +| 视觉 | 值 | +|------|-----| +| 字号 | 13px | +| 缩进 | 12px / 级 | +| 行左右 padding/margin | 4px | +| 选中底 | `--muted` | +| 外包 | `px-1.5 py-1` + 右边框 | +| 打开动画 | 18ms,opacity + 左移 12px | + +**交互:** + +- 单击文件 → 在统一标签栏开文件标签 +- 单击目录 → 展开/折叠 +- 多选 / Cmd / Shift 点击不自动打开 +- 树内拖拽移动;拖到终端标签或终端区域会把路径写进 PTY +- 内联重命名;新建草稿名硬编码 `"New File"` / `"New Folder"`(重名加数字) +- Git 状态混进路径列表(未跟踪/改动的文件即使目录未扫到也会出现) + +**条目右键:** Open · Open in Default App · Reveal in Finder · Refresh · New File · New Folder · Rename · Copy Relative Path · Copy Absolute Path · Delete(红) + +**空白/根右键:** New File · New Folder · Refresh · Reveal in Finder · Copy Relative / Absolute Path(根) + +加载中树自身处理;路径错误时半透明错误字叠在树上(不挡操作)。 + +### 8.2 Git — 紧凑变更面板 + +``` +竖向满高 +├── ChangesFileList(可滚) +│ └── sticky 头:全选 checkbox + "{n} changed file(s)" + 放大开 Diff 对话框 +│ └── 文件行:checkbox + 图标 + 名 + A/D/M/R 徽章 + discard +└── CommitComposer(底,上边框,padding 10×8) + ├── 小写标题 COMMIT + ├── Summary 单行(高 28px) + ├── Description 多行(最小 4.5rem) + ├── "{included} of {total} files included" + All / None + ├── Commit 按钮(需有摘要且至少勾一个文件) + └── 若 ahead>0:Push 按钮 + 上传图标 +``` + +空列表: “No changes detected”。双击文件打开大 Diff 并选中该文件。`Cmd/Ctrl+Enter` 提交。 + +### 8.3 Notes + +懒加载 Markdown 编辑器,占满侧栏。占位 “Write notes for this profile…”。自动保存。状态徽章:Saving / Saved / Failed。失败 Toast “Notes save failed”。 + +--- + +## 9. 统一标签栏 + 终端 + 文件查看器 + +### 9.1 标签栏 + +一条横滚、底边框的 line tabs。每个 trigger `max-width: 14rem`(`max-w-56`),左对齐。 + +**终端标签(左起):** + +1. 图标 14px:标题含 claude/codex/gemini/kimi/cline/openclaw/opencode/qoder 则用对应彩色 SVG,否则 TerminalWindow +2. 标题截断 +3. 运行/等待状态点;若只有 completed,点可点掉 +4. 关闭 X(16px 热区,12px 图标),`aria-label="Close {title}"` + +终端标签是文件树路径的 drop target。 + +**文件标签(接在终端后面):** + +1. 文件类型图标 14px +2. 文件名截断 +3. 未保存:8px muted 圆点 +4. 关闭(脏则先弹确认) + +**最右:New Terminal** + +- 无模板:一个 `+ New Terminal` tab-looking 控件 +- 有模板:主按钮 + 右侧 caret 分裂按钮,菜单分 **Project Templates** / **Global Templates** + - 项目模板可显示相对 cwd 副文案 + - 空提示去项目设置或应用设置加模板 + +### 9.2 空终端 CTA(无任何标签时) + +居中 Empty:终端窗图标 + “No terminals open” + “Open a terminal to start working in this project.” + 同上的 New Terminal(可分裂)。分裂按钮 `aria-label` 硬编码 `"Choose template"`。 + +### 9.3 终端本体 + +每个会话一个 xterm.js,绝对铺满标签下区域。非活动:`visibility:hidden` + `pointer-events:none`。恢复中居中 Spinner。 + +**必须遵守:** 不要因切标签卸载 xterm。WebKit 下字体测量必须用挂在 document 里的 canvas,否则半角字宽测错、右侧空一截。 + +终端内: + +- 搜索条:右上绝对定位(`top/right: 12px`),半透明底、边框、阴影。输入宽 224px 高 28px + `n/m` 或 “No results” + 上/下/关。Esc 关,Enter 下一个,Shift+Enter 上一个。高亮:普通匹配褐底 `#5f4b16`,当前蓝 `#1f6feb`。 +- 点链接:确认框,说明可 Ctrl+点击跳过;显示 URL;Default Browser / Open With。 +- 点文件路径:在工作区内则开文件标签;歧义则选择列表;工作区外提示不能打开。 +- 主题 10 套:GitHub Dark/Light、Dracula、Ayu Dark/Light、Solarized Dark/Light、One Dark/Light。暗/亮模式可各选一套,或同步。 + +全局(当前 Profile):`Cmd+T` 新终端,`Cmd+W` 关当前终端。 + +### 9.4 文件查看器 + +按扩展名三条路: + +**A. 文本(非 md)— Monaco** + +- 满高,无 minimap,无 word wrap,padding 上下 12px +- 字体 = 终端字体 + ligatures,字号 = 终端字号 +- 主题:终端主题名含 `light` 用 `light`,否则 `vs-dark` +- TS/JS 诊断关闭 +- `Cmd/Ctrl+S` 保存(窗口级 + 编辑器命令) +- 草稿 400ms 防抖;脏点在标签上 + +**B. Markdown / MDX — Milkdown** + +顶工具条(`px-2 py-1`,xs 图标按钮)+ 编辑区。工具:段落/H1–H3、粗斜代码删、链接、列表、引用、代码块、表、分割线、保存。`/` 斜杠菜单(最小宽 180、最高 280)。代码块深色 `#282c34`,语言选择器 240px,预览最高 420px。保存徽章与 Notes 相同。 + +**C. 二进制预览** + +顶条高 ≥36px,muted 底:文件名 + 右侧 “Preview” 或 “Office Preview”。 + +- 图:棋盘透明底,contain +- PDF / Office→PDF:白底 iframe +- 压缩包:`ArchivePreviewTree`,统计硬编码 `"{n} files / {m} folders"` +- 其它:“Preview unavailable”(硬编码) + +加载:高 128px 居中 Spinner。错误:居中 muted 消息。 + +关脏文件:对话框 “Close Unsaved File?” + “{file} has unsaved changes…” + Cancel / Discard Changes(红)。 + +--- + +## 10. 命令面板(Cmd/Ctrl+K) + +仅当前活动 Profile。 + +| 项 | 值 | +|----|-----| +| 遮罩 | 全屏 40% 黑,z 1400 | +| 面板 | 顶距 72px,水平居中,宽 `min(100%, 40rem)`,最高 `100vh-96px` | +| 圆角 | 8px | +| 边框 | `--border` | +| 背景 | `--popover` | + +结构: + +1. 底边框搜索行,`px-4 py-3`,无边框输入,16px,占位 “Search files by name…” +2. 列表最高 60vh:每项 图标16 + 文件名 14px + 父路径 12px muted;选中 `bg-muted` +3. 空:未输入 “Start typing…”;无结果 “No matching files found.”;错误显示消息 + +打开后 focus + 全选。选中 Enter → 开文件标签并关面板。自管过滤关(`shouldFilter=false`),结果来自后端搜索。 + +--- + +## 11. Git 大对话框 + +`Cmd+G` 或侧栏 Git 放大、或顶栏逻辑打开。 + +``` +DialogContent 宽 min(88rem, 100vw-2rem) 高 min(82dvh, 56rem) 无默认关闭钮 +├── Header(底边框,px-4 py-2) +│ ├── 左:Git 图标 + 分支名(缺省显示硬编码 "main") +│ ├── 中右:Unified / Split toggle +│ └── 关 X +└── 横向 flex-1 + ├── 左栏约 360px + │ └── Tabs: Changes | History + │ ├── Changes:文件列表 + CommitComposer(与侧栏同构,更宽) + │ └── History:提交列表 → 点进后变该提交的文件列表 + Back + └── 右:Diff pane(Pierre diffs + Shiki,主题跟终端主题映射) +``` + +文件徽章:A / D / M / R。大 diff(超过阈值的行数)默认折叠,说明 “Large diff hidden by default”,按钮 “Load diff anyway”。重命名显示 Previous/Current path。图片 Before/After。二进制不可预览走 “Preview unavailable”。 + +Review Queue:浮动按钮,收集 diff 评论,可复制/复制并清空。 + +History 空:“No commits found”。无文件变更:“No file changes”。未选文件:“Select a file to view changes”。 + +--- + +## 12. 设置窗口(六个标签) + +顶:横向 TabsList,`margin: 20px 20px 0`,溢出横滚。每项 图标 + 字。默认 General(URL 无 `tab`);其它写入 `?tab=`。 + +内容区 `padding: 20px`,可滚。 + +### 12.1 General + +单列 `max-w-md`,项间距 24px。 + +1. **Language** — native select:English / 中文 +2. **Theme** — System / Light / Dark(应用 chrome,不是终端) +3. **Border Radius** — 五档 +4. **Default Worktree Directory** — 输入,占位 `Default: ~/.2code/workspace`,可清空。说明:项目没设 `worktree_dir` 时新 Profile 用它;相对路径相对项目根 +5. **Debug Mode** — 横排:标题+说明 / Switch。说明 “Show backend log events in a floating panel.” +6. **Performance Profiling** — 同上,写前后端 trace +7. **Show Project Avatars** — 控制侧栏 16px 头像 + +### 12.2 Terminal + +左列(`max-w-md`)+ 右列实时 `TerminalPreview`,间距 32px。 + +左:终端主题(可预览 hover)、Default Shell(系统列表 + Custom 路径)、Font(系统字体,可 “Show all fonts”)、Font Size stepper。Shell/Font 加载失败:70px 高骨架或 InlineError。 + +### 12.3 Terminal Templates + +`max-w-2xl`。全局模板列表:名、shell、cwd、命令(一行一条)。增删改。空:“No templates yet.” + +### 12.4 Notification + +总开关 “Enable Notifications” + 说明。Sound:系统音列表或 None;不可用时 “No system sounds”。Agent 等待会弹系统通知并按配置播放声音。 + +### 12.5 Top Bar + +预览当前控件 + 可拖 “Available Controls”。提示 “Drag controls to reorder or move between areas.” Reset to Defaults。Editor / Terminal 应用下拉。检测中 “Detecting installed apps...”。空槽 “No controls in the top bar…” + +控件显示名:GitHub Desktop、Editor、Terminal、Pull Request。编辑器/终端选项见 `messages/en.json` 的 `topbar*`。 + +### 12.6 About + +应用图标、描述 “The Vibe Coding Workstation — …”、可点复制的 Version 徽章、维护者 AkaraChen(头像+主维护说明)、Repository / Releases / Website 外链。更新卡片:Check for Updates、Accept Beta Updates 开关、有更新则 Install {version}。状态文案见 i18n `update*`。 + +--- + +## 13. 全部对话框目录 + +凡未写尺寸的,都用 §3.5 标准小对话框。 + +### 13.1 Create Project + +触发:侧栏 `+`。 + +- 标题:FolderPlus + “Create Project” +- **未选文件夹:** 虚线大按钮,Folder 24px + “Choose Folder”,hover muted +- **已选:** 上 “Folder” + xs “Choose Folder” 铅笔;下 code 块显示路径 +- Project Name 输入,占位 “Optional. Leave empty to use folder name” +- 说明随状态变三条 hint(先选文件夹 / 只用文件夹名 / 自定义名) +- Footer:Cancel / Create(无文件夹或进行中禁用;进行中出 Spinner) +- 选文件夹用系统目录对话框;若名为空则填 basename +- 成功关窗并导航到新项目第一个 Profile +- Enter 在名称框提交 + +### 13.2 Delete Project + +触发:项目右键。 + +- Trash + “Delete Project” +- “Are you sure you want to delete this project? This action cannot be undone.” +- Cancel / Delete(红,进行中 Spinner) +- 若删的是当前项目:跳到列表里相邻项目的默认 Profile,否则回 `/` + +### 13.3 Rename Project + +触发:项目右键。打开时 focus 输入。 + +- Pencil + “Rename” +- “New Name” +- Cancel / Rename(空或未改禁用) + +### 13.4 Project Settings + +触发:项目右键或顶栏齿轮。 + +- Gear + “Project Settings” +- 内 Tabs:**Scripts**(Code 图标)/ **Templates**(终端窗图标) +- Scripts:Worktree Directory + Init / Setup / Teardown 三个等宽 textarea(4 行,占位 “One command per line…”) +- Templates:项目级终端模板编辑器(与全局同构) +- Cancel / Save(进行中 Spinner) +- 加载失败:DialogBodyError + +### 13.5 Create Profile + +触发:项目行 `+` 或子列表 “New Profile”。 + +- GitBranch + “New Profile” +- Branch Name,占位说明可留空自动生成 `pr/tokyo-a1b2c3d4` 这类名 +- Cancel / Create → 导航到新 Profile + +### 13.6 Delete Profile + +触发:非默认 Profile 右键。 + +- Trash + “Delete Profile” +- 确认 worktree 会删 +- 检查中:Spinner + “Checking this profile's Git status…” +- 有风险:Alert “Check Git changes before deleting” + 未提交/未推送/合计 diff 数字 +- 检查失败:另一 Alert,请手动确认 +- 主按钮在有风险时改成 “Delete Anyway” +- 删当前 Profile 则回默认/其它 Profile 或 `/` + +### 13.7 Close Unsaved File + +- Warning + “Close Unsaved File?” +- “{file} has unsaved changes. Closing it will discard those changes.” +- Cancel / Discard Changes(红) + +### 13.8 Switch Branch + +- GitBranch + “Switch branch” +- 搜索框 “Search branches…” +- 列表行:图标 + 名 + 徽章 current/trunk/used + ↑ahead ↓behind +- current / used 禁用;used 琥珀徽章(已被其它 Profile 占用) +- 当前行 muted 底;其它 hover +- 空:“No branches found” +- 成功 Toast “Switched to {branch}”;失败 “Checkout failed” + +### 13.9 终端相关 + +- Open Link 确认 +- Choose File(歧义路径) +- 模板草稿编辑(名、shell、cwd、命令) + +### 13.10 Git Review Queue + +从 Diff 对话框打开。标题 “Review Queue”。复制 / 复制并清空。Toast “Review comments copied”。 + +### 13.11 Debug Log + +设置里打开 Debug Mode 后,主窗右下 **扳手圆钮**(`fixed right-16 bottom-4 z-50`)。点开日志对话框:搜索 “Search logs...”、Clear、自动滚、空 “No log entries yet”。`Cmd/Ctrl+Shift+D` 切换面板。 + +--- + +## 14. 叠加层与全局反馈 + +| 层 | z / 位置 | 内容 | +|----|----------|------| +| 命令面板遮罩 | 1400 | 40% 黑 | +| 命令面板 | 1401 | 见 §10 | +| 对话框 | 50 | 见 §3.5 | +| 菜单/tooltip | popover | 圆角 8、阴影、细环 | +| Toast | Sonner 默认 | 见 §3.6 | +| 调试 FAB | 50,右下偏左 | 仅 debug 开 | +| 新手引导 | driver.js | 见 §5 | +| 启动更新 | Toast 12s | 见 §2.4 | + +侧栏 Home 的 onboarding 锚在 `#add-project-button`,不在 Home DOM 里。 + +--- + +## 15. 键盘一览 + +| 快捷键 | 范围 | 动作 | +|--------|------|------| +| `Cmd/Ctrl+,` | 全局 | 开设置窗 | +| `Cmd/Ctrl+Shift+D` | 全局 | 开/关调试面板 | +| `Cmd/Ctrl+K` | 活动 Profile | 命令面板 | +| `Cmd/Ctrl+T` | 活动 Profile | 新终端 | +| `Cmd/Ctrl+W` | 活动 Profile | 关当前终端 | +| `Cmd/Ctrl+E` | 活动 Profile | 开关次级侧栏 | +| `Cmd/Ctrl+G` | 活动 Profile | 开 Git Diff | +| `Cmd/Ctrl+S` | 文件查看器可见 | 保存 | +| `Cmd/Ctrl+Enter` | Commit 摘要/正文 | 提交 | +| `Enter` / `Shift+Enter` | 终端搜索 | 下/上一个匹配 | +| `Esc` | 搜索/多数对话框 | 关闭 | +| `↑` `↓` | 应用侧栏 | 项目/Profile 间移动焦点 | +| `←` `→` / Home / End | resize 热区 | 调宽度 | +| `Enter` / `Space` | 分组/部分动作 | 切换 | + +macOS 用 Meta,其它用 Ctrl。TerminalLayer 的 T/W 当前实现检查 `metaKey`(macOS 路径)。 + +--- + +## 16. 平台差异(必须实现) + +| | macOS | Windows | Linux | +|--|-------|---------|-------| +| 标题栏 | Overlay + 红绿灯 (16,24) | Overlay + 自定义 min/max/close | Overlay,无自定义按钮 | +| 侧栏 header padding-top | 32px | 8px | 8px | +| 侧栏折叠后顶栏 padding-left | 84px | 正常 | 正常 | +| 顶栏 padding-right | 20px | 118px | 20px | +| Reveal 文案 | “Reveal in Finder” | 同一 i18n key,实现应走系统文件管理器 | 同左 | +| 字体列表 | core-text | fontdb | fontdb | +| 系统音 | /System/Library/Sounds + afplay | C:\Windows\Media + PowerShell | XDG + 桌面播放器 | +| 顶栏外开 App | 探测本机已装的编辑器/终端/GitHub Desktop | 同 | 同 | + +--- + +## 17. 持久化(影响 UI 还原) + +| Store / key | 影响的 UI | +|-------------|-----------| +| `app-sidebar-width` | 侧栏宽、折叠、分组折叠 | +| `file-tree-panel` | 次级侧栏宽 | +| themeStore | 亮/暗/系统 | +| terminalSettingsStore | 字体、字号、主题、shell | +| notificationStore | 通知开关、声音 | +| sidebarSettingsStore | 是否显示项目头像 | +| topbar store | 控件顺序、编辑器/终端选择 | +| updater settings | 是否收 beta | +| debugStore | 调试是否启用(FAB 显隐) | +| locale | en / zh | + +设置窗与主窗必须共用这些值(跨窗 sync)。 + +--- + +## 18. 硬编码、未走 i18n 的字(重写时建议补) + +| 位置 | 字符串 | +|------|--------| +| 侧栏品牌 | `2Code` | +| 应用侧栏 resize aria | `Resize sidebar`(次级侧栏已 i18n) | +| 模板分裂按钮 | `Choose template` | +| Git Diff 关 | `Close` | +| Git 无分支时 | `main` | +| 文件树草稿名 | `New File` / `New Folder` | +| 预览失败 | `Preview unavailable` | +| 预览条 | `Preview` / `Office Preview` | +| 压缩包统计 | `{n} files / {m} folders` | +| 完成点 | `Dismiss completion notification` | +| 关标签 | `Close {title}` | +| Windows 按钮 | Minimize / Restore / Maximize / Close | +| 设置窗 title | `Settings` | +| 链接占位 | `https://` | +| About 作者 | `AkaraChen`、仓库 URL | + +中文包在 `messages/zh.json`,key 与 `en.json` 对齐。重写时不要把英文写死进布局,除上表。 + +--- + +## 19. 完整组件树(实现对照) + +``` +[主窗口] +App +├── StartupUpdateCheck +├── AppSidebar +│ ├── Header: "2Code" + collapse +│ ├── Home? | Pinned[] | Projects[ Group | Project ] +│ │ └── Project +│ │ ├── Avatar + name + (+|caret|agent-dot) +│ │ ├── context: group / settings / rename / delete +│ │ └── sub: Default, Profile…, New Profile +│ ├── Footer: Settings +│ ├── resize +│ └── CreateProjectDialog +├── main +│ ├── HomePage | (empty ProjectDetailPage) +│ └── TerminalLayer +│ └── per profile (display none unless active) +│ └── ProfileLayout +│ ├── CommandPalette +│ ├── ProjectTopBar +│ │ ├── expand sidebar? +│ │ ├── Files | Git+stats | Notes +│ │ ├── project name + branch → SwitchBranchDialog +│ │ ├── topbar controls + project settings +│ │ └── GitDiffDialog +│ ├── ProfileSidebar +│ │ ├── FileTreePanel +│ │ ├── SidebarGitPanel → CommitComposer + ChangesFileList +│ │ └── ProfileNotesEditor → MarkdownEditor +│ └── TerminalTabs +│ ├── terminal tabs + file tabs + New Terminal menu +│ ├── FileViewerPane (Monaco | Markdown | preview) +│ ├── Terminal… (xterm, search, link dialogs) +│ └── UnsavedFileCloseDialog +├── DebugFloat → DebugLogDialog +└── WindowControls? + +[设置窗口] +SettingsWindow → SettingsPage +├── General | Terminal+Preview | Templates | Notification | Top Bar | About +``` + +--- + +## 20. 重写验收清单 + +做完新 UI 后,对照下列是否 **看起来和用起来** 一样(不必同技术栈): + +1. 冷启动无项目:左栏只有 Home + Settings,右栏 Home 空态,引导指向 `+`。 +2. 建项目:虚线选文件夹 → 命名 → 进入默认 Profile 空终端 CTA。 +3. 开终端:标签出现,xterm 铺满;再开一个;切换不丢滚动/进程。 +4. 切到另一 Profile 再切回:终端还在。 +5. 文件树开文件:标签出现;md 走工具条编辑器;图走棋盘预览;脏点;关时确认。 +6. Cmd+K 搜文件打开。 +7. 次级侧栏 Files/Git/Notes 切换,Git 显示 +/-,Notes 能写能存。 +8. Cmd+G 大 Diff:Changes 提交/推送,History 看提交,Unified/Split。 +9. 顶栏点项目名开 Finder;点分支切换;齿轮开项目脚本/模板。 +10. 侧栏钉选、分组、拖拽排序、折叠整栏、拖宽。 +11. Cmd+, 独立设置窗;改主题/字体/圆角/头像,主窗立刻变。 +12. Debug 开后右下扳手出日志。 +13. 有更新时 Toast 12s,动作进 About。 +14. Windows 右上三按钮可用;macOS 红绿灯不被标题挡住。 +15. 中英切换,布局不碎。 + +--- + +## 21. 附录索引 + +需要像素级 className、token 表、完整 i18n key 列表时打开: + +1. [sidebar-ui-inventory.md](./sidebar-ui-inventory.md) +2. [ui-inventory-home-project.md](./ui-inventory-home-project.md) +3. [ui-inventory-settings-terminal-git-debug-updater.md](./ui-inventory-settings-terminal-git-debug-updater.md) +4. 文案源:[`messages/en.json`](../messages/en.json) / [`messages/zh.json`](../messages/zh.json) +5. 颜色与全局 CSS:[`src/app.css`](../src/app.css) +6. 窗口配置:[`src-tauri/tauri.conf.json`](../src-tauri/tauri.conf.json) diff --git a/justfile b/justfile index 6fbdc9bf..17c68549 100644 --- a/justfile +++ b/justfile @@ -1,9 +1,23 @@ +# GPUI native desktop app (primary product UI) +gpui: + cd src-gpui && cargo run + +gpui-check: + cd src-gpui && cargo test + start: - ./start.sh + just gpui + +tauri-dev: + just gpui + +leftover-tauri: + bun run leftover:tauri:dev fmt: fama "./src/**/*.{ts,tsx}" cd src-tauri && cargo fmt + cd src-gpui && cargo fmt test-frontend: bun run test @@ -12,13 +26,11 @@ test-rust: cd src-tauri && cargo test test-all: - bun run test + just gpui-check just test-rust verify: - bun run lint:check - bun run typecheck - bun run test + just gpui-check just test-rust coverage: diff --git a/messages/en.json b/messages/en.json index da50bf83..7f318803 100644 --- a/messages/en.json +++ b/messages/en.json @@ -361,5 +361,6 @@ "notesCodeBlockHidePreview": "Hide preview", "reviewCommentsCopied": "Review comments copied", "reviewCommentsCopiedAndCleared": "Review comments copied and cleared", - "reviewQueue": "Review Queue" + "reviewQueue": "Review Queue", + "gitReviewCommentPlaceholder": "Write a review comment" } diff --git a/messages/zh.json b/messages/zh.json index 6e028d66..39495998 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -361,5 +361,6 @@ "notesCodeBlockHidePreview": "隐藏预览", "reviewCommentsCopied": "评论已复制", "reviewCommentsCopiedAndCleared": "评论已复制并清空", - "reviewQueue": "审查队列" + "reviewQueue": "审查队列", + "gitReviewCommentPlaceholder": "写一条审查评论" } diff --git a/package.json b/package.json index 143ef834..c22af029 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,12 @@ "open:dmg": "bash ./scripts/open-dmg.sh", "preview": "vite preview", "tauri": "tauri", - "start": "tauri dev", + "start": "just gpui", + "tauri:dev": "just gpui", + "leftover:tauri:dev": "tauri dev", + "legacy:tauri:dev": "tauri dev", + "leftover:tauri:build": "tauri build", + "legacy:tauri:build": "tauri build", "lint": "eslint --fix", "lint:check": "eslint . --max-warnings=0", "test": "bun run paraglide:compile && vitest run", diff --git a/src-gpui/.gitignore b/src-gpui/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/src-gpui/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock new file mode 100644 index 00000000..e402db63 --- /dev/null +++ b/src-gpui/Cargo.lock @@ -0,0 +1,8828 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "zbus", +] + +[[package]] +name = "ashpd" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a3c86f3fd70c0ffa500ed189abfa90b5a52398a45d5dc372fcc38ebeb7a645" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "serde", + "serde_repr", + "url", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.5.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "async-compression", + "crc32fast", + "futures-lite 2.6.1", + "pin-project", + "thiserror 1.0.69", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blade-graphics" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71cfb73b98eb9f58ee84048aa1bdf4e7497fd20c141b57523499fa066b48fed" +dependencies = [ + "ash", + "ash-window", + "bitflags 2.13.1", + "bytemuck", + "codespan-reporting", + "glow", + "gpu-alloc", + "gpu-alloc-ash", + "hidden-trait", + "js-sys", + "khronos-egl", + "libloading", + "log", + "mint", + "naga", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "objc2-ui-kit", + "once_cell", + "raw-window-handle", + "slab", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "blade-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "blade-util" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c" +dependencies = [ + "blade-graphics", + "bytemuck", + "log", + "profiling", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.1", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "code-gpui" +version = "3.0.1" +dependencies = [ + "anyhow", + "cc", + "diesel", + "dirs 6.0.0", + "fontdb 0.23.0", + "gpui", + "gpui-component", + "infra", + "libsqlite3-sys", + "model", + "open", + "regex", + "repo", + "rfd", + "serde", + "serde_json", + "service", + "tracing", + "tracing-subscriber", + "ureq", + "uuid", + "vt100", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "command-fds" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" +dependencies = [ + "nix 0.31.3", + "thiserror 2.0.20", +] + +[[package]] +name = "compression-codecs" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +dependencies = [ + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" +dependencies = [ + "bitflags 2.13.1", + "block", + "cfg-if", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45e71d5be22206bed53c3c3cb99315fc4c3d31b8963808c6bc4538168c4f8ef" +dependencies = [ + "block", + "core-foundation 0.10.0", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" +dependencies = [ + "bitflags 2.13.1", + "fontdb 0.16.2", + "log", + "rangemap", + "rustc-hash 1.1.0", + "rustybuzz 0.14.1", + "self_cell", + "smol_str", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diesel" +version = "2.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dtor" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.5+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.5.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-ash" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643756f08ef6def813c776199e4766596395a4c6530373c9a4374a64de2f53a1" +dependencies = [ + "ash", + "gpu-alloc-types", + "tinyvec", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707" +dependencies = [ + "anyhow", + "as-raw-xcb-connection", + "ashpd 0.11.1", + "async-task", + "bindgen", + "blade-graphics", + "blade-macros", + "blade-util", + "block", + "bytemuck", + "calloop", + "calloop-wayland-source", + "cbindgen", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "cosmic-text", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "filedescriptor", + "flume", + "foreign-types", + "futures", + "gpui-macros", + "gpui_collections", + "gpui_http_client", + "gpui_media", + "gpui_refineable", + "gpui_semantic_version", + "gpui_sum_tree", + "gpui_util", + "gpui_util_macros", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "metal", + "naga", + "num_cpus", + "objc", + "oo7", + "open", + "parking", + "parking_lot", + "pathfinder_geometry", + "pin-project", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "resvg", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "smol", + "stacksafe", + "strum 0.27.2", + "taffy", + "thiserror 2.0.20", + "usvg", + "uuid", + "waker-fn", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics", + "windows-registry 0.5.3", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-font-kit", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui-component" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d021d46b4088d3d93a57ccdf443da85695a77272108caca2f6fe5369f584966a" +dependencies = [ + "aho-corasick", + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "gpui", + "gpui-component-macros", + "gpui-macros", + "html5ever", + "itertools 0.13.0", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify 7.0.0", + "num-traits", + "once_cell", + "paste", + "regex", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", + "unicode-segmentation", + "uuid", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86fbc2d84bf91717b171320e6adc600d91ccb3ed259448f3b006787633c1c615" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb02dd63a2859714ac7b6b476937617c3c744157af1b49f7c904023a79039be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_collections" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" +dependencies = [ + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui_derive_refineable" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_http_client" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "bytes", + "derive_more", + "futures", + "gpui_util", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "url", + "zed-async-tar", + "zed-reqwest", +] + +[[package]] +name = "gpui_media" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "gpui_perf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40a0961dcf598955130e867f4b731150a20546427b41b1a63767c1037a86d77" +dependencies = [ + "gpui_collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui_refineable" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258cb099254e9468181aee5614410fba61db4ae115fc1d51b4a0b985f60d6641" +dependencies = [ + "gpui_derive_refineable", +] + +[[package]] +name = "gpui_semantic_version" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201e45eff7b695528fb3af6560a534943fbc2db5323d755b9d198bd743948e35" +dependencies = [ + "anyhow", + "serde", +] + +[[package]] +name = "gpui_sum_tree" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" +dependencies = [ + "arrayvec", + "log", + "rayon", +] + +[[package]] +name = "gpui_util" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "command-fds", + "dirs 4.0.0", + "dunce", + "futures", + "futures-lite 1.13.0", + "globset", + "gpui_collections", + "itertools 0.14.0", + "libc", + "log", + "nix 0.29.0", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex 1.3.0", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "walkdir", + "which", +] + +[[package]] +name = "gpui_util_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" +dependencies = [ + "gpui_perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "grid" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hidden-trait" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infra" +version = "0.1.0" +dependencies = [ + "diesel", + "diesel_migrations", + "flate2", + "ignore", + "model", + "notify 8.2.0", + "pinyin", + "portable-pty", + "serde", + "serde_json", + "slug", + "tar", + "tempfile", + "tracing", + "tracing-subscriber", + "zip", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.0", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "model" +version = "0.1.0" +dependencies = [ + "diesel", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases 0.2.2", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify 0.11.5", + "kqueue", + "libc", + "log", + "mio", + "notify-types 2.1.0", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oo7" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +dependencies = [ + "aes", + "ashpd 0.12.3", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-lite 2.6.1", + "futures-util", + "getrandom 0.3.4", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "rand 0.9.5", + "serde", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinyin" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.5.0", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs 1.2.1", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa 0.25.0", + "core-graphics 0.23.2", + "objc", + "raw-window-handle", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "repo" +version = "0.1.0" +dependencies = [ + "diesel", + "model", + "tracing", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd 0.11.1", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring 0.4.0", + "unicode-ccc 0.4.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "service" +version = "0.1.0" +dependencies = [ + "diesel", + "dirs 6.0.0", + "infra", + "model", + "notify 8.2.0", + "repo", + "tracing", + "uuid", + "vt100", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" + +[[package]] +name = "sval_buffer" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr 1.6.1", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz 0.20.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.20", + "windows 0.61.3", + "windows-future", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.2", + "futures-core", + "futures-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zed-async-tar" +version = "0.5.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf4b5f655e29700e473cb1acd914ab112b37b62f96f7e642d5fc6a0c02eb881" +dependencies = [ + "async-std", + "filetime", + "libc", + "pin-project", + "redox_syscall 0.2.16", + "xattr 0.2.3", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-sum-tree" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" +dependencies = [ + "arrayvec", + "log", + "rayon", + "workspace-hack", +] + +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml new file mode 100644 index 00000000..92b99e85 --- /dev/null +++ b/src-gpui/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "code-gpui" +version = "3.0.1" +edition = "2021" +description = "2code desktop app rewritten in GPUI" +authors = ["AkaraChen"] + +[[bin]] +name = "2code" +path = "src/main.rs" + +[dependencies] +model = { path = "../src-tauri/crates/model" } +repo = { path = "../src-tauri/crates/repo" } +infra = { path = "../src-tauri/crates/infra" } +service = { path = "../src-tauri/crates/service" } + +gpui = "0.2.2" +gpui-component = { version = "0.5.1", features = ["tree-sitter-languages"] } +# Unify with tree-sitter-sequel (~1.2) and libsqlite3-sys (^1.2). +cc = "=1.2.67" +diesel = { version = "2", features = ["sqlite"] } +anyhow = "1" +dirs = "6" +rfd = "0.15" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +vt100 = "0.16" +uuid = { version = "1", features = ["v4"] } +open = "5" +ureq = { version = "2", features = ["json"] } +fontdb = "0.23" +regex = "1" + +[target.'cfg(target_os = "windows")'.dependencies] +libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/src-gpui/README.md b/src-gpui/README.md new file mode 100644 index 00000000..48716127 --- /dev/null +++ b/src-gpui/README.md @@ -0,0 +1,31 @@ +# 2code GPUI + +Native desktop rewrite of 2code using [GPUI](https://gpui.rs) and [gpui-component](https://github.com/longbridge/gpui-component). This crate is the primary product shell. It calls the existing `model` / `repo` / `service` / `infra` crates directly (no IPC). The React/Tauri UI remains in the repo as a reference implementation. + +## Run + +```bash +# from repo root +just start +# or +just gpui +# or +cd src-gpui && cargo run +``` + +`./start.sh` and `bun start` also launch this GPUI shell. The legacy Tauri path is `just tauri-dev` / `bun tauri:dev`. + +Requires Rust 1.87+ (GPUI). On Linux install `libxkbcommon`, Vulkan, and Wayland/X11 development libraries. + +## Window contract + +- Main window `2code`: 1440×900, overlay title bar, traffic lights at (16, 24) +- Settings window `Settings`: 880×640, min 600×420 +- Same SQLite DB as Tauri: `$XDG_DATA_HOME/com.akrc.code/app.db` (or platform equivalent) +- Preferences: `gpui-prefs.json` next to the DB + +## Surfaces + +App sidebar, Home empty state, Profile workspace (top bar, Files/Git/Notes, unified tabs), PTY terminals (vt100 cell grid, never destroyed on tab switch, restored from the session DB on launch, drag-to-PTY paths, clickable links), file viewer (text / markdown / image / archive), file-tree multi-select + inline rename, command palette, Git panel + unified/split two-column diff, all product dialogs, toasts, debug FAB, settings (6 tabs, system font/sound pickers), GitHub updater, filesystem watcher, en/zh i18n from `messages/*.json`. + +See `docs/ui-inventory.md` for the rewrite spec this crate implements. diff --git a/src-gpui/assets/app-icon.png b/src-gpui/assets/app-icon.png new file mode 100644 index 00000000..9486f690 Binary files /dev/null and b/src-gpui/assets/app-icon.png differ diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs new file mode 100644 index 00000000..8ded7a0d --- /dev/null +++ b/src-gpui/src/app.rs @@ -0,0 +1,3524 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::Duration; + +use gpui::{ + div, prelude::*, px, Action, App, ClipboardEntry, ClipboardItem, Context, Entity, EntityInputHandler, FocusHandle, + Focusable, KeyBinding, Timer, Window, WindowHandle, +}; + +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenSettings; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct ToggleDebug; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenPalette; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct NewTerminal; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct CloseActiveTab; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct ToggleProfileSidebar; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenGitDiff; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct SaveFile; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct CommitChanges; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct DismissOverlay; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct FindInTerminal; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct IncreaseFontSize; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct DecreaseFontSize; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct WrapBold; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct WrapItalic; +use crate::backend::{self, Backend}; +use crate::i18n::{self, Locale}; +use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; +use crate::state::{ + AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, MdMenu, NotesStatus, OpenFileTab, + OverlayState, Route, SettingsTab, SidebarMode, SidebarNavItem, TermSession, ToastKind, TreeNode, UnifiedTab, + Workspace, +}; +use crate::ui; +use gpui_component::input::{Input, InputState}; +use gpui_component::{Root, WindowExt}; + +pub struct Inputs { + pub project_name: Entity, + pub profile_branch: Entity, + pub group_name: Entity, + pub rename: Entity, + pub commit_summary: Entity, + pub commit_body: Entity, + pub notes: Entity, + pub palette: Entity, + pub file_editor: Entity, + pub file_search: Entity, + pub debug_search: Entity, + pub branch_search: Entity, + pub new_path: Entity, + pub worktree: Entity, + pub init_script: Entity, + pub setup_script: Entity, + pub teardown_script: Entity, + pub template_name: Entity, + pub template_shell: Entity, + pub template_cwd: Entity, + pub template_commands: Entity, + pub custom_shell: Entity, + pub default_worktree: Entity, + pub term_search: Entity, + pub review_comment: Entity, + pub md_link: Entity, +} + +pub struct AppView { + pub backend: Backend, + pub data: AppData, + pub inputs: Inputs, + pub focus: FocusHandle, + pub settings_window: Option>, + pub settings_view: Option>, +} + +impl AppView { + pub fn new(backend: Backend, window: &mut Window, cx: &mut Context) -> Self { + fn input(window: &mut Window, cx: &mut Context, placeholder: &str, multi: bool) -> Entity { + let ph = placeholder.to_string(); + cx.new(|cx| { + let mut state = InputState::new(window, cx).placeholder(ph); + if multi { + state = state.multi_line(true); + } + state + }) + } + + let prefs = Prefs::load(&backend.app_data_dir); + let locale = prefs.language; + let data = AppData { + locale, + prefs: prefs.clone(), + projects: Vec::new(), + groups: Vec::new(), + route: Route::Home, + current_project: None, + current_profile: None, + workspaces: HashMap::new(), + overlay: OverlayState::default(), + toasts: Vec::new(), + toast_seq: 0, + sidebar_error: None, + notes_dirty_since: None, + notes_bound_profile: None, + file_dirty_since: None, + avatars: HashMap::new(), + }; + + let inputs = Inputs { + project_name: input(window, cx, "", false), + profile_branch: input(window, cx, "", false), + group_name: input(window, cx, "", false), + rename: input(window, cx, "", false), + commit_summary: input(window, cx, "", false), + commit_body: input(window, cx, "", true), + notes: input(window, cx, "", true), + palette: input(window, cx, "", false), + file_editor: cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .code_editor("text") + .line_number(true) + .soft_wrap(false) + }), + file_search: input(window, cx, &i18n::t(locale, "fileViewerFindInFile"), false), + debug_search: input(window, cx, "", false), + branch_search: input(window, cx, "", false), + new_path: input(window, cx, "", false), + worktree: input(window, cx, "", false), + init_script: input(window, cx, "", true), + setup_script: input(window, cx, "", true), + teardown_script: input(window, cx, "", true), + template_name: input(window, cx, "", false), + template_shell: input(window, cx, "", false), + template_cwd: input(window, cx, "", false), + template_commands: input(window, cx, "", true), + custom_shell: input(window, cx, "", false), + default_worktree: input(window, cx, "", false), + term_search: input(window, cx, "", false), + review_comment: input(window, cx, "", true), + md_link: input(window, cx, "https://", false), + }; + + inputs.project_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectNamePlaceholderFolder"), window, cx); + }); + inputs.profile_branch.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "branchNamePlaceholder"), window, cx); + }); + inputs.group_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectGroupNamePlaceholder"), window, cx); + }); + inputs.commit_summary.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitCommitSummaryPlaceholder"), window, cx); + }); + inputs.commit_body.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitCommitBodyPlaceholder"), window, cx); + }); + inputs.notes.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "notesPlaceholder"), window, cx); + }); + inputs.palette.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "commandPalettePlaceholder"), window, cx); + }); + inputs.branch_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "searchBranchesPlaceholder"), window, cx); + }); + inputs.debug_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "debugSearchPlaceholder"), window, cx); + }); + inputs.term_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalSearchPlaceholder"), window, cx); + }); + inputs.file_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "fileViewerFindInFile"), window, cx); + }); + inputs.review_comment.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitReviewCommentPlaceholder"), window, cx); + }); + inputs.worktree.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectWorktreeDirPlaceholder"), window, cx); + }); + inputs.default_worktree.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "defaultWorktreeDirPlaceholder"), window, cx); + s.set_value(prefs.worktree_dir.clone(), window, cx); + }); + inputs.init_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.setup_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.teardown_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.template_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateNamePlaceholder"), window, cx); + }); + inputs.template_shell.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateShellPlaceholder"), window, cx); + }); + inputs.template_cwd.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateCwdPlaceholder"), window, cx); + }); + inputs.custom_shell.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "customShellPlaceholder"), window, cx); + s.set_value(prefs.custom_shell.clone(), window, cx); + }); + + let view = Self { + backend, + data, + inputs, + focus: cx.focus_handle(), + settings_window: None, + settings_view: None, + }; + + cx.spawn(async move |this, cx| loop { + Timer::after(Duration::from_millis(50)).await; + if this + .update(cx, |this, cx| { + this.tick(cx); + cx.notify(); + }) + .is_err() + { + break; + } + }) + .detach(); + + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_secs(1)).await; + let accept_beta = this.update(cx, |app, _| app.data.prefs.accept_beta).unwrap_or(false); + let result = crate::updater::check_for_update(accept_beta); + let _ = this.update(cx, |app, cx| { + app.apply_update_result(result, true); + cx.notify(); + }); + }) + .detach(); + + let mut view = view; + view.reload_projects(); + view.restore_all_sessions(); + if view.data.projects.is_empty() { + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_millis(300)).await; + let _ = this.update(cx, |app, cx| { + if app.data.projects.is_empty() && app.data.overlay.dialog.is_none() { + app.data.overlay.onboarding = true; + cx.notify(); + } + }); + }) + .detach(); + } else if let Some((project_id, profile_id)) = view.data.projects.first().and_then(|first| { + first + .profiles + .iter() + .find(|p| p.is_default) + .or_else(|| first.profiles.first()) + .map(|p| (first.id.clone(), p.id.clone())) + }) { + view.open_profile(&project_id, &profile_id); + } + view + } + + pub fn t(&self, key: &str) -> String { + i18n::t(self.data.locale, key) + } + + pub fn tf(&self, key: &str, pairs: &[(&str, &str)]) -> String { + i18n::tf(self.data.locale, key, pairs) + } + + pub fn persist_prefs(&self) { + self.data.prefs.save(&self.backend.app_data_dir); + } + + pub fn apply_theme(&self, window: &mut Window, cx: &mut App) { + let mode = match self.data.prefs.theme { + ThemePref::Dark => gpui_component::ThemeMode::Dark, + ThemePref::Light => gpui_component::ThemeMode::Light, + ThemePref::System => cx.window_appearance().into(), + }; + gpui_component::Theme::change(mode, Some(window), cx); + let scale = self.data.prefs.radius.scale(); + let theme = gpui_component::Theme::global_mut(cx); + theme.radius = px(6.0 * scale); + theme.radius_lg = px(8.0 * scale); + if !self.data.prefs.font_family.is_empty() { + theme.mono_font_family = self.data.prefs.font_family.clone().into(); + theme.mono_font_size = px(self.data.prefs.font_size); + } + } + + pub fn reload_projects(&mut self) { + match self.backend.list_projects() { + Ok(list) => { + self.data.projects = list; + self.data.sidebar_error = None; + } + Err(err) => self.data.sidebar_error = Some(err.to_string()), + } + self.data.groups = self.backend.list_groups().unwrap_or_default(); + self.data.avatars = self + .data + .projects + .iter() + .filter_map(|p| self.backend.github_avatar(&p.id).map(|url| (p.id.clone(), url))) + .collect(); + } + + pub fn open_profile(&mut self, project_id: &str, profile_id: &str) { + self.data.current_project = Some(project_id.to_string()); + self.data.current_profile = Some(profile_id.to_string()); + self.data.route = Route::Workspace; + self.ensure_workspace(project_id, profile_id); + self.refresh_workspace(profile_id); + } + + fn ensure_workspace(&mut self, project_id: &str, profile_id: &str) { + if self.data.workspaces.contains_key(profile_id) { + return; + } + let project = self.data.project(project_id); + let profile = project.and_then(|p| p.profiles.iter().find(|pr| pr.id == profile_id)); + let Some(project) = project.cloned() else { + return; + }; + let Some(profile) = profile.cloned() else { + return; + }; + let branch = if profile.is_default { + self.backend + .git_branch(&profile.worktree_path) + .unwrap_or_else(|_| profile.branch_name.clone()) + } else { + profile.branch_name.clone() + }; + let config = self.backend.project_config(project_id).unwrap_or_default(); + self.data.workspaces.insert( + profile_id.to_string(), + Workspace { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + branch, + worktree: profile.worktree_path.clone(), + project_name: project.name.clone(), + is_default: profile.is_default, + sidebar_mode: SidebarMode::Files, + sidebar_open: self.data.prefs.profile_sidebar_open, + terminals: Vec::new(), + files: Vec::new(), + active: None, + tree: HashMap::new(), + tree_selected: HashSet::new(), + tree_anchor: None, + tree_error: None, + tree_scroll_y: 0.0, + git_files: Vec::new(), + git_included: HashSet::new(), + git_stats: Default::default(), + git_ahead: 0, + notes: profile.notes.clone(), + notes_status: NotesStatus::Saved, + pr: None, + pr_error: None, + avatar: self.backend.github_avatar(project_id), + config, + }, + ); + self.load_tree_root(profile_id); + } + + pub fn refresh_workspace(&mut self, profile_id: &str) { + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + ws.git_stats = self.backend.git_diff_stats(profile_id).unwrap_or_default(); + ws.git_ahead = self.backend.git_ahead(profile_id).unwrap_or(0); + ws.git_files = self + .backend + .tree_git_status(profile_id) + .unwrap_or_default() + .into_iter() + .map(|e| (e.path, e.status)) + .collect(); + ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + match self.backend.pr_status(&ws.worktree, Some(&ws.branch)) { + Ok(pr) => { + ws.pr = pr; + ws.pr_error = None; + } + Err(err) => { + ws.pr = None; + ws.pr_error = Some(err.to_string()); + } + } + if ws.is_default { + if let Ok(branch) = self.backend.git_branch(&ws.worktree) { + ws.branch = branch; + } + } + } + self.inject_git_paths(profile_id); + } + + pub fn load_tree_root(&mut self, profile_id: &str) { + self.load_tree_children(profile_id, None); + } + + pub fn load_tree_children(&mut self, profile_id: &str, parent: Option<&str>) { + let result = self.backend.list_tree_children(profile_id, parent); + let worktree = self + .data + .workspaces + .get(profile_id) + .map(|w| w.worktree.clone()) + .unwrap_or_default(); + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + match result { + Ok(paths) => { + ws.tree_error = None; + let mut names = Vec::new(); + for path in paths { + let abs = Path::new(&worktree).join(&path); + let is_dir = abs.is_dir(); + let name = backend::file_name(&path); + names.push(path.clone()); + ws.tree.entry(path.clone()).or_insert_with(|| TreeNode { + path: path.clone(), + name, + is_dir, + expanded: false, + children_loaded: false, + children: Vec::new(), + }); + } + if let Some(parent) = parent { + if let Some(node) = ws.tree.get_mut(parent) { + node.children = names; + node.children_loaded = true; + node.expanded = true; + } + } else { + ws.tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: ".".into(), + is_dir: true, + expanded: true, + children_loaded: true, + children: names, + }, + ); + } + } + Err(err) => ws.tree_error = Some(err.to_string()), + } + self.inject_git_paths(profile_id); + } + + fn inject_git_paths(&mut self, profile_id: &str) { + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + let files: Vec = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + crate::state::inject_git_paths(&mut ws.tree, files); + } + + pub fn toggle_dir(&mut self, profile_id: &str, path: &str) { + let needs_load = self + .data + .workspaces + .get(profile_id) + .and_then(|w| w.tree.get(path)) + .map(|n| n.is_dir && !n.children_loaded) + .unwrap_or(false); + if needs_load { + self.load_tree_children(profile_id, Some(path)); + return; + } + if let Some(node) = self + .data + .workspaces + .get_mut(profile_id) + .and_then(|w| w.tree.get_mut(path)) + { + if node.is_dir { + node.expanded = !node.expanded; + } + } + } + + pub fn open_file(&mut self, profile_id: &str, path: &str, window: &mut Window, cx: &mut Context) { + let previewable = backend::is_previewable(path); + let (content, load_error) = if previewable { + (String::new(), None) + } else { + match self.backend.read_file(profile_id, path) { + Ok(content) => (content, None), + Err(err) => (String::new(), Some(err.to_string())), + } + }; + let preview = if previewable { + self.backend.file_preview(profile_id, path).ok() + } else { + None + }; + let preview_path = preview.as_ref().map(|p| p.file_path.clone()).unwrap_or_default(); + let archive_entries = preview + .as_ref() + .and_then(|p| p.archive_entries.clone()) + .unwrap_or_default() + .into_iter() + .map(|e| (e.path, e.kind)) + .collect(); + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + if let Some(ix) = ws.files.iter().position(|f| f.path == path) { + ws.active = Some(UnifiedTab::File { index: ix }); + if !ws.files[ix].preview { + let draft = ws.files[ix].draft.clone(); + let file_path = ws.files[ix].path.clone(); + self.bind_file_editor(&file_path, &draft, window, cx); + } + return; + } + let tab = OpenFileTab { + path: path.to_string(), + title: backend::file_name(path), + content: content.clone(), + draft: content.clone(), + preview: previewable, + preview_kind: preview.as_ref().map(|p| p.kind.clone()).unwrap_or_default(), + binary_note: preview.map(|p| p.mime_type).unwrap_or_default(), + preview_path, + archive_entries, + load_error, + }; + ws.files.push(tab); + ws.active = Some(UnifiedTab::File { + index: ws.files.len() - 1, + }); + if !previewable { + self.bind_file_editor(path, &content, window, cx); + } + } + + pub fn bind_file_editor(&mut self, path: &str, content: &str, window: &mut Window, cx: &mut Context) { + let lang = backend::language_from_path(path); + let show_gutter = !backend::is_markdown(path); + self.inputs.file_editor.update(cx, |s, cx| { + s.set_highlighter(lang, cx); + s.set_line_number(show_gutter, window, cx); + s.set_soft_wrap(!show_gutter, window, cx); + s.set_value(content.to_string(), window, cx); + }); + } + + pub fn save_active_file(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let draft = self.inputs.file_editor.read(cx).value().to_string(); + let (title, path) = { + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + let Some(file) = ws.active_file_mut() else { + return; + }; + if file.preview { + return; + } + file.draft = draft.clone(); + (file.title.clone(), file.path.clone()) + }; + match self.backend.write_file(&profile_id, &path, &draft) { + Ok(()) => { + if let Some(file) = self + .data + .workspaces + .get_mut(&profile_id) + .and_then(|w| w.active_file_mut()) + { + file.content = draft; + file.draft = file.content.clone(); + } + self.data.push_toast(ToastKind::Success, self.t("save"), title); + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + let _ = window; + } + + pub fn request_close_file(&mut self, profile_id: &str, path: &str) { + let dirty = self + .data + .workspaces + .get(profile_id) + .and_then(|w| w.files.iter().find(|f| f.path == path)) + .map(|f| f.dirty()) + .unwrap_or(false); + if dirty { + self.data.overlay.dialog = Some(DialogKind::CloseUnsaved); + self.data.overlay.dialog_file = Some(path.to_string()); + self.data.overlay.pending_close_file = Some(path.to_string()); + } else { + self.close_file(profile_id, path); + } + } + + pub fn close_file(&mut self, profile_id: &str, path: &str) { + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + if let Some(ix) = ws.files.iter().position(|f| f.path == path) { + ws.files.remove(ix); + ws.active = if !ws.terminals.is_empty() { + Some(UnifiedTab::Terminal { index: 0 }) + } else if !ws.files.is_empty() { + Some(UnifiedTab::File { index: 0 }) + } else { + None + }; + } + } + } + + pub fn create_terminal(&mut self, title: &str, cwd: &str, commands: Vec) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let Some(ws) = self.data.workspaces.get(&profile_id) else { + return; + }; + let worktree = ws.worktree.clone(); + let cwd_abs = if cwd.is_empty() { + worktree + } else { + Path::new(&worktree).join(cwd).to_string_lossy().into_owned() + }; + let shell = self.data.prefs.effective_shell(); + let title = if title.is_empty() { + self.t("terminal") + } else { + title.to_string() + }; + match self + .backend + .create_terminal(&profile_id, &title, &cwd_abs, &shell, commands) + { + Ok(id) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.terminals.push(TermSession::new(id, title, profile_id.clone())); + ws.active = Some(UnifiedTab::Terminal { + index: ws.terminals.len() - 1, + }); + } + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + } + + pub fn close_terminal(&mut self, profile_id: &str, session_id: &str) { + let _ = self.backend.close_terminal(session_id); + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + ws.terminals.retain(|t| t.id != session_id); + ws.active = if !ws.terminals.is_empty() { + Some(UnifiedTab::Terminal { index: 0 }) + } else if !ws.files.is_empty() { + Some(UnifiedTab::File { index: 0 }) + } else { + None + }; + } + } + + pub fn write_path_to_pty(&mut self, session_id: &str, path: &str) { + let quoted = if path.contains(' ') { + format!("\"{path}\" ") + } else { + format!("{path} ") + }; + let _ = self.backend.write_pty(session_id, quoted.as_bytes()); + } + + pub fn write_to_active_pty(&mut self, bytes: &[u8]) { + if let Some(ws) = self.data.current_ws() { + if let Some(UnifiedTab::Terminal { index }) = ws.active { + if let Some(term) = ws.terminals.get(index) { + let _ = self.backend.write_pty(&term.id, bytes); + } + } + } + } + + pub fn clear_active_terminal(&mut self) { + let Some(id) = self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.id.clone()) + else { + return; + }; + let _ = self.backend.clear_pty_output(&id); + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.clear_screen(); + } + self.write_to_active_pty(&[0x0c]); + } + + pub fn tick(&mut self, cx: &mut Context) { + let started = std::time::Instant::now(); + self.data.expire_toasts(); + let ids: Vec<(String, String)> = self + .data + .workspaces + .iter() + .flat_map(|(pid, ws)| { + ws.terminals + .iter() + .map(|t| (pid.clone(), t.id.clone())) + .collect::>() + }) + .collect(); + for (profile_id, session_id) in ids { + let bytes = self.backend.take_output(&session_id); + if bytes.is_empty() { + continue; + } + let mut became_waiting = false; + if let Some(term) = self + .data + .workspaces + .get_mut(&profile_id) + .and_then(|w| w.terminals.iter_mut().find(|t| t.id == session_id)) + { + let before = term.agent; + term.feed(&bytes); + became_waiting = before != AgentStatus::Waiting && term.agent == AgentStatus::Waiting; + } + if became_waiting { + self.notify_agent_waiting(); + } + } + if let Ok(exits) = self.backend.exits.lock() { + let _ = exits.len(); + } + self.drain_watch_events(); + self.drain_debug_logs(); + self.autosave_notes(cx); + self.sync_file_draft(cx); + if self.data.prefs.performance_profile { + let tick_ms = started.elapsed().as_millis(); + if tick_ms >= 8 { + tracing::info!(target: "perf", tick_ms, "ui tick"); + let dir = self.backend.app_data_dir.join("profiles"); + let _ = std::fs::create_dir_all(&dir); + let line = format!( + "{{\"tick_ms\":{tick_ms},\"ts\":{}}}\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("frontend-perf.jsonl")) + { + use std::io::Write; + let _ = file.write_all(line.as_bytes()); + } + } + } + } + + fn drain_debug_logs(&mut self) { + let incoming = self.backend.take_debug_logs(); + if incoming.is_empty() { + return; + } + self.data.overlay.debug_logs.extend(incoming); + if self.data.overlay.debug_logs.len() > crate::backend::DEBUG_LOG_CAP { + let extra = self.data.overlay.debug_logs.len() - crate::backend::DEBUG_LOG_CAP; + self.data.overlay.debug_logs.drain(0..extra); + } + } + + fn notify_agent_waiting(&self) { + if !self.data.prefs.notifications { + return; + } + let sound = self.data.prefs.notification_sound.clone(); + if !sound.is_empty() { + let _ = crate::platform::play_system_sound(&sound); + } + if let Some(term) = self + .data + .workspaces + .values() + .flat_map(|ws| ws.terminals.iter()) + .find(|t| t.agent == AgentStatus::Waiting) + { + let title = if term.agent_kind == crate::state::AgentKind::Unknown { + self.t("agentWaitingNotificationTitleGeneric") + } else { + i18n::tf( + self.data.locale, + "agentWaitingNotificationTitle", + &[("agent", term.agent_kind.label())], + ) + }; + let body = i18n::tf( + self.data.locale, + "agentWaitingNotificationBody", + &[("tab", term.title.as_str())], + ); + crate::platform::send_notification(&title, &body); + } + } + + fn sync_file_draft(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let live = self.inputs.file_editor.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + let Some(file) = ws.active_file_mut() else { + self.data.file_dirty_since = None; + return; + }; + if file.preview || file.draft == live { + self.data.file_dirty_since = None; + return; + } + match self.data.file_dirty_since { + None => self.data.file_dirty_since = Some(std::time::Instant::now()), + Some(since) if since.elapsed() < Duration::from_millis(400) => {} + Some(_) => { + file.draft = live; + self.data.file_dirty_since = None; + } + } + } + + fn drain_watch_events(&mut self) { + let events = self.backend.take_watch_events(); + if events.is_empty() { + return; + } + let mut profiles = std::collections::HashSet::new(); + for event in events { + if let Some(pid) = event.profile_id { + profiles.insert(pid); + } else if let Some(default) = self.data.default_profile_of(&event.project_id) { + profiles.insert(default.id); + } + } + for profile_id in profiles { + if self.data.workspaces.contains_key(&profile_id) { + self.load_tree_root(&profile_id); + self.refresh_workspace(&profile_id); + } + } + } + + fn restore_all_sessions(&mut self) { + let Ok(sessions) = self.backend.list_all_sessions() else { + return; + }; + for record in sessions { + let Some(project_id) = self + .data + .projects + .iter() + .find(|p| p.profiles.iter().any(|pr| pr.id == record.profile_id)) + .map(|p| p.id.clone()) + else { + continue; + }; + self.ensure_workspace(&project_id, &record.profile_id); + match self.backend.restore_session(&record) { + Ok((new_id, history)) => { + if let Some(ws) = self.data.workspaces.get_mut(&record.profile_id) { + let mut term = TermSession::new(new_id, record.title.clone(), record.profile_id.clone()); + let _ = term.set_size(record.rows.max(1) as u16, record.cols.max(1) as u16); + if !history.is_empty() { + term.feed(&history); + } + ws.terminals.push(term); + ws.active = Some(UnifiedTab::Terminal { + index: ws.terminals.len() - 1, + }); + } + } + Err(err) => { + tracing::warn!( + error = %err, + session = %record.id, + "failed to restore pty session" + ); + } + } + } + } + + fn sync_notes_input(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if self.data.notes_bound_profile.as_deref() == Some(profile_id.as_str()) { + return; + } + if let Some(prev) = self.data.notes_bound_profile.clone() { + if self.data.notes_dirty_since.is_some() { + let notes = self.inputs.notes.read(cx).value().to_string(); + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes = notes.clone(); + ws.notes_status = NotesStatus::Saving; + } + match self.backend.update_notes(&prev, ¬es) { + Ok(()) => { + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes_status = NotesStatus::Saved; + } + } + Err(_) => { + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes_status = NotesStatus::Failed; + } + } + } + self.data.notes_dirty_since = None; + } + } + let notes = self + .data + .workspaces + .get(&profile_id) + .map(|w| w.notes.clone()) + .unwrap_or_default(); + self.inputs.notes.update(cx, |s, cx| { + s.set_value(notes, window, cx); + }); + self.data.notes_bound_profile = Some(profile_id); + } + + pub fn click_tree_path( + &mut self, + path: &str, + is_dir: bool, + multi: bool, + range: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(profile) = self.data.current_profile.clone() else { + return; + }; + if range { + let paths = self + .data + .workspaces + .get(&profile) + .map(visible_tree_paths) + .unwrap_or_default(); + let anchor = self + .data + .workspaces + .get(&profile) + .and_then(|w| w.tree_anchor.clone()) + .unwrap_or_else(|| path.to_string()); + if let (Some(a), Some(b)) = ( + paths.iter().position(|p| p == &anchor), + paths.iter().position(|p| p == path), + ) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + ws.tree_selected = paths[lo..=hi].iter().cloned().collect(); + ws.tree_anchor = Some(anchor); + } + } + return; + } + if multi { + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + if !ws.tree_selected.remove(path) { + ws.tree_selected.insert(path.to_string()); + } + ws.tree_anchor = Some(path.to_string()); + } + return; + } + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + ws.tree_selected.clear(); + ws.tree_selected.insert(path.to_string()); + ws.tree_anchor = Some(path.to_string()); + } + if is_dir { + self.toggle_dir(&profile, path); + } else if !self.is_deleted_tree_path(&profile, path) { + self.open_file(&profile, path, window, cx); + } + } + + pub fn nudge_sidebar(&mut self, profile: bool, key: &str) -> bool { + let (min, max) = if profile { + (180.0_f32, 560.0_f32) + } else { + (220.0_f32, 420.0_f32) + }; + let width = if profile { + &mut self.data.prefs.profile_sidebar_width + } else { + &mut self.data.prefs.sidebar_width + }; + let next = match key { + "left" => *width - 16.0, + "right" => *width + 16.0, + "home" => min, + "end" => max, + _ => return false, + } + .clamp(min, max); + if (*width - next).abs() < f32::EPSILON { + return false; + } + *width = next; + self.persist_prefs(); + true + } + + fn autosave_notes(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if self.data.notes_bound_profile.as_deref() != Some(profile_id.as_str()) { + return; + } + let live = self.inputs.notes.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + if live == ws.notes { + return; + } + ws.notes_status = NotesStatus::Saving; + match self.data.notes_dirty_since { + None => self.data.notes_dirty_since = Some(std::time::Instant::now()), + Some(since) if since.elapsed() < Duration::from_millis(800) => {} + Some(_) => { + self.data.notes_dirty_since = None; + self.save_notes(cx); + } + } + } + + fn is_deleted_tree_path(&self, profile: &str, path: &str) -> bool { + self.data + .workspaces + .get(profile) + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path || p.ends_with(path))) + .is_some_and(|(_, status)| git_status_kind(status) == GitStatusKind::Deleted) + } + + pub fn open_find(&mut self, window: &mut Window, cx: &mut Context) { + let file_tab = self + .data + .current_ws() + .and_then(|w| w.active.as_ref()) + .is_some_and(|tab| matches!(tab, UnifiedTab::File { .. })); + if file_tab { + self.data.overlay.file_search_open = true; + self.data.overlay.file_search_ix = 0; + self.inputs.file_search.update(cx, |input, cx| { + input.focus(window, cx); + }); + return; + } + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.search_open = true; + } + self.inputs.term_search.update(cx, |input, cx| { + input.focus(window, cx); + }); + } + + pub fn bump_font_size(&mut self, delta: f32) { + self.data.prefs.font_size = (self.data.prefs.font_size + delta).clamp(10.0, 20.0); + self.persist_prefs(); + } + + pub fn paste_to_pty(&mut self, cx: &mut Context) { + let Some(item) = cx.read_from_clipboard() else { + return; + }; + if let Some(text) = item.text().filter(|t| !t.is_empty()) { + self.write_to_active_pty(text.as_bytes()); + return; + } + if item + .entries() + .iter() + .any(|entry| matches!(entry, ClipboardEntry::Image(_))) + { + self.write_to_active_pty(&[0x16]); + } + } + + pub fn copy_term_selection(&mut self, cx: &mut Context) -> bool { + let Some(text) = self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.selected_text()) + else { + return false; + }; + if text.is_empty() { + return false; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true + } + + pub fn copy_term_or_interrupt(&mut self, cx: &mut Context) { + if self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .is_some_and(|t| t.has_selection()) + { + self.copy_term_selection(cx); + } else { + self.write_to_active_pty(&[0x03]); + } + } + + pub fn toggle_md_menu( + &mut self, + menu: MdMenu, + target: crate::ui::markdown::MarkupTarget, + window: &mut Window, + cx: &mut Context, + ) { + if self.data.overlay.md_menu == Some(menu) { + self.data.overlay.md_menu = None; + return; + } + self.data.overlay.md_menu = Some(menu); + if menu == MdMenu::Link { + let input = crate::ui::markdown::md_input(self, target); + let text = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + let href = crate::ui::markdown::link_href_at(&text, caret).unwrap_or_default(); + self.inputs.md_link.update(cx, |s, cx| { + s.set_placeholder("https://", window, cx); + s.set_value(href, window, cx); + }); + } + } + + pub fn run_md( + &mut self, + target: crate::ui::markdown::MarkupTarget, + action: crate::ui::markdown::MdAction, + window: &mut Window, + cx: &mut Context, + ) { + use crate::ui::markdown::{ + apply_block_prefix, apply_link, apply_slash_at, apply_table_op, insert_snippet, remove_link, MdAction, + }; + let input = crate::ui::markdown::md_input(self, target).clone(); + match action { + MdAction::Wrap(prefix, suffix) => { + wrap_markup(&input, prefix, suffix, window, cx); + self.data.overlay.md_menu = None; + } + MdAction::Block(prefix) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_block_prefix(&text, caret, prefix); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Slash(prefix, suffix) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_slash_at(&text, caret, prefix, suffix); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Insert(snippet) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = insert_snippet(&text, caret, snippet); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Table(op) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_table_op(&text, caret, op); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::ApplyLink => { + let href = self.inputs.md_link.read(cx).value().to_string(); + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let (start, end) = match state.selected_text_range(true, window, cx) { + Some(sel) => ( + utf16_offset_to_bytes(&text, sel.range.start), + utf16_offset_to_bytes(&text, sel.range.end), + ), + None => { + let caret = state.cursor().min(text.len()); + (caret, caret) + } + }; + let (next, at) = apply_link(&text, start, end, &href); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::RemoveLink => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = remove_link(&text, caret); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + } + if target == crate::ui::markdown::MarkupTarget::Notes { + self.data.notes_dirty_since = Some(std::time::Instant::now()); + } else { + self.data.file_dirty_since = Some(std::time::Instant::now()); + let draft = input.read(cx).value().to_string(); + if let Some(file) = self.data.current_ws_mut().and_then(|w| w.active_file_mut()) { + file.draft = draft; + } + } + } + + pub fn set_md_fence_language( + &mut self, + target: crate::ui::markdown::MarkupTarget, + lang: &str, + window: &mut Window, + cx: &mut Context, + ) { + let input = crate::ui::markdown::md_input(self, target).clone(); + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = crate::ui::markdown::set_fence_language(&text, caret, lang); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + if target == crate::ui::markdown::MarkupTarget::Notes { + self.data.notes_dirty_since = Some(std::time::Instant::now()); + } else if let Some(file) = self.data.current_ws_mut().and_then(|w| w.active_file_mut()) { + file.draft = input.read(cx).value().to_string(); + self.data.file_dirty_since = Some(std::time::Instant::now()); + } + } + + pub fn copy_md_fence(&mut self, target: crate::ui::markdown::MarkupTarget, cx: &mut Context) { + let input = crate::ui::markdown::md_input(self, target); + let text = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + if let Some(body) = crate::ui::markdown::fence_body(&text, caret) { + cx.write_to_clipboard(ClipboardItem::new_string(body)); + } + } + + pub fn wrap_active_markup(&mut self, prefix: &str, suffix: &str, window: &mut Window, cx: &mut Context) { + let markdown_file = self.data.current_ws().and_then(|w| match w.active { + Some(UnifiedTab::File { index }) => w.files.get(index).map(|f| backend::is_markdown(&f.path)), + _ => None, + }); + if markdown_file == Some(true) { + wrap_markup(&self.inputs.file_editor, prefix, suffix, window, cx); + return; + } + if self + .data + .current_ws() + .is_some_and(|w| w.sidebar_open && w.sidebar_mode == SidebarMode::Notes) + { + wrap_markup(&self.inputs.notes, prefix, suffix, window, cx); + } + } + + pub fn cycle_file_search(&mut self, window: &mut Window, cx: &mut Context, next: bool) { + let query = self.inputs.file_search.read(cx).value().to_string(); + let draft = self.inputs.file_editor.read(cx).value().to_string(); + let hits = search_match_offsets(&draft, &query); + if hits.is_empty() { + self.data.overlay.file_search_ix = 0; + return; + } + if next { + self.data.overlay.file_search_ix = (self.data.overlay.file_search_ix + 1) % hits.len(); + } else { + self.data.overlay.file_search_ix = if self.data.overlay.file_search_ix == 0 { + hits.len() - 1 + } else { + self.data.overlay.file_search_ix - 1 + }; + } + let offset = hits[self.data.overlay.file_search_ix]; + self.inputs.file_editor.update(cx, |state, cx| { + let (line, character) = offset_line_col(&draft, offset); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + } + + pub fn cycle_term_search(&mut self, cx: &mut Context, next: bool) { + let query = self.inputs.term_search.read(cx).value().to_string(); + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.search_query = query.clone(); + let hits = term.search_hits(&query); + if hits.is_empty() { + term.search_ix = 0; + return; + } + if next { + term.search_ix = (term.search_ix + 1) % hits.len(); + } else { + term.search_ix = if term.search_ix == 0 { + hits.len() - 1 + } else { + term.search_ix - 1 + }; + } + } + } + + pub fn sync_pty_size(&mut self, window: &Window) { + let (rows, cols) = self.estimate_pty_size(window); + let mut resized = Vec::new(); + if let Some(ws) = self.data.current_ws_mut() { + for term in &mut ws.terminals { + if term.set_size(rows, cols) { + resized.push(term.id.clone()); + } + } + } + for id in resized { + let _ = self.backend.resize_pty(&id, rows, cols); + } + } + + fn estimate_pty_size(&self, window: &Window) -> (u16, u16) { + let size = window.viewport_size(); + let (cell_w, cell_h) = + ui::terminal::measure_pty_cell(window, self.data.prefs.font_family.clone(), self.data.prefs.font_size); + ui::terminal::pty_grid_size( + f32::from(size.width), + f32::from(size.height), + cell_w, + cell_h, + self.data.prefs.sidebar_collapsed, + self.data.prefs.sidebar_width, + self.data.current_ws().map(|w| w.sidebar_open).unwrap_or(false), + self.data.prefs.profile_sidebar_width, + ) + } + + pub fn apply_update_result(&mut self, result: Result, silent: bool) { + match result { + Ok(info) if info.available => { + let title = self.tf("updateAvailableTitle", &[("version", &info.latest_version)]); + let body = self.tf( + "updateAvailableDescription", + &[ + ("currentVersion", &info.current_version), + ("version", &info.latest_version), + ], + ); + self.data + .push_toast_action(ToastKind::Info, title, body, Some(crate::state::ToastAction::OpenAbout)); + } + Ok(_) => { + if !silent { + self.data.push_toast( + ToastKind::Info, + self.t("updateNotAvailableTitle"), + self.t("updateNotAvailableDescription"), + ); + } + } + Err(err) => { + if !silent { + self.data + .push_toast(ToastKind::Error, self.t("updateCheckFailedTitle"), err); + } + } + } + self.data.overlay.update_checked = true; + } + + pub fn move_sidebar_project(&mut self, id: &str, delta: i32) { + if let Err(err) = self.backend.move_project(id, delta) { + self.data + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); + return; + } + self.reload_projects(); + } + + pub fn set_project_pinned(&mut self, id: &str, pinned: bool) { + if let Err(err) = self.backend.set_pinned(id, pinned) { + self.data + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); + return; + } + self.reload_projects(); + } + + pub fn drop_tree_paths(&mut self, sources: &[String], target_dir: Option<&str>) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if sources.is_empty() { + return; + } + if let Err(err) = self.backend.move_paths(&profile_id, sources, target_dir) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.load_tree_root(&profile_id); + } + + pub fn drop_sidebar_project(&mut self, dragged: &str, target: Option<&str>, unpin: bool) { + if let Err(err) = self.backend.drop_project(dragged, target, unpin) { + self.data + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); + return; + } + self.reload_projects(); + } + + pub fn open_project_menu(&mut self, id: String, x: f32, y: f32, window: &mut Window, cx: &mut Context) { + self.inputs.group_name.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.data.overlay.group_menu_creating = self.data.groups.is_empty(); + self.data.overlay.context_menu = Some((ContextMenu::Project { id }, x, y)); + } + + pub fn assign_project_to_group(&mut self, project_id: &str, group_id: Option) { + if let Err(err) = self.backend.assign_to_group(project_id, group_id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); + self.data.overlay.context_menu = None; + self.data.overlay.group_menu_creating = false; + } + + pub fn submit_create_group(&mut self, project_id: Option<&str>, cx: &mut Context) { + let name = self.inputs.group_name.read(cx).value().trim().to_string(); + if name.is_empty() { + return; + } + let pid = project_id + .map(|id| id.to_string()) + .or_else(|| self.data.overlay.dialog_project.clone()); + match self.backend.create_group(&name) { + Ok(group) => { + if let Some(pid) = pid { + if let Err(err) = self.backend.assign_to_group(&pid, Some(group.id)) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + } + } + self.reload_projects(); + self.data.overlay.dialog = None; + self.data.overlay.context_menu = None; + self.data.overlay.group_menu_creating = false; + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + } + + pub fn apply_picked_folder(&mut self, folder: String, window: &mut Window, cx: &mut Context) { + let empty = self.inputs.project_name.read(cx).value().trim().is_empty(); + self.data.overlay.dialog_folder = Some(folder.clone()); + if empty { + let name = suggested_project_name(&folder, ""); + self.inputs.project_name.update(cx, |s, cx| { + s.set_value(name, window, cx); + }); + } + } + + pub fn create_project_from_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let folder = match self.data.overlay.dialog_folder.clone() { + Some(f) => f, + None => return, + }; + let name = suggested_project_name(&folder, &self.inputs.project_name.read(cx).value()); + self.data.overlay.dialog_busy = true; + match self.backend.create_project(&name, &folder) { + Ok(project) => { + self.reload_projects(); + self.data.overlay.dialog = None; + self.data.overlay.dialog_busy = false; + self.data.overlay.onboarding = false; + if let Some(profile) = self.data.default_profile_of(&project.id) { + self.open_profile(&project.id, &profile.id); + } + } + Err(err) => { + self.data.overlay.dialog_busy = false; + self.data.overlay.dialog_error = Some(err.to_string()); + } + } + let _ = window; + } + + pub fn delete_current_dialog_project(&mut self) { + let Some(id) = self.data.overlay.dialog_project.clone() else { + return; + }; + if let Err(err) = self.backend.delete_project(&id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + let was_current = self.data.current_project.as_deref() == Some(id.as_str()); + self.reload_projects(); + self.data.overlay.dialog = None; + if was_current { + if let Some(next) = self.data.projects.first().cloned() { + if let Some(profile) = self.data.default_profile_of(&next.id) { + self.open_profile(&next.id, &profile.id); + return; + } + } + self.data.route = Route::Home; + self.data.current_project = None; + self.data.current_profile = None; + } + } + + pub fn rename_dialog_project(&mut self, cx: &mut Context) { + let Some(id) = self.data.overlay.dialog_project.clone() else { + return; + }; + let name = self.inputs.rename.read(cx).value().to_string(); + let init = self + .data + .projects + .iter() + .find(|p| p.id == id) + .map(|p| p.name.clone()) + .unwrap_or_default(); + if crate::state::leftover_rename_disabled(&name, &init) { + self.data.overlay.dialog = None; + return; + } + if let Err(err) = self.backend.rename_project(&id, &name) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); + self.data.overlay.dialog = None; + } + + pub fn create_profile_from_dialog(&mut self, cx: &mut Context) { + let Some(project_id) = self.data.overlay.dialog_project.clone() else { + return; + }; + let branch = self.inputs.profile_branch.read(cx).value().to_string(); + match self.backend.create_profile(&project_id, branch.trim()) { + Ok(profile) => { + self.reload_projects(); + self.data.overlay.dialog = None; + self.open_profile(&project_id, &profile.id); + } + Err(err) => self.data.overlay.dialog_error = Some(err.to_string()), + } + } + + pub fn delete_dialog_profile(&mut self) { + let Some(id) = self.data.overlay.dialog_profile.clone() else { + return; + }; + let was_current = self.data.current_profile.as_deref() == Some(id.as_str()); + let project_id = self.data.current_project.clone(); + if let Err(err) = self.backend.delete_profile(&id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.data.workspaces.remove(&id); + self.reload_projects(); + self.data.overlay.dialog = None; + if was_current { + if let Some(project_id) = project_id { + if let Some(profile) = self.data.default_profile_of(&project_id) { + self.open_profile(&project_id, &profile.id); + return; + } + } + self.data.route = Route::Home; + } + } + + pub fn prepare_delete_profile(&mut self, id: &str) { + self.data.overlay.dialog = Some(DialogKind::DeleteProfile); + self.data.overlay.dialog_profile = Some(id.to_string()); + self.data.overlay.dialog_busy = true; + self.data.overlay.delete_warning = None; + self.data.overlay.delete_check_failed = false; + match self.backend.delete_profile_check(id) { + Ok(check) => { + self.data.overlay.dialog_busy = false; + let mut parts = Vec::new(); + if check.working_tree_diff.files_changed > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileLocalChangesWarning", + &[ + ("files", &check.working_tree_diff.files_changed.to_string()), + ("insertions", &check.working_tree_diff.insertions.to_string()), + ("deletions", &check.working_tree_diff.deletions.to_string()), + ], + )); + } + if check.unpushed_commit_count > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileUnpushedCommitsWarning", + &[ + ("count", &check.unpushed_commit_count.to_string()), + ("files", &check.unpushed_commit_diff.files_changed.to_string()), + ("insertions", &check.unpushed_commit_diff.insertions.to_string()), + ("deletions", &check.unpushed_commit_diff.deletions.to_string()), + ], + )); + } + if check.total_diff.files_changed > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileTotalDiffWarning", + &[ + ("files", &check.total_diff.files_changed.to_string()), + ("insertions", &check.total_diff.insertions.to_string()), + ("deletions", &check.total_diff.deletions.to_string()), + ], + )); + } + self.data.overlay.delete_warning = crate::state::leftover_delete_profile_warning(&parts); + } + Err(_) => { + self.data.overlay.dialog_busy = false; + self.data.overlay.delete_check_failed = true; + } + } + } + + pub fn commit_selected(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let summary = self.inputs.commit_summary.read(cx).value().to_string(); + let body = self.inputs.commit_body.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get(&profile_id) else { + return; + }; + let files: Vec = ws.git_included.iter().cloned().collect(); + if summary.trim().is_empty() || files.is_empty() { + return; + } + let body = if body.trim().is_empty() { + None + } else { + Some(body.as_str()) + }; + match self.backend.commit_changes(&profile_id, &files, &summary, body) { + Ok(hash) => { + let short = crate::ui::git::leftover_short_hash(&hash); + self.data.push_toast( + ToastKind::Success, + self.t("gitCommitSuccessTitle"), + i18n::tf(self.data.locale, "gitCommitSuccessDescription", &[("hash", &short)]), + ); + self.inputs.commit_summary.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.inputs.commit_body.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.refresh_workspace(&profile_id); + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitCommitErrorTitle"), err.to_string()), + } + } + + pub fn push_current(&mut self) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.git_push(&profile_id) { + Ok(()) => { + self.data + .push_toast(ToastKind::Success, self.t("gitPushSuccessTitle"), ""); + self.refresh_workspace(&profile_id); + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitPushErrorTitle"), err.to_string()), + } + } + + pub fn discard_file(&mut self, path: &str) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.discard_file(&profile_id, &[path.to_string()]) { + Ok(()) => { + self.data.push_toast( + ToastKind::Success, + self.t("gitDiscardFileSuccessTitle"), + i18n::tf(self.data.locale, "gitDiscardFileSuccessDescription", &[("file", path)]), + ); + self.refresh_workspace(&profile_id); + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitDiscardFileErrorTitle"), err.to_string()), + } + } + + pub fn open_git_diff(&mut self) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + self.data.overlay.git_diff_open = true; + self.data.overlay.git_diff_tab = GitDiffTab::Changes; + self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + self.data.overlay.git_commits = self.backend.git_log(&profile_id, 80).unwrap_or_default(); + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_file_index = 0; + self.data.overlay.git_commit_index = None; + self.data.overlay.git_commit_file_index = 0; + self.refresh_workspace(&profile_id); + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.first().map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + } + + pub fn switch_git_tab(&mut self, tab: GitDiffTab) { + self.data.overlay.git_diff_tab = tab; + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_commit_file_index = 0; + self.data.overlay.git_commit_index = None; + if tab == GitDiffTab::Changes { + self.data.overlay.git_file_index = 0; + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.first().map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + } + } + + pub fn commit_back(&mut self) { + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_commit_file_index = 0; + self.data.overlay.git_diff_file = None; + } + + pub fn select_diff_file(&mut self, path: &str) { + self.data.overlay.git_diff_file = Some(path.to_string()); + if let Some(ix) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.iter().position(|(p, _)| p == path)) + { + self.data.overlay.git_file_index = ix; + } + if let Some(profile_id) = self.data.current_profile.clone() { + if self.data.overlay.git_selected_commit.is_none() { + self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + } + } + } + + pub fn select_review_line(&mut self, file: &str, side: crate::review::ReviewSide, line_no: u32, extend: bool) { + if line_no == 0 { + return; + } + let hunk = extract_file_hunk(&self.data.overlay.git_diff_text, file); + let prev_name = crate::diff::rename_paths(&hunk).map(|(old, _)| old); + let display_name = match &prev_name { + Some(prev) if prev != file => format!("{prev} -> {file}"), + _ => file.to_string(), + }; + let range = if extend { + if let Some(sel) = self.data.overlay.review_selection.as_ref().filter(|s| s.file == file) { + crate::review::ReviewRange { + start: sel.range.start, + end: line_no, + side: sel.range.side, + end_side: side, + } + } else { + crate::review::ReviewRange { + start: line_no, + end: line_no, + side, + end_side: side, + } + } + } else { + crate::review::ReviewRange { + start: line_no, + end: line_no, + side, + end_side: side, + } + }; + self.data.overlay.review_selection = Some(crate::review::ReviewSelection { + file: file.to_string(), + display_name, + prev_name, + range, + }); + } + + pub fn cancel_review_comment(&mut self, window: &mut Window, cx: &mut Context) { + self.data.overlay.review_selection = None; + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + } + + pub fn add_review_comment(&mut self, window: &mut Window, cx: &mut Context) { + let body = self.inputs.review_comment.read(cx).value().to_string(); + let Some(sel) = self.data.overlay.review_selection.clone() else { + return; + }; + if body.trim().is_empty() { + return; + } + let hunk = extract_file_hunk(&self.data.overlay.git_diff_text, &sel.file); + let selected_text = crate::review::selected_text_from_hunk(&hunk, sel.range); + self.data + .overlay + .review_comments + .push(crate::review::create_review_comment( + sel.file, + sel.prev_name, + sel.range, + selected_text, + body.trim(), + )); + self.cancel_review_comment(window, cx); + } + + pub fn delete_review_comment(&mut self, id: &str) { + self.data.overlay.review_comments.retain(|c| c.id != id); + if self.data.overlay.review_edit_id.as_deref() == Some(id) { + self.data.overlay.review_edit_id = None; + } + } + + pub fn begin_review_edit(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + self.flush_review_edit(window, cx); + let body = self + .data + .overlay + .review_comments + .iter() + .find(|c| c.id == id) + .map(|c| c.body.clone()) + .unwrap_or_default(); + self.data.overlay.review_edit_id = Some(id.to_string()); + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value(&body, window, cx); + }); + } + + fn flush_review_edit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(id) = self.data.overlay.review_edit_id.clone() else { + return; + }; + let body = self.inputs.review_comment.read(cx).value().to_string(); + if let Some(comment) = self.data.overlay.review_comments.iter_mut().find(|c| c.id == id) { + comment.body = body; + } + self.data.overlay.review_edit_id = None; + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + } + + pub fn select_commit(&mut self, hash: &str) { + self.data.overlay.git_selected_commit = Some(hash.to_string()); + self.data.overlay.git_commit_index = self + .data + .overlay + .git_commits + .iter() + .position(|c| c.hash == hash || c.full_hash == hash); + self.data.overlay.git_commit_file_index = 0; + if let Some(profile_id) = self.data.current_profile.clone() { + let diff = self.backend.commit_diff(&profile_id, hash).unwrap_or_default(); + self.data.overlay.git_commit_files = parse_diff_files(&diff); + self.data.overlay.git_diff_text = diff; + self.data.overlay.git_diff_file = self.data.overlay.git_commit_files.first().cloned(); + } + } + + pub fn checkout_branch(&mut self, name: &str) { + let Some(ws) = self.data.current_ws() else { + return; + }; + let folder = ws.worktree.clone(); + match self.backend.checkout_branch(&folder, name) { + Ok(()) => { + self.data.push_toast( + ToastKind::Success, + i18n::tf(self.data.locale, "gitCheckoutSuccessTitle", &[("branch", name)]), + "", + ); + if let Some(pid) = self.data.current_profile.clone() { + self.refresh_workspace(&pid); + } + self.data.overlay.dialog = None; + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitCheckoutErrorTitle"), err.to_string()), + } + } + + pub fn save_notes(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let notes = self.inputs.notes.read(cx).value().to_string(); + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Saving; + ws.notes = notes.clone(); + } + match self.backend.update_notes(&profile_id, ¬es) { + Ok(()) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Saved; + } + } + Err(_) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Failed; + } + self.data + .push_toast(ToastKind::Error, self.t("notesSaveFailedTitle"), ""); + } + } + } + + pub fn add_project_template(&mut self, cx: &mut Context) { + self.upsert_project_template(cx); + } + + pub fn open_project_template_create(&mut self, window: &mut Window, cx: &mut Context) { + self.data.overlay.editing_template = None; + self.inputs.template_name.update(cx, |s, cx| s.set_value("", window, cx)); + self.inputs.template_cwd.update(cx, |s, cx| s.set_value("", window, cx)); + self.inputs.template_commands.update(cx, |s, cx| s.set_value("", window, cx)); + self.data.overlay.template_dialog_open = true; + } + + pub fn close_project_template_draft(&mut self) { + self.data.overlay.template_dialog_open = false; + self.data.overlay.editing_template = None; + } + + pub fn delete_editing_project_template(&mut self) { + if let Some(id) = self.data.overlay.editing_template.clone() { + self.remove_project_template(&id); + } + self.close_project_template_draft(); + } + + pub fn load_project_template(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + let Some(template) = self + .data + .workspaces + .values() + .find(|w| { + Some(w.project_id.as_str()) == self.data.overlay.dialog_project.as_deref() + || Some(w.project_id.as_str()) == self.data.current_project.as_deref() + }) + .and_then(|w| w.config.terminal_templates.iter().find(|t| t.id == id).cloned()) + else { + return; + }; + self.data.overlay.editing_template = Some(id.to_string()); + self.data.overlay.template_dialog_open = true; + self.inputs.template_name.update(cx, |s, cx| { + s.set_value(template.name.clone(), window, cx); + }); + self.inputs.template_cwd.update(cx, |s, cx| { + s.set_value(template.cwd.clone(), window, cx); + }); + self.inputs.template_commands.update(cx, |s, cx| { + s.set_value(template.commands.join("\n"), window, cx); + }); + } + + pub fn upsert_project_template(&mut self, cx: &mut Context) { + let name = self.inputs.template_name.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + let editing = self.data.overlay.editing_template.clone(); + let cwd = self.inputs.template_cwd.read(cx).value().to_string(); + let commands = lines(&self.inputs.template_commands.read(cx).value()); + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { + if let Some(id) = editing.as_deref() { + if let Some(template) = ws.config.terminal_templates.iter_mut().find(|t| t.id == id) { + template.name = name; + template.cwd = cwd; + template.commands = commands; + self.data.overlay.editing_template = None; + return; + } + } + ws.config + .terminal_templates + .push(model::project::ProjectTerminalTemplate { + id: uuid::Uuid::new_v4().to_string(), + name, + cwd, + commands, + }); + } + self.data.overlay.editing_template = None; + } + + pub fn save_editing_template(&mut self, cx: &mut Context) { + let name = self.inputs.template_name.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + let id = self.data.overlay.editing_template.clone(); + let shell = self.inputs.template_shell.read(cx).value().to_string(); + let cwd = self.inputs.template_cwd.read(cx).value().to_string(); + let commands = lines(&self.inputs.template_commands.read(cx).value()); + if id.as_ref().is_some_and(|id| { + self.data + .workspaces + .values() + .any(|w| w.config.terminal_templates.iter().any(|t| t.id == *id)) + }) { + self.upsert_project_template(cx); + } else { + crate::prefs::upsert_template( + &mut self.data.prefs.templates, + id.as_deref(), + name, + shell, + cwd, + commands, + ); + self.persist_prefs(); + } + self.data.overlay.editing_template = None; + self.data.overlay.template_dialog_open = false; + if self.data.overlay.dialog == Some(crate::state::DialogKind::EditTemplate) { + self.data.overlay.dialog = None; + } + } + + pub fn remove_project_template(&mut self, id: &str) { + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { + ws.config.terminal_templates.retain(|t| t.id != id); + } + } + + pub fn save_project_settings(&mut self, cx: &mut Context) { + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + let mut config = self.backend.project_config(&project_id).unwrap_or_default(); + let worktree = self.inputs.worktree.read(cx).value().to_string(); + config.worktree_dir = if worktree.trim().is_empty() { + None + } else { + Some(worktree) + }; + config.init_script = lines(&self.inputs.init_script.read(cx).value()); + config.setup_script = lines(&self.inputs.setup_script.read(cx).value()); + config.teardown_script = lines(&self.inputs.teardown_script.read(cx).value()); + let templates = self + .data + .workspaces + .values() + .find(|w| w.project_id == project_id) + .map(|w| w.config.terminal_templates.clone()) + .unwrap_or_else(|| config.terminal_templates.clone()); + config.terminal_templates = templates; + if let Err(err) = self.backend.save_project_config(&project_id, &config) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { + ws.config = config; + } + self.data.overlay.dialog = None; + } + + pub fn search_palette(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let q = self.inputs.palette.read(cx).value().to_string(); + if q == self.data.overlay.palette_query { + return; + } + self.data.overlay.palette_query = q.clone(); + if q.trim().is_empty() { + self.data.overlay.palette_results.clear(); + self.data.overlay.palette_error = None; + self.data.overlay.palette_index = 0; + return; + } + match self.backend.search_files(&profile_id, &q) { + Ok(results) => { + self.data.overlay.palette_results = results; + self.data.overlay.palette_error = None; + } + Err(err) => { + self.data.overlay.palette_results.clear(); + self.data.overlay.palette_error = Some(err.to_string()); + } + } + self.data.overlay.palette_index = 0; + } + + pub fn open_palette_selection(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let ix = self.data.overlay.palette_index; + let path = self.data.overlay.palette_results.get(ix).map(|r| r.path.clone()); + if let Some(path) = path { + self.data.overlay.palette_open = false; + self.open_file(&profile_id, &path, window, cx); + } + } + + pub fn start_rename_path(&mut self, path: &str, window: &mut Window, cx: &mut Context) { + self.data.overlay.renaming_path = Some(path.to_string()); + let name = backend::file_name(path); + self.inputs.rename.update(cx, |s, cx| { + s.set_value(name, window, cx); + }); + } + + pub fn commit_rename_path(&mut self, cx: &mut Context) { + let Some(from) = self.data.overlay.renaming_path.clone() else { + return; + }; + let name = self.inputs.rename.read(cx).value().to_string(); + if name.trim().is_empty() { + self.data.overlay.renaming_path = None; + return; + } + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let parent = std::path::Path::new(&from) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let to = if parent.is_empty() { + name + } else { + format!("{parent}/{name}") + }; + if let Err(err) = self.backend.rename_path(&profile_id, &from, &to) { + self.data + .push_toast(ToastKind::Error, self.t("fileTreeCreateErrorTitle"), err.to_string()); + return; + } + self.data.overlay.renaming_path = None; + self.load_tree_root(&profile_id); + } + + pub fn drop_file_on_terminal(&mut self) { + let Some(path) = self.data.overlay.drag_file.take() else { + return; + }; + let quoted = if path.contains(' ') { + format!("\"{path}\" ") + } else { + format!("{path} ") + }; + self.write_to_active_pty(quoted.as_bytes()); + } + + pub fn copy_review_comments(&mut self, clear: bool, window: &mut Window, cx: &mut Context) { + self.flush_review_edit(window, cx); + let text = crate::review::format_review_comments_for_agent(&self.data.overlay.review_comments); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + if clear { + self.data.overlay.review_comments.clear(); + self.data + .push_toast(ToastKind::Success, self.t("reviewCommentsCopiedAndCleared"), ""); + } else { + self.data + .push_toast(ToastKind::Success, self.t("reviewCommentsCopied"), ""); + } + self.data.overlay.dialog = None; + } + + pub fn open_url_with(&mut self, browser: &str) { + let Some(url) = self.data.overlay.dialog_url.clone() else { + return; + }; + if browser.is_empty() { + let _ = open::that(&url); + } else { + let _ = std::process::Command::new(browser).arg(&url).spawn(); + } + self.data.overlay.dialog = None; + } + + pub fn open_clickable( + &mut self, + token: &crate::detector::Clickable, + skip_confirm: bool, + window: &mut Window, + cx: &mut Context, + ) { + match token { + crate::detector::Clickable::Url(url) => { + if skip_confirm { + let _ = open::that(url); + return; + } + self.data.overlay.dialog_url = Some(url.clone()); + self.data.overlay.open_link_menu = false; + self.data.overlay.dialog = Some(DialogKind::OpenLink); + } + crate::detector::Clickable::Path(path) => { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.resolve_file(&profile_id, path) { + Ok(model::filesystem::ResolvedFilePath::Exact { path }) => { + self.open_file(&profile_id, &path, window, cx); + } + Ok(model::filesystem::ResolvedFilePath::Fuzzy { candidates }) => { + self.data.overlay.dialog = Some(DialogKind::ChooseFile); + self.data.overlay.fuzzy_files = candidates; + } + Err(err) => { + let raw = err.to_string(); + let body = if raw.to_ascii_lowercase().contains("outside the workspace") { + self.t("terminalFilePathOutsideWorkspace") + } else { + raw + }; + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), body); + } + } + } + } + } + + pub fn create_path(&mut self, is_dir: bool, parent: Option<&str>, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if let Some(parent) = parent { + let loaded = self + .data + .workspaces + .get(&profile_id) + .and_then(|w| w.tree.get(parent)) + .is_some_and(|n| n.children_loaded); + if !loaded { + self.load_tree_children(&profile_id, Some(parent)); + } + } + let name = self.inputs.new_path.read(cx).value().to_string(); + let existing = self + .data + .workspaces + .get(&profile_id) + .map(|w| sibling_names(&w.tree, parent)) + .unwrap_or_default(); + let name = if name.trim().is_empty() { + unique_tree_name(&existing, if is_dir { "New Folder" } else { "New File" }) + } else { + name + }; + let path = join_tree_path(parent, &name); + if let Err(err) = self.backend.create_path(&profile_id, &path, is_dir) { + self.data + .push_toast(ToastKind::Error, self.t("fileTreeCreateErrorTitle"), err.to_string()); + return; + } + if let Some(parent) = parent { + self.load_tree_children(&profile_id, Some(parent)); + } else { + self.load_tree_root(&profile_id); + } + self.start_rename_path(&path, window, cx); + } + + pub fn delete_tree_paths(&mut self, paths: &[String]) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if paths.is_empty() { + return; + } + if let Err(err) = self.backend.delete_paths(&profile_id, paths) { + self.data + .push_toast(ToastKind::Error, self.t("fileTreeDeleteErrorTitle"), err.to_string()); + return; + } + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.files.retain(|f| !paths.iter().any(|p| p == &f.path)); + if let Some(UnifiedTab::File { index }) = ws.active { + if index >= ws.files.len() { + ws.active = ws.files.last().map(|_| UnifiedTab::File { + index: ws.files.len() - 1, + }); + } + } + ws.tree_selected.retain(|p| !paths.iter().any(|gone| gone == p)); + } + self.load_tree_root(&profile_id); + } + + pub fn tree_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { + if self.data.overlay.renaming_path.is_some() + || self.data.overlay.palette_open + || self.data.overlay.dialog.is_some() + || self.data.overlay.context_menu.is_some() + { + return false; + } + let Some(profile) = self.data.current_profile.clone() else { + return false; + }; + let Some(ws) = self.data.workspaces.get(&profile) else { + return false; + }; + if ws.sidebar_mode != SidebarMode::Files { + return false; + } + let paths = visible_tree_paths(ws); + if paths.is_empty() { + return false; + } + let current = ws + .tree_anchor + .clone() + .or_else(|| ws.tree_selected.iter().next().cloned()) + .unwrap_or_else(|| paths[0].clone()); + let ix = paths.iter().position(|p| p == ¤t).unwrap_or(0); + let is_dir = ws.tree.get(¤t).is_some_and(|n| n.is_dir); + let expanded = ws.tree.get(¤t).is_some_and(|n| n.expanded); + let parent = create_target_directory(&ws.tree, Some(¤t)); + let next_child = paths + .get(ix + 1) + .filter(|p| p.starts_with(&format!("{current}/"))) + .cloned(); + match key { + "up" | "down" => { + let next = if key == "up" { + ix.saturating_sub(1) + } else { + (ix + 1).min(paths.len() - 1) + }; + self.select_tree_path(&paths[next], shift); + true + } + "left" => { + if is_dir && expanded { + self.toggle_dir(&profile, ¤t); + } else if let Some(parent) = parent { + if parent != current { + self.select_tree_path(&parent, false); + } + } + true + } + "right" => { + if is_dir && !expanded { + self.toggle_dir(&profile, ¤t); + } else if let Some(child) = next_child { + self.select_tree_path(&child, false); + } + true + } + "enter" => { + let is_dir = self + .data + .workspaces + .get(&profile) + .and_then(|w| w.tree.get(¤t)) + .is_some_and(|n| n.is_dir); + if is_dir { + self.toggle_dir(&profile, ¤t); + } else if !self.is_deleted_tree_path(&profile, ¤t) { + self.open_file(&profile, ¤t, window, cx); + } + true + } + "delete" | "backspace" => { + let selected = self + .data + .workspaces + .get(&profile) + .map(|w| context_action_paths(&w.tree_selected, ¤t)) + .unwrap_or_else(|| vec![current]); + self.delete_tree_paths(&selected); + true + } + "f2" => { + self.start_rename_path(¤t, window, cx); + true + } + _ => false, + } + } + + fn select_tree_path(&mut self, path: &str, range: bool) { + let Some(profile) = self.data.current_profile.clone() else { + return; + }; + let Some(ws) = self.data.workspaces.get_mut(&profile) else { + return; + }; + if range { + let paths = visible_tree_paths(ws); + let anchor = ws.tree_anchor.clone().unwrap_or_else(|| path.to_string()); + if let (Some(a), Some(b)) = ( + paths.iter().position(|p| p == &anchor), + paths.iter().position(|p| p == path), + ) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + ws.tree_selected = paths[lo..=hi].iter().cloned().collect(); + ws.tree_anchor = Some(anchor); + return; + } + } + ws.tree_selected.clear(); + ws.tree_selected.insert(path.to_string()); + ws.tree_anchor = Some(path.to_string()); + } + + pub fn reveal(&mut self, path: Option<&str>) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let _ = self.backend.reveal_path(&profile_id, path); + } + + pub fn open_external(&mut self, path: &str) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let _ = self.backend.open_in_default_app(&profile_id, path); + } + + pub fn copy_path(&self, path: &str, absolute: bool, cx: &mut App) { + let abs = self + .data + .current_ws() + .map(|w| Path::new(&w.worktree).join(path).to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + let text = if absolute { abs } else { path.to_string() }; + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + } + + pub fn open_topbar_app(&self, kind: &str) { + let Some(ws) = self.data.current_ws() else { + return; + }; + let folder = &ws.worktree; + let cmd = match kind { + "github-desktop" => "github", + "editor" => match self.data.prefs.editor_app.as_str() { + "cursor" => "cursor", + "windsurf" => "windsurf", + "zed" => "zed", + "sublime" | "subl" | "sublime-text" => "subl", + _ => "code", + }, + "terminal" => match self.data.prefs.terminal_app.as_str() { + "ghostty" => "ghostty", + "iterm2" => "open", + "kitty" => "kitty", + "warp" => "warp", + _ => "x-terminal-emulator", + }, + _ => return, + }; + if kind == "terminal" && self.data.prefs.terminal_app == "iterm2" { + let _ = std::process::Command::new("open").args(["-a", "iTerm", folder]).spawn(); + return; + } + let _ = std::process::Command::new(cmd).arg(folder).spawn(); + } + + pub fn open_pr(&self) { + if let Some(url) = self + .data + .current_ws() + .and_then(|w| w.pr.as_ref().map(|p| p.url.clone())) + { + let _ = open::that(url); + } + } + + pub fn current_term_theme(&self) -> &'static crate::prefs::TermTheme { + let dark = window_is_dark_pref(&self.data.prefs); + let name = if self.data.prefs.sync_terminal_theme { + &self.data.prefs.terminal_theme_dark + } else if dark { + &self.data.prefs.terminal_theme_dark + } else { + &self.data.prefs.terminal_theme_light + }; + term_theme_by_name(name) + } + + pub fn agent_for_project(&self, project_id: &str) -> AgentStatus { + self.data + .project(project_id) + .and_then(|p| p.profiles.iter().find(|pr| pr.is_default)) + .and_then(|pr| self.data.workspaces.get(&pr.id)) + .and_then(|ws| ws.terminals.iter().map(|t| t.agent).max_by_key(|a| agent_rank(*a))) + .unwrap_or(AgentStatus::Idle) + } + + pub fn agent_for_profile(&self, profile_id: &str) -> AgentStatus { + self.data + .workspaces + .get(profile_id) + .and_then(|ws| ws.terminals.iter().map(|t| t.agent).max_by_key(|a| agent_rank(*a))) + .unwrap_or(AgentStatus::Idle) + } + + pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("ctrl-,", OpenSettings, None), + KeyBinding::new("cmd-shift-d", ToggleDebug, None), + KeyBinding::new("ctrl-shift-d", ToggleDebug, None), + KeyBinding::new("cmd-k", OpenPalette, None), + KeyBinding::new("ctrl-k", OpenPalette, None), + KeyBinding::new("cmd-t", NewTerminal, None), + KeyBinding::new("ctrl-t", NewTerminal, None), + KeyBinding::new("cmd-w", CloseActiveTab, None), + KeyBinding::new("ctrl-w", CloseActiveTab, None), + KeyBinding::new("cmd-e", ToggleProfileSidebar, None), + KeyBinding::new("ctrl-e", ToggleProfileSidebar, None), + KeyBinding::new("cmd-g", OpenGitDiff, None), + KeyBinding::new("ctrl-g", OpenGitDiff, None), + KeyBinding::new("cmd-s", SaveFile, None), + KeyBinding::new("ctrl-s", SaveFile, None), + KeyBinding::new("cmd-enter", CommitChanges, None), + KeyBinding::new("ctrl-enter", CommitChanges, None), + KeyBinding::new("escape", DismissOverlay, None), + KeyBinding::new("cmd-f", FindInTerminal, None), + KeyBinding::new("ctrl-shift-f", FindInTerminal, None), + KeyBinding::new("cmd-=", IncreaseFontSize, None), + KeyBinding::new("cmd-+", IncreaseFontSize, None), + KeyBinding::new("ctrl-=", IncreaseFontSize, None), + KeyBinding::new("cmd--", DecreaseFontSize, None), + KeyBinding::new("ctrl--", DecreaseFontSize, None), + KeyBinding::new("cmd-b", WrapBold, None), + KeyBinding::new("ctrl-b", WrapBold, None), + KeyBinding::new("cmd-i", WrapItalic, None), + KeyBinding::new("ctrl-i", WrapItalic, None), + ]); + } + + pub fn open_debug_log(&mut self, window: &mut Window, cx: &mut Context) { + if !self.data.prefs.debug_mode { + return; + } + self.inputs.debug_search.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.data.overlay.dialog = Some(DialogKind::DebugLog); + self.data.overlay.debug_open = true; + self.data.overlay.debug_auto_scroll = true; + self.data.overlay.debug_scroll_y = 0.0; + } + + pub fn toggle_debug_log(&mut self, window: &mut Window, cx: &mut Context) { + if !self.data.prefs.debug_mode { + return; + } + if self.data.overlay.dialog == Some(DialogKind::DebugLog) { + self.data.overlay.dialog = None; + self.data.overlay.debug_open = false; + return; + } + self.open_debug_log(window, cx); + } + + /// Close the topmost overlay. Returns true when something was dismissed. + pub fn dismiss_overlay(&mut self) -> bool { + if self.data.overlay.context_menu.is_some() + && self.data.overlay.group_menu_creating + && !self.data.groups.is_empty() + { + self.data.overlay.group_menu_creating = false; + return true; + } + if self.data.overlay.context_menu.take().is_some() { + self.data.overlay.group_menu_creating = false; + return true; + } + if self.data.overlay.renaming_path.take().is_some() { + return true; + } + if self.data.overlay.file_search_open { + self.data.overlay.file_search_open = false; + return true; + } + if self.data.overlay.palette_open { + self.data.overlay.palette_open = false; + self.data.overlay.palette_results.clear(); + self.data.overlay.palette_query.clear(); + self.data.overlay.palette_error = None; + return true; + } + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if term.search_open { + term.search_open = false; + term.search_query.clear(); + term.search_ix = 0; + return true; + } + } + if self.data.overlay.git_diff_open { + if self.data.overlay.git_selected_commit.is_some() { + self.commit_back(); + return true; + } + self.data.overlay.git_diff_open = false; + return true; + } + if self.data.overlay.dialog.take().is_some() { + self.data.overlay.dialog_error = None; + self.data.overlay.dialog_busy = false; + self.data.overlay.debug_open = false; + return true; + } + if self.data.overlay.debug_open { + self.data.overlay.debug_open = false; + return true; + } + if self.data.overlay.onboarding { + self.data.overlay.onboarding = false; + return true; + } + false + } + + pub fn move_palette(&mut self, delta: i32) { + let len = self.data.overlay.palette_results.len(); + if len == 0 { + return; + } + let cur = self.data.overlay.palette_index as i32; + self.data.overlay.palette_index = (cur + delta).rem_euclid(len as i32) as usize; + } + + pub fn sidebar_nav_items(&self) -> Vec { + crate::state::collect_sidebar_nav_items( + &self.data.projects, + &self.data.groups, + &self.data.prefs.collapsed_groups, + &self.data.overlay.collapsed_projects, + self.data.current_project.as_deref(), + ) + } + + pub fn move_sidebar_nav(&mut self, delta: i32) -> bool { + let items = self.sidebar_nav_items(); + if items.is_empty() { + return false; + } + let cur = self + .data + .overlay + .sidebar_nav + .as_ref() + .and_then(|cur| items.iter().position(|item| item == cur)) + .unwrap_or_else(|| { + if let Some(pid) = &self.data.current_profile { + items + .iter() + .position( + |item| matches!(item, SidebarNavItem::Profile { profile_id, .. } if profile_id == pid), + ) + .unwrap_or(0) + } else { + 0 + } + }); + let next = (cur as i32 + delta).clamp(0, items.len() as i32 - 1) as usize; + self.data.overlay.sidebar_nav = Some(items[next].clone()); + true + } + + pub fn activate_sidebar_nav(&mut self) -> bool { + let item = self + .data + .overlay + .sidebar_nav + .clone() + .or_else(|| self.sidebar_nav_items().into_iter().next()); + let Some(item) = item else { + return false; + }; + match item { + SidebarNavItem::Home => { + self.data.route = Route::Home; + self.data.current_project = None; + self.data.current_profile = None; + } + SidebarNavItem::Project(id) => { + if let Some(profile) = self.data.default_profile_of(&id) { + self.open_profile(&id, &profile.id); + } + } + SidebarNavItem::Profile { project_id, profile_id } => { + self.open_profile(&project_id, &profile_id); + } + } + true + } + + fn leftover_interactive_focused(&self, window: &Window, cx: &App) -> bool { + [ + &self.inputs.commit_summary, + &self.inputs.commit_body, + &self.inputs.review_comment, + &self.inputs.palette, + &self.inputs.file_search, + &self.inputs.branch_search, + &self.inputs.debug_search, + &self.inputs.term_search, + &self.inputs.file_editor, + &self.inputs.notes, + &self.inputs.md_link, + &self.inputs.rename, + &self.inputs.new_path, + ] + .iter() + .any(|input| input.read(cx).focus_handle(cx).is_focused(window)) + } + + pub fn handle_git_list_key(&mut self, key: &str) -> bool { + if !self.data.overlay.git_diff_open { + return false; + } + match (self.data.overlay.git_diff_tab, key) { + (GitDiffTab::Changes, "up" | "down") => { + let count = self.data.current_ws().map(|ws| ws.git_files.len()).unwrap_or(0); + let next = crate::ui::git::leftover_step_index( + self.data.overlay.git_file_index, + if key == "down" { 1 } else { -1 }, + count, + ); + self.data.overlay.git_file_index = next; + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.get(next).map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + true + } + (GitDiffTab::Changes, "space") => { + let path = self.data.current_ws().and_then(|ws| { + ws.git_files + .get(self.data.overlay.git_file_index) + .map(|(p, _)| p.clone()) + }); + let Some(path) = path else { + return false; + }; + if let Some(ws) = self.data.current_ws_mut() { + if !ws.git_included.remove(&path) { + ws.git_included.insert(path); + } + } + true + } + (GitDiffTab::History, "up" | "down") if self.data.overlay.git_selected_commit.is_some() => { + let count = self.data.overlay.git_commit_files.len(); + let next = crate::ui::git::leftover_step_index( + self.data.overlay.git_commit_file_index, + if key == "down" { 1 } else { -1 }, + count, + ); + self.data.overlay.git_commit_file_index = next; + self.data.overlay.git_diff_file = self.data.overlay.git_commit_files.get(next).cloned(); + true + } + (GitDiffTab::History, "up" | "down") => { + self.data.overlay.git_commit_index = crate::ui::git::leftover_step_commit_index( + self.data.overlay.git_commit_index, + if key == "down" { 1 } else { -1 }, + self.data.overlay.git_commits.len(), + ); + true + } + (GitDiffTab::History, "enter") if self.data.overlay.git_selected_commit.is_none() => { + if let Some(ix) = self.data.overlay.git_commit_index { + if let Some(hash) = self.data.overlay.git_commits.get(ix).map(|c| c.hash.clone()) { + self.select_commit(&hash); + return true; + } + } + false + } + (GitDiffTab::History, "backspace") if self.data.overlay.git_selected_commit.is_some() => { + self.commit_back(); + true + } + _ => false, + } + } + + pub fn handle_overlay_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { + if self.data.overlay.dialog == Some(DialogKind::CreateProject) && key == "enter" { + self.create_project_from_dialog(window, cx); + return true; + } + if self.data.overlay.dialog == Some(DialogKind::CreateProfile) && key == "enter" { + self.create_profile_from_dialog(cx); + return true; + } + if self.data.overlay.dialog == Some(DialogKind::RenameProject) && key == "enter" { + self.rename_dialog_project(cx); + return true; + } + if self.data.overlay.dialog == Some(DialogKind::CreateGroup) && key == "enter" { + self.submit_create_group(None, cx); + return true; + } + if key == "enter" { + if let Some((ContextMenu::Project { id }, _, _)) = self.data.overlay.context_menu.clone() { + if self.data.overlay.group_menu_creating || self.data.groups.is_empty() { + self.submit_create_group(Some(&id), cx); + return true; + } + } + } + if self.data.overlay.file_search_open && key == "enter" { + self.cycle_file_search(window, cx, !shift); + return true; + } + if self.data.overlay.palette_open { + return match key { + "up" => { + self.move_palette(-1); + true + } + "down" => { + self.move_palette(1); + true + } + "enter" => { + self.open_palette_selection(window, cx); + true + } + _ => false, + }; + } + if self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .is_some_and(|t| t.search_open) + { + return match key { + "enter" => { + self.cycle_term_search(cx, !shift); + true + } + _ => false, + }; + } + if self.data.overlay.renaming_path.is_some() && key == "enter" { + self.commit_rename_path(cx); + return true; + } + if self.data.overlay.git_diff_open && !self.leftover_interactive_focused(window, cx) { + return self.handle_git_list_key(key); + } + false + } + + pub fn handle_sidebar_key(&mut self, key: &str) -> bool { + if self.data.overlay.palette_open + || self.data.overlay.dialog.is_some() + || self.data.overlay.git_diff_open + || self.data.overlay.context_menu.is_some() + || self.data.overlay.renaming_path.is_some() + || self.data.overlay.sidebar_resize_focus.is_some() + { + return false; + } + match key { + "up" => self.move_sidebar_nav(-1), + "down" => self.move_sidebar_nav(1), + "enter" | "space" => self.activate_sidebar_nav(), + _ => false, + } + } +} + +pub fn create_target_directory(tree: &HashMap, context: Option<&str>) -> Option { + let path = context?.trim_end_matches('/'); + if path.is_empty() { + return None; + } + if tree.get(path).is_some_and(|node| node.is_dir) { + return Some(path.to_string()); + } + path.rfind('/').map(|i| path[..i].to_string()).filter(|p| !p.is_empty()) +} + +pub fn join_tree_path(parent: Option<&str>, name: &str) -> String { + match parent.map(str::trim).filter(|p| !p.is_empty()) { + Some(parent) => format!("{parent}/{name}"), + None => name.to_string(), + } +} + +pub fn sibling_names(tree: &HashMap, parent: Option<&str>) -> Vec { + let key = parent.unwrap_or(""); + tree.get(key) + .map(|node| { + node.children + .iter() + .filter_map(|child| tree.get(child).map(|n| n.name.clone())) + .collect() + }) + .unwrap_or_default() +} + +pub fn context_action_paths(selected: &HashSet, clicked: &str) -> Vec { + if selected.contains(clicked) { + let mut paths: Vec = selected.iter().cloned().collect(); + paths.sort(); + paths + } else { + vec![clicked.to_string()] + } +} + +pub fn unique_tree_name(existing: &[String], base: &str) -> String { + if !existing.iter().any(|n| n == base) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base} {n}"); + if !existing.iter().any(|name| name == &candidate) { + return candidate; + } + n += 1; + } +} + +fn agent_rank(status: AgentStatus) -> u8 { + match status { + AgentStatus::Running => 3, + AgentStatus::Waiting => 2, + AgentStatus::Completed => 1, + AgentStatus::Idle => 0, + } +} + +fn window_is_dark_pref(prefs: &Prefs) -> bool { + matches!(prefs.theme, ThemePref::Dark) +} + +fn lines(s: &str) -> Vec { + s.lines() + .map(|l| l.trim_end().to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +pub fn parse_diff_files(diff: &str) -> Vec { + let mut files = Vec::new(); + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("diff --git ") { + if let Some((_, b)) = rest.split_once(' ') { + files.push(b.trim_start_matches("b/").to_string()); + } + } + } + files +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitStatusKind { + Added, + Untracked, + Deleted, + Renamed, + Ignored, + Modified, +} + +pub fn git_status_kind(status: &str) -> GitStatusKind { + let s = status.trim().to_ascii_lowercase(); + if s.contains("untrack") || s.contains('?') { + GitStatusKind::Untracked + } else if s.contains("added") || s == "a" || s.contains("add") { + GitStatusKind::Added + } else if s.contains("delet") || s == "d" { + GitStatusKind::Deleted + } else if s.contains("renam") || s == "r" { + GitStatusKind::Renamed + } else if s.contains("ignor") { + GitStatusKind::Ignored + } else { + GitStatusKind::Modified + } +} + +pub fn file_status_badge(status: &str) -> &'static str { + match git_status_kind(status) { + GitStatusKind::Added | GitStatusKind::Untracked => "A", + GitStatusKind::Deleted => "D", + GitStatusKind::Renamed => "R", + GitStatusKind::Ignored => "I", + GitStatusKind::Modified => "M", + } +} + +pub fn extract_file_hunk(diff: &str, path: &str) -> String { + let marker = format!("b/{path}"); + let mut out = String::new(); + let mut take = false; + for line in diff.lines() { + if line.starts_with("diff --git ") { + take = line.contains(&marker) || line.ends_with(path); + if take { + out.push_str(line); + out.push('\n'); + } + continue; + } + if take { + out.push_str(line); + out.push('\n'); + } + } + if out.is_empty() { + diff.to_string() + } else { + out + } +} + +fn visible_tree_paths(ws: &Workspace) -> Vec { + let mut out = Vec::new(); + fn walk(ws: &Workspace, path: &str, out: &mut Vec) { + let Some(node) = ws.tree.get(path) else { + return; + }; + for child in &node.children { + out.push(child.clone()); + if ws.tree.get(child).is_some_and(|c| c.is_dir && c.expanded) { + walk(ws, child, out); + } + } + } + walk(ws, "", &mut out); + out +} + +impl gpui::Render for AppView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.apply_theme(window, cx); + self.sync_pty_size(window); + self.sync_notes_input(window, cx); + if self.data.overlay.palette_open { + self.search_palette(cx); + } + div() + .id("app-root") + .track_focus(&self.focus) + .key_context("App") + .size_full() + .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { + ui::settings::open_settings_window(this, window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleDebug, window, cx| { + this.toggle_debug_log(window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &OpenPalette, _, cx| { + if this.data.route == Route::Workspace { + this.data.overlay.palette_open = !this.data.overlay.palette_open; + if !this.data.overlay.palette_open { + this.data.overlay.palette_results.clear(); + this.data.overlay.palette_query.clear(); + this.data.overlay.palette_error = None; + } + cx.notify(); + } + })) + .on_action(cx.listener(|this, _: &NewTerminal, _, cx| { + this.create_terminal(&this.t("newTerminal"), "", Vec::new()); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &CloseActiveTab, _, cx| { + if let Some(profile_id) = this.data.current_profile.clone() { + if let Some(ws) = this.data.workspaces.get(&profile_id) { + match ws.active { + Some(UnifiedTab::Terminal { index }) => { + if let Some(id) = ws.terminals.get(index).map(|t| t.id.clone()) { + this.close_terminal(&profile_id, &id); + } + } + Some(UnifiedTab::File { index }) => { + if let Some(path) = ws.files.get(index).map(|f| f.path.clone()) { + this.request_close_file(&profile_id, &path); + } + } + None => {} + } + } + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &ToggleProfileSidebar, _, cx| { + if let Some(ws) = this.data.current_ws_mut() { + ws.sidebar_open = !ws.sidebar_open; + this.data.prefs.profile_sidebar_open = ws.sidebar_open; + this.persist_prefs(); + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &OpenGitDiff, _, cx| { + this.open_git_diff(); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &SaveFile, window, cx| { + this.save_active_file(window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &CommitChanges, window, cx| { + let (no_files, ahead) = this + .data + .current_ws() + .map(|ws| (ws.git_files.is_empty(), ws.git_ahead)) + .unwrap_or((true, 0)); + if no_files && ahead > 0 { + this.push_current(); + } else { + this.commit_selected(window, cx); + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &DismissOverlay, _, cx| { + if this.dismiss_overlay() { + cx.notify(); + } + })) + .on_action(cx.listener(|this, _: &FindInTerminal, window, cx| { + this.open_find(window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &IncreaseFontSize, _, cx| { + this.bump_font_size(1.0); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &DecreaseFontSize, _, cx| { + this.bump_font_size(-1.0); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &WrapBold, window, cx| { + this.wrap_active_markup("**", "**", window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &WrapItalic, window, cx| { + this.wrap_active_markup("*", "*", window, cx); + cx.notify(); + })) + .child(ui::shell::render(self, window, cx)) + } +} + +#[allow(dead_code)] +pub fn input_el(state: &Entity) -> Input { + Input::new(state) +} + +pub fn suggested_project_name(folder: &str, current_name: &str) -> String { + if current_name.trim().is_empty() { + backend::file_name(folder) + } else { + current_name.to_string() + } +} + +pub fn search_match_offsets(text: &str, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + text.match_indices(query).map(|(ix, _)| ix).collect() +} + +pub fn offset_line_col(text: &str, offset: usize) -> (u32, u32) { + let mut cursor = offset.min(text.len()); + while cursor > 0 && !text.is_char_boundary(cursor) { + cursor -= 1; + } + let before = &text[..cursor]; + let line = before.matches('\n').count() as u32; + let character = before.rsplit('\n').next().unwrap_or("").chars().count() as u32; + (line, character) +} + +pub fn wrap_markup_text(text: &str, start: usize, end: usize, prefix: &str, suffix: &str) -> (String, usize) { + let start = snap_char_boundary(text, start.min(text.len())); + let end = snap_char_boundary(text, end.min(text.len())); + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let mut out = String::with_capacity(text.len() + prefix.len() + suffix.len()); + out.push_str(&text[..a]); + out.push_str(prefix); + out.push_str(&text[a..b]); + out.push_str(suffix); + out.push_str(&text[b..]); + (out, a + prefix.len() + (b - a)) +} + +fn snap_char_boundary(text: &str, mut offset: usize) -> usize { + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + offset +} + +fn utf16_offset_to_bytes(text: &str, utf16: usize) -> usize { + let mut seen = 0; + for (byte, ch) in text.char_indices() { + if seen >= utf16 { + return byte; + } + seen += ch.len_utf16(); + } + text.len() +} + +pub fn apply_slash_command(text: &str, prefix: &str, suffix: &str) -> String { + crate::ui::markdown::apply_slash_at(text, text.len(), prefix, suffix).0 +} + +pub fn wrap_markup( + input: &Entity, + prefix: &str, + suffix: &str, + window: &mut Window, + cx: &mut Context, +) { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let (start, end) = match state.selected_text_range(true, window, cx) { + Some(sel) => ( + utf16_offset_to_bytes(&text, sel.range.start), + utf16_offset_to_bytes(&text, sel.range.end), + ), + None => { + let cursor = snap_char_boundary(&text, state.cursor().min(text.len())); + (cursor, cursor) + } + }; + let (new_text, caret) = wrap_markup_text(&text, start, end, prefix, suffix); + state.set_value(new_text.clone(), window, cx); + let (line, character) = offset_line_col(&new_text, caret); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); +} + +#[cfg(test)] +mod tests { + use super::{ + apply_slash_command, context_action_paths, create_target_directory, file_status_badge, git_status_kind, + join_tree_path, offset_line_col, search_match_offsets, sibling_names, suggested_project_name, unique_tree_name, + wrap_markup_text, GitStatusKind, + }; + use crate::state::TreeNode; + use std::collections::{HashMap, HashSet}; + + #[test] + fn unique_tree_name_adds_numbers() { + assert_eq!(unique_tree_name(&[], "New File"), "New File"); + assert_eq!(unique_tree_name(&["New File".into()], "New File"), "New File 2"); + assert_eq!( + unique_tree_name(&["New File".into(), "New File 2".into()], "New File"), + "New File 3" + ); + } + + fn node(path: &str, is_dir: bool, children: Vec) -> TreeNode { + TreeNode { + path: path.into(), + name: path.rsplit('/').next().unwrap_or(path).into(), + is_dir, + expanded: is_dir, + children_loaded: true, + children, + } + } + + #[test] + fn create_target_directory_uses_folder_or_parent() { + let mut tree = HashMap::new(); + tree.insert("".into(), node("", true, vec!["src".into(), "README.md".into()])); + tree.insert("src".into(), node("src", true, vec!["src/main.rs".into()])); + tree.insert("src/main.rs".into(), node("src/main.rs", false, Vec::new())); + tree.insert("README.md".into(), node("README.md", false, Vec::new())); + assert_eq!(create_target_directory(&tree, Some("src")).as_deref(), Some("src")); + assert_eq!( + create_target_directory(&tree, Some("src/main.rs")).as_deref(), + Some("src") + ); + assert_eq!(create_target_directory(&tree, Some("README.md")), None); + assert_eq!(create_target_directory(&tree, None), None); + assert_eq!(join_tree_path(Some("src"), "New File"), "src/New File"); + assert_eq!(join_tree_path(None, "New File"), "New File"); + assert_eq!(sibling_names(&tree, Some("src")), vec!["main.rs".to_string()]); + } + + #[test] + fn context_action_paths_uses_selection_when_clicked_is_selected() { + let selected = HashSet::from(["a".into(), "b".into()]); + assert_eq!( + context_action_paths(&selected, "a"), + vec!["a".to_string(), "b".to_string()] + ); + assert_eq!(context_action_paths(&selected, "c"), vec!["c".to_string()]); + } + + #[test] + fn suggested_project_name_uses_basename_when_empty() { + assert_eq!(suggested_project_name("/tmp/my-app", ""), "my-app"); + assert_eq!(suggested_project_name("/tmp/my-app", " "), "my-app"); + assert_eq!(suggested_project_name("/tmp/my-app", "Custom"), "Custom"); + } + + #[test] + fn file_status_badge_maps_word_and_letter_status() { + assert_eq!(file_status_badge("untracked"), "A"); + assert_eq!(file_status_badge("added"), "A"); + assert_eq!(file_status_badge("deleted"), "D"); + assert_eq!(file_status_badge("renamed"), "R"); + assert_eq!(file_status_badge("ignored"), "I"); + assert_eq!(file_status_badge("modified"), "M"); + assert_eq!(git_status_kind("deleted"), GitStatusKind::Deleted); + assert_eq!(git_status_kind("??"), GitStatusKind::Untracked); + } + + #[test] + fn offset_line_col_tracks_lines() { + assert_eq!(offset_line_col("abc", 2), (0, 2)); + assert_eq!(offset_line_col("ab\ncd", 4), (1, 1)); + assert_eq!(offset_line_col("", 0), (0, 0)); + } + + #[test] + fn search_match_offsets_finds_each_hit() { + assert_eq!(search_match_offsets("abcabc", "bc"), vec![1, 4]); + assert!(search_match_offsets("abc", "").is_empty()); + assert!(search_match_offsets("abc", "z").is_empty()); + } + + #[test] + fn wrap_markup_text_wraps_selection_or_caret() { + assert_eq!(wrap_markup_text("hello", 2, 2, "**", "**"), ("he****llo".into(), 4)); + assert_eq!(wrap_markup_text("hello", 1, 4, "*", "*"), ("h*ell*o".into(), 5)); + assert_eq!(wrap_markup_text("hello", 4, 1, "*", "*"), ("h*ell*o".into(), 5)); + } + + #[test] + fn apply_slash_command_replaces_the_slash_line() { + assert_eq!(apply_slash_command("/h1", "# ", ""), "# "); + assert_eq!( + apply_slash_command("intro\n/code", "```\n", "\n```"), + "intro\n```\n\n```" + ); + } +} diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs new file mode 100644 index 00000000..cc6ee2e8 --- /dev/null +++ b/src-gpui/src/backend.rs @@ -0,0 +1,798 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{mpsc, Arc, Mutex}; + +use infra::db::{init_db, DbPool}; +use infra::pty::{self as pty_infra, PtyReadThreads, PtySessionMap}; +use model::error::AppError; +use model::filesystem::{FilePreview, FileSearchResult, FileTreeGitStatusEntry}; +use model::profile::{Profile, ProfileDeleteCheck}; +use model::project::{ + GitBranchInfo, GitCommit, GitDiffSnapshot, GitDiffStats, GitPullRequestStatus, Project, ProjectConfig, + ProjectSidebarLayoutUpdate, ProjectWithProfiles, +}; +use model::project_group::ProjectGroup; +use model::pty::{PtyConfig, PtySessionMeta, PtySessionRecord}; +use service::pty::{PtyContext, PtyFlushSenders, PtyLogDir}; +use service::{PtyEventEmitter, WatchEventSender}; + +pub type TerminalBuffers = Arc>>>; +pub type TerminalExits = Arc>>; +pub type WatchInbox = Arc>>; +pub type DebugInbox = Arc>>; + +pub const DEBUG_LOG_CAP: usize = 1000; + +pub struct GpuiPtyEmitter { + buffers: TerminalBuffers, + exits: TerminalExits, +} + +impl PtyEventEmitter for GpuiPtyEmitter { + fn emit_output(&self, session_id: &str, bytes: &[u8]) -> bool { + if let Ok(mut map) = self.buffers.lock() { + map.entry(session_id.to_string()).or_default().extend_from_slice(bytes); + } + true + } + + fn emit_exit(&self, session_id: &str) { + if let Ok(mut exits) = self.exits.lock() { + exits.push(session_id.to_string()); + } + } +} + +struct GpuiWatchSender { + events: WatchInbox, +} + +impl WatchEventSender for GpuiWatchSender { + fn send(&self, event: model::watcher::WatchEvent) -> bool { + if let Ok(mut queue) = self.events.lock() { + queue.push(event); + } + true + } +} + +struct NoopWatchSender; + +impl WatchEventSender for NoopWatchSender { + fn send(&self, _event: model::watcher::WatchEvent) -> bool { + true + } +} + +#[derive(Clone)] +pub struct Backend { + pub db: DbPool, + pub app_data_dir: PathBuf, + pub sessions: PtySessionMap, + pub flush_senders: PtyFlushSenders, + pub read_threads: PtyReadThreads, + pub output_dir: PathBuf, + pub buffers: TerminalBuffers, + pub exits: TerminalExits, + pub watch_events: WatchInbox, + pub debug_logs: DebugInbox, +} + +impl Backend { + pub fn init(log_handle: infra::logger::ChannelLayerHandle) -> Result { + let app_data_dir = app_data_dir(); + std::fs::create_dir_all(&app_data_dir).map_err(|e| format!("create app data dir: {e}"))?; + + let db = init_db(&app_data_dir)?; + service::pty::mark_all_closed(&db); + let output_dir = infra::pty_log::logs_dir(&app_data_dir); + service::pty::gc_orphan_logs(&db, &output_dir); + + let buffers = Arc::new(Mutex::new(HashMap::new())); + let exits = Arc::new(Mutex::new(Vec::new())); + let watch_events = Arc::new(Mutex::new(Vec::new())); + let debug_logs = Arc::new(Mutex::new(Vec::new())); + { + let inbox = debug_logs.clone(); + log_handle.attach(move |entry| { + if let Ok(mut queue) = inbox.lock() { + queue.push(entry); + if queue.len() > DEBUG_LOG_CAP * 2 { + let extra = queue.len() - DEBUG_LOG_CAP; + queue.drain(0..extra); + } + } + true + }); + } + tracing::info!(target: "pty", "startup: marked orphaned sessions closed"); + service::watcher::start( + Box::new(GpuiWatchSender { + events: watch_events.clone(), + }), + db.clone(), + infra::watcher::create_shutdown_flag(), + ); + + Ok(Self { + db, + app_data_dir, + sessions: pty_infra::create_session_map(), + flush_senders: service::pty::create_flush_senders(), + read_threads: pty_infra::create_thread_tracker(), + output_dir, + buffers, + exits, + watch_events, + debug_logs, + }) + } + + fn pty_ctx(&self) -> PtyContext { + PtyContext { + db: self.db.clone(), + sessions: self.sessions.clone(), + flush_senders: self.flush_senders.clone(), + read_threads: self.read_threads.clone(), + emitter: Arc::new(GpuiPtyEmitter { + buffers: self.buffers.clone(), + exits: self.exits.clone(), + }), + output_dir: self.output_dir.clone(), + } + } + + fn with_db( + &self, + f: impl FnOnce(&mut diesel::sqlite::SqliteConnection) -> Result, + ) -> Result { + let mut conn = self.db.lock().map_err(|_| AppError::LockError)?; + f(&mut conn) + } + + pub fn list_projects(&self) -> Result, AppError> { + self.with_db(service::project::list) + } + + pub fn list_groups(&self) -> Result, AppError> { + self.with_db(service::project::list_groups) + } + + pub fn create_project(&self, name: &str, folder: &str) -> Result { + self.with_db(|conn| service::project::create_from_folder(conn, name, folder)) + } + + pub fn rename_project(&self, id: &str, name: &str) -> Result { + self.with_db(|conn| service::project::update(conn, id, Some(name.to_string()), None)) + } + + pub fn delete_project(&self, id: &str) -> Result<(), AppError> { + service::project::delete_with_context(&self.pty_ctx(), id) + } + + pub fn create_group(&self, name: &str) -> Result { + self.with_db(|conn| service::project::create_group(conn, name)) + } + + pub fn assign_to_group(&self, project_id: &str, group_id: Option) -> Result { + self.with_db(|conn| service::project::assign_to_group(conn, project_id, group_id)) + } + + pub fn update_sidebar_layout(&self, updates: Vec) -> Result<(), AppError> { + self.with_db(|conn| service::project::update_sidebar_layout(conn, updates)) + } + + pub fn create_profile(&self, project_id: &str, branch_name: &str) -> Result { + self.with_db(|conn| service::profile::create(conn, project_id, branch_name)) + } + + pub fn delete_profile(&self, id: &str) -> Result<(), AppError> { + service::profile::delete_with_context(&self.pty_ctx(), id) + } + + pub fn delete_profile_check(&self, id: &str) -> Result { + self.with_db(|conn| service::profile::delete_check(conn, id)) + } + + pub fn update_notes(&self, profile_id: &str, notes: &str) -> Result<(), AppError> { + self.with_db(|conn| repo::profile::update_notes(conn, profile_id, notes).map(|_| ())) + } + + pub fn git_branch(&self, folder: &str) -> Result { + service::project::get_branch(folder) + } + + pub fn git_diff_stats(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_diff_stats(conn, profile_id)) + } + + pub fn git_diff(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_diff(conn, profile_id)) + } + + pub fn git_diff_snapshot(&self, profile_id: &str) -> Result { + self.with_db(|conn| { + let profile = repo::profile::find_by_id(conn, profile_id)?; + infra::git::diff_snapshot(&profile.worktree_path) + }) + } + + pub fn git_log(&self, profile_id: &str, limit: u32) -> Result, AppError> { + self.with_db(|conn| service::project::get_log(conn, profile_id, limit)) + } + + pub fn commit_diff(&self, profile_id: &str, hash: &str) -> Result { + self.with_db(|conn| service::project::get_commit_diff(conn, profile_id, hash)) + } + + pub fn commit_changes( + &self, + profile_id: &str, + files: &[String], + message: &str, + body: Option<&str>, + ) -> Result { + self.with_db(|conn| service::project::commit_changes(conn, profile_id, files, message, body)) + } + + pub fn discard_file(&self, profile_id: &str, paths: &[String]) -> Result<(), AppError> { + self.with_db(|conn| service::project::discard_file_changes(conn, profile_id, paths)) + } + + pub fn git_ahead(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_ahead_count(conn, profile_id)) + } + + pub fn git_push(&self, profile_id: &str) -> Result<(), AppError> { + self.with_db(|conn| service::project::push(conn, profile_id)) + } + + pub fn list_branches(&self, folder: &str) -> Result, AppError> { + infra::git::list_branches(folder) + } + + pub fn checkout_branch(&self, folder: &str, branch: &str) -> Result<(), AppError> { + infra::git::checkout_branch(folder, branch) + } + + pub fn pr_status(&self, folder: &str, branch: Option<&str>) -> Result, AppError> { + service::project::get_pull_request_status_for_folder(folder, branch) + } + + pub fn project_config(&self, project_id: &str) -> Result { + let folder = self.with_db(|conn| Ok(repo::project::find_by_id(conn, project_id)?.folder))?; + Ok(infra::config::load_project_config(&folder).unwrap_or_default()) + } + + pub fn save_project_config(&self, project_id: &str, config: &ProjectConfig) -> Result<(), AppError> { + let folder = self.with_db(|conn| Ok(repo::project::find_by_id(conn, project_id)?.folder))?; + infra::config::write_project_config(&folder, config) + } + + pub fn github_avatar(&self, project_id: &str) -> Option { + self.with_db(|conn| { + let folder = repo::project::find_by_id(conn, project_id)?.folder; + Ok(infra::git::github_avatar_url(&folder)) + }) + .ok() + .flatten() + } + + pub fn list_tree_children(&self, profile_id: &str, relative: Option<&str>) -> Result, AppError> { + service::filesystem::list_file_tree_child_paths(&self.db, profile_id, relative) + } + + pub fn tree_git_status(&self, profile_id: &str) -> Result, AppError> { + self.with_db(|conn| service::filesystem::get_file_tree_git_status(conn, profile_id)) + } + + pub fn search_files(&self, profile_id: &str, query: &str) -> Result, AppError> { + self.with_db(|conn| service::filesystem::search_file(conn, profile_id, query)) + } + + pub fn read_file(&self, profile_id: &str, path: &str) -> Result { + service::filesystem::read_file_content(&self.db, profile_id, path) + } + + pub fn write_file(&self, profile_id: &str, path: &str, content: &str) -> Result<(), AppError> { + service::filesystem::write_file_content(&self.db, profile_id, path, content) + } + + pub fn file_preview(&self, profile_id: &str, path: &str) -> Result { + let file_cache = self.app_data_dir.join("file-preview"); + let office_cache = self.app_data_dir.join("office-preview"); + let _ = std::fs::create_dir_all(&file_cache); + let _ = std::fs::create_dir_all(&office_cache); + service::filesystem::get_file_preview(&self.db, profile_id, path, &file_cache, &office_cache) + } + + pub fn create_path(&self, profile_id: &str, path: &str, is_dir: bool) -> Result<(), AppError> { + service::filesystem::create_file_tree_path( + &self.db, + profile_id, + path, + if is_dir { "directory" } else { "file" }, + ) + } + + pub fn rename_path(&self, profile_id: &str, from: &str, to: &str) -> Result<(), AppError> { + service::filesystem::rename_file_tree_path(&self.db, profile_id, from, to) + } + + pub fn delete_paths(&self, profile_id: &str, paths: &[String]) -> Result<(), AppError> { + service::filesystem::delete_file_tree_paths(&self.db, profile_id, paths) + } + + pub fn move_paths(&self, profile_id: &str, sources: &[String], target_dir: Option<&str>) -> Result<(), AppError> { + service::filesystem::move_file_tree_paths(&self.db, profile_id, sources, target_dir) + } + + pub fn reveal_path(&self, profile_id: &str, path: Option<&str>) -> Result<(), AppError> { + service::filesystem::reveal_path_in_file_manager(&self.db, profile_id, path) + } + + pub fn open_in_default_app(&self, profile_id: &str, path: &str) -> Result<(), AppError> { + service::filesystem::open_path_in_default_app(&self.db, profile_id, path) + } + + pub fn create_terminal( + &self, + profile_id: &str, + title: &str, + cwd: &str, + shell: &str, + startup_commands: Vec, + ) -> Result { + let ctx = self.pty_ctx(); + service::pty::create_session( + &ctx, + &PtySessionMeta { + profile_id: profile_id.to_string(), + title: title.to_string(), + }, + &PtyConfig { + shell: shell.to_string(), + cwd: cwd.to_string(), + rows: 32, + cols: 120, + startup_commands, + }, + ) + } + + pub fn write_pty(&self, session_id: &str, data: &[u8]) -> Result<(), AppError> { + pty_infra::write_to_pty(&self.sessions, session_id, data) + } + + pub fn clear_pty_output(&self, session_id: &str) -> Result<(), AppError> { + service::pty::clear_output(&self.output_dir, &self.flush_senders, session_id) + } + + pub fn resize_pty(&self, session_id: &str, rows: u16, cols: u16) -> Result<(), AppError> { + pty_infra::resize_pty(&self.sessions, session_id, rows, cols) + } + + pub fn close_terminal(&self, session_id: &str) -> Result<(), AppError> { + service::pty::close_session_full(&self.sessions, &self.flush_senders, &self.output_dir, session_id)?; + self.with_db(|conn| { + repo::pty::mark_closed(conn, session_id); + Ok(()) + }) + } + + pub fn find_project(&self, id: &str) -> Result { + self.with_db(|conn| repo::project::find_by_id(conn, id)) + } + + pub fn find_profile(&self, id: &str) -> Result { + self.with_db(|conn| repo::profile::find_by_id(conn, id)) + } + + pub fn worktree_path(&self, profile_id: &str) -> Result { + self.with_db(|conn| Ok(repo::profile::find_by_id(conn, profile_id)?.worktree_path)) + } + + pub fn set_pinned(&self, project_id: &str, pinned: bool) -> Result<(), AppError> { + let project = self.find_project(project_id)?; + self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project_id.into(), + group_id: project.group_id, + sort_order: Some(project.sort_order), + pinned_order: if pinned { + Some(project.pinned_order.unwrap_or(0)) + } else { + None + }, + }]) + } + + pub fn move_project(&self, project_id: &str, delta: i32) -> Result<(), AppError> { + let projects = self.list_projects()?; + let Some(project) = projects.iter().find(|p| p.id == project_id).cloned() else { + return Ok(()); + }; + let mut bucket: Vec<_> = projects + .into_iter() + .filter(|p| { + if project.pinned_at.is_some() { + p.pinned_at.is_some() + } else { + p.pinned_at.is_none() && p.group_id == project.group_id + } + }) + .collect(); + if project.pinned_at.is_some() { + bucket.sort_by_key(|p| p.pinned_order.unwrap_or(0)); + } else { + bucket.sort_by_key(|p| p.sort_order); + } + let Some(ix) = bucket.iter().position(|p| p.id == project_id) else { + return Ok(()); + }; + let next = (ix as i32 + delta).clamp(0, bucket.len() as i32 - 1) as usize; + if next == ix { + return Ok(()); + } + bucket.swap(ix, next); + let updates = bucket + .iter() + .enumerate() + .map(|(i, p)| ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: p.id.clone(), + group_id: p.group_id.clone(), + sort_order: Some(if p.pinned_at.is_some() { p.sort_order } else { i as i32 }), + pinned_order: if p.pinned_at.is_some() { Some(i as i32) } else { None }, + }) + .collect(); + self.update_sidebar_layout(updates) + } + + pub fn drop_project(&self, dragged: &str, target: Option<&str>, unpin: bool) -> Result<(), AppError> { + let project = self.find_project(dragged)?; + if unpin { + return self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project.id, + group_id: None, + sort_order: Some(project.sort_order), + pinned_order: None, + }]); + } + let Some(target) = target else { + return Ok(()); + }; + if target == dragged { + return Ok(()); + } + let dest = self.find_project(target)?; + self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project.id, + group_id: dest.group_id, + sort_order: Some(dest.sort_order.saturating_sub(1)), + pinned_order: dest.pinned_order, + }]) + } + + pub fn list_sessions(&self, project_id: &str) -> Result, AppError> { + self.with_db(|conn| service::pty::list_project_sessions(conn, project_id)) + } + + pub fn list_all_sessions(&self) -> Result, AppError> { + let projects = self.list_projects()?; + let mut sessions = Vec::new(); + for project in projects { + sessions.extend(self.list_sessions(&project.id)?); + } + Ok(sessions) + } + + pub fn restore_session(&self, record: &PtySessionRecord) -> Result<(String, Vec), AppError> { + let ctx = self.pty_ctx(); + let result = service::pty::restore_session( + &ctx, + &record.id, + &PtySessionMeta { + profile_id: record.profile_id.clone(), + title: record.title.clone(), + }, + &PtyConfig { + shell: record.shell.clone(), + cwd: record.cwd.clone(), + rows: record.rows.max(1) as u16, + cols: record.cols.max(1) as u16, + startup_commands: Vec::new(), + }, + )?; + Ok((result.new_session_id, result.history)) + } + + pub fn take_watch_events(&self) -> Vec { + self.watch_events + .lock() + .ok() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default() + } + + pub fn resolve_file(&self, profile_id: &str, path: &str) -> Result { + service::filesystem::resolve_terminal_file_path(&self.db, profile_id, path) + } + + pub fn take_debug_logs(&self) -> Vec { + self.debug_logs + .lock() + .ok() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default() + } + + pub fn take_output(&self, session_id: &str) -> Vec { + self.buffers + .lock() + .ok() + .and_then(|mut map| map.remove(session_id)) + .unwrap_or_default() + } + + pub fn shutdown(&self) { + pty_infra::close_all_sessions(&self.sessions); + pty_infra::join_all_read_threads(&self.read_threads); + service::pty::mark_all_closed(&self.db); + } +} + +pub fn app_data_dir() -> PathBuf { + if let Some(dir) = dirs::data_dir() { + return dir.join("com.akrc.code"); + } + PathBuf::from(".").join(".2code") +} + +pub fn pick_folder() -> Option { + rfd::FileDialog::new() + .set_title("Choose Folder") + .pick_folder() + .map(|p| p.to_string_lossy().into_owned()) +} + +pub fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()) +} + +#[allow(dead_code)] +pub fn _log_dir(backend: &Backend) -> PtyLogDir { + PtyLogDir(backend.output_dir.clone()) +} + +#[allow(dead_code)] +pub fn _watch_noop() -> Box { + Box::new(NoopWatchSender) +} + +#[allow(dead_code)] +pub fn _channel() -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel() +} + +pub fn is_previewable(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + [ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", ".zip", ".tar", ".gz", ".tgz", ".docx", + ".xlsx", ".pptx", + ] + .iter() + .any(|ext| lower.ends_with(ext)) +} + +impl Backend { + pub fn cached_head_blob(&self, folder: &str, path: &str) -> Option { + let slug = path.replace(['/', '\\'], "_"); + let dest = self.app_data_dir.join("git-preview").join(slug); + if dest.exists() { + return Some(dest); + } + std::fs::create_dir_all(dest.parent()?).ok()?; + let spec = format!("HEAD:{path}"); + let out = std::process::Command::new("git") + .args(["-C", folder, "show", &spec]) + .output() + .ok()?; + if !out.status.success() || out.stdout.is_empty() { + return None; + } + std::fs::write(&dest, &out.stdout).ok()?; + Some(dest) + } +} + +pub fn is_image(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + [ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico", ".tif", ".tiff", + ] + .iter() + .any(|ext| lower.ends_with(ext)) +} + +pub fn is_markdown(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".mdx") +} + +pub fn is_pdf(path: &str) -> bool { + path.to_ascii_lowercase().ends_with(".pdf") +} + +pub fn is_office(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".docx") || lower.ends_with(".xlsx") || lower.ends_with(".pptx") +} + +pub fn is_document_preview(kind: &str, path: &str) -> bool { + matches!(kind, "pdf" | "office-pdf" | "office") || is_pdf(path) || is_office(path) +} + +pub fn rasterize_pdf_preview(pdf: &str) -> Option { + let pdf = Path::new(pdf); + if !pdf.is_file() { + return None; + } + let dest = pdf.with_file_name(format!("{}.preview-page1.png", pdf.file_stem()?.to_string_lossy())); + if dest.is_file() { + let png_mtime = dest.metadata().ok().and_then(|m| m.modified().ok()); + let pdf_mtime = pdf.metadata().ok().and_then(|m| m.modified().ok()); + if match (png_mtime, pdf_mtime) { + (Some(png), Some(src)) => png >= src, + _ => true, + } { + return Some(dest); + } + } + let prefix = dest.with_extension(""); + for bin in ["pdftoppm", "pdftocairo"] { + let ok = std::process::Command::new(bin) + .args(["-png", "-f", "1", "-l", "1", "-singlefile"]) + .arg(pdf) + .arg(&prefix) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if ok && dest.is_file() { + return Some(dest); + } + } + None +} + +pub fn language_from_path(path: &str) -> &'static str { + let ext = Path::new(path) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "tsx", + "js" | "mjs" | "cjs" | "jsx" => "javascript", + "json" | "jsonc" => "json", + "py" => "python", + "go" => "go", + "toml" => "toml", + "yml" | "yaml" => "yaml", + "md" | "mdx" => "markdown", + "css" | "scss" => "css", + "html" | "htm" => "html", + "sh" | "bash" | "zsh" => "bash", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp", + "java" => "java", + "rb" => "ruby", + "sql" => "sql", + "swift" => "swift", + "zig" => "zig", + "cs" => "csharp", + "diff" | "patch" => "diff", + "xml" => "html", + _ => "text", + } +} + +pub fn format_debug_time(timestamp_ms: u64) -> String { + let total_secs = timestamp_ms / 1000; + let ms = timestamp_ms % 1000; + let s = total_secs % 60; + let m = (total_secs / 60) % 60; + let h = (total_secs / 3600) % 24; + format!("{h:02}:{m:02}:{s:02}.{ms:03}") +} + +pub fn leftover_debug_log_matches(entry: &model::debug::LogEntry, query: &str) -> bool { + if query.is_empty() { + return true; + } + let q = query.to_ascii_lowercase(); + entry.message.to_ascii_lowercase().contains(&q) + || entry.source.to_ascii_lowercase().contains(&q) + || entry.level.to_ascii_lowercase().contains(&q) +} + +pub fn format_debug_log(entry: &model::debug::LogEntry) -> String { + format!( + "{} {} {} {}", + format_debug_time(entry.timestamp), + entry.level, + entry.source, + entry.message + ) +} + +pub fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_preview_kinds() { + assert!(is_document_preview("pdf", "notes.txt")); + assert!(is_document_preview("office-pdf", "slide.pptx")); + assert!(is_document_preview("", "spec.pdf")); + assert!(is_document_preview("", "brief.docx")); + assert!(!is_document_preview("", "readme.md")); + } + + #[test] + fn language_from_path_maps_extensions() { + assert_eq!(language_from_path("src/main.rs"), "rust"); + assert_eq!(language_from_path("app.tsx"), "tsx"); + assert_eq!(language_from_path("notes.md"), "markdown"); + assert_eq!(language_from_path("unknown.xyz"), "text"); + } + + #[test] + fn rasterize_missing_pdf_is_none() { + assert!(rasterize_pdf_preview("/no/such/file.pdf").is_none()); + } + + #[test] + fn formats_debug_clock_in_utc() { + assert_eq!(format_debug_time(0), "00:00:00.000"); + assert_eq!(format_debug_time(3_661_123), "01:01:01.123"); + } + + #[test] + fn formats_debug_log_row() { + let line = format_debug_log(&model::debug::LogEntry { + timestamp: 1_000, + level: "INFO".into(), + source: "pty".into(), + message: "ready".into(), + }); + assert_eq!(line, "00:00:01.000 INFO pty ready"); + } + + #[test] + fn leftover_debug_search_matches_message_source_level() { + let entry = model::debug::LogEntry { + timestamp: 1_000, + level: "WARN".into(), + source: "pty".into(), + message: "ready".into(), + }; + assert!(leftover_debug_log_matches(&entry, "")); + assert!(leftover_debug_log_matches(&entry, "rea")); + assert!(leftover_debug_log_matches(&entry, "PTY")); + assert!(leftover_debug_log_matches(&entry, "warn")); + assert!(!leftover_debug_log_matches(&entry, "00:00")); + assert!(!leftover_debug_log_matches(&entry, "error")); + } +} diff --git a/src-gpui/src/detector/manifests.rs b/src-gpui/src/detector/manifests.rs new file mode 100644 index 00000000..067ec772 --- /dev/null +++ b/src-gpui/src/detector/manifests.rs @@ -0,0 +1,964 @@ +//! Rule tables ported from `src/features/terminal/detector/rules/*.ts`. + +use super::{Gate, Manifest, Region, Rule, Semantic}; +use crate::state::AgentKind; + +const fn c(contains: &'static [&'static str]) -> Gate { + Gate { + contains, + ..Gate::EMPTY + } +} + +const fn r(regex: &'static [&'static str]) -> Gate { + Gate { regex, ..Gate::EMPTY } +} + +const fn l(line_regex: &'static [&'static str]) -> Gate { + Gate { + line_regex, + ..Gate::EMPTY + } +} + +macro_rules! rule { + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: false, + skip_state: false, + gate: $gate, + } + }; + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr, visible_idle) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: true, + skip_state: false, + gate: $gate, + } + }; + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr, skip_state) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: false, + skip_state: true, + gate: $gate, + } + }; +} + +pub const MANIFESTS: &[Manifest] = &[ + CLAUDE, CODEX, OPENCODE, AMP, AGY, CLINE, CURSOR, DEVIN, DROID, GEMINI, COPILOT, GROK, HERMES, KILO, KIMI, KIRO, + PI, QODER, +]; + +const CLAUDE: Manifest = Manifest { + id: AgentKind::Claude, + aliases: &["claude-code"], + rules: &[ + rule!( + "osc_title_working", + Working, + 1100, + Region::OscTitle, + r(&[r"^[\u{2800}-\u{28FF}] "]) + ), + rule!( + "live_blocked_form", + Blocked, + 980, + Region::AfterLastHorizontalRule, + Gate { + contains: &["enter to select", "esc to cancel"], + any: &[ + c(&["tab/arrow keys to navigate"]), + c(&["arrow keys to navigate"]), + c(&["arrows to navigate"]), + c(&["↑/↓ to navigate"]), + c(&["↑↓ to navigate"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "dynamic_workflow_prompt", + Blocked, + 980, + Region::WholeRecent, + c(&["run a dynamic workflow?", "esc to cancel"]) + ), + rule!( + "live_prompt_box", + Idle, + 950, + Region::PromptBoxBody, + Gate { + line_regex: &[r"^\s*❯"], + not: &[ + c(&["enter to select"]), + c(&["esc to cancel"]), + c(&["tab/arrow keys"]), + c(&["arrow keys to navigate"]), + c(&["↑/↓ to navigate"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + rule!( + "bash_permission_prompt", + Blocked, + 850, + Region::WholeRecent, + Gate { + contains: &["do you want to proceed?"], + any: &[ + c(&["bash command"]), + c(&["bash("]), + c(&["contains expansion"]), + c(&["tab to amend"]), + c(&["ctrl+e to explain"]), + ], + all: &[Gate { + any: &[ + l(&[r"^\s*(?:❯\s*)?yes\b"]), + l(&[r"^\s*1\.\s*yes\b"]), + l(&[r"^\s*2\.\s*no\b"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "generic_permission_prompt", + Blocked, + 840, + Region::AfterLastHorizontalRule, + Gate { + contains: &["do you want to proceed?", "esc to cancel"], + all: &[Gate { + any: &[ + l(&[r"^\s*(?:❯\s*)?1\.\s*yes\b"]), + l(&[r"^\s*2\.\s*yes\b"]), + l(&[r"^\s*2\.\s*no\b"]), + l(&[r"^\s*3\.\s*no\b"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "legacy_no_prompt_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["do you want to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + Gate { + contains: &["would you like to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + c(&["waiting for permission"]), + c(&["do you want to allow this connection?"]), + c(&["tab to amend"]), + c(&["ctrl+e to explain"]), + c(&["do you want to proceed?", "esc to cancel"]), + c(&["review your answers"]), + c(&["skip interview and plan immediately"]), + ], + not: &[r(&[r"^\s*❯\s*$"])], + ..Gate::EMPTY + } + ), + rule!( + "osc_title_idle", + Idle, + 250, + Region::OscTitle, + r(&[r"^\u{2733} "]), + visible_idle + ), + rule!("osc_progress_idle", Idle, 250, Region::OscProgress, r(&[r"^4;0"])), + ], +}; + +const CODEX: Manifest = Manifest { + id: AgentKind::Codex, + aliases: &[], + rules: &[ + rule!( + "osc_title_blocked", + Blocked, + 1100, + Region::OscTitle, + c(&["Action Required"]) + ), + rule!( + "osc_title_working", + Working, + 1050, + Region::OscTitle, + r(&[r"^[\u{2800}-\u{28FF}] "]) + ), + rule!( + "live_strong_blocker", + Blocked, + 900, + Region::AfterLastPromptMarker, + Gate { + any: &[ + c(&["press enter to confirm or esc to cancel"]), + c(&["enter to submit answer"]), + c(&["enter to submit all"]), + c(&["allow command?"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "weak_blocker", + Blocked, + 600, + Region::WholeRecent, + Gate { + any: &[ + c(&["[y/n]"]), + c(&["yes (y)"]), + Gate { + contains: &["do you want to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + Gate { + contains: &["would you like to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "osc_title_idle", + Idle, + 100, + Region::OscTitle, + Gate { + regex: &[r"\S"], + not: &[r(&[r"^[\u{2800}-\u{28FF}]"]), c(&["Action Required"])], + ..Gate::EMPTY + }, + visible_idle + ), + ], +}; + +const OPENCODE: Manifest = Manifest { + id: AgentKind::OpenCode, + aliases: &["open-code"], + rules: &[ + rule!( + "permission_required", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["△ Permission required"]), + Gate { + contains: &["esc dismiss"], + any: &[c(&["enter confirm"]), c(&["enter submit"]), c(&["enter toggle"])], + all: &[Gate { + any: &[c(&["↑↓ select"]), c(&["⇆ tab"])], + ..Gate::EMPTY + }], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "interrupt_hint_working", + Working, + 110, + Region::WholeRecent, + Gate { + any: &[ + c(&["esc to interrupt"]), + c(&["ctrl+c to interrupt"]), + c(&["press esc to interrupt"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "progress_bar_working", + Working, + 100, + Region::WholeRecent, + r(&["(■|⬝){4,}"]) + ), + ], +}; + +const AMP: Manifest = Manifest { + id: AgentKind::Amp, + aliases: &["amp-local"], + rules: &[ + rule!( + "approval_footer", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["waiting for approval"]), + c(&["invoke tool"]), + c(&["run this command?"]), + c(&["allow editing file:"]), + c(&["allow creating file:"]), + c(&["confirm tool call"]), + Gate { + contains: &["approve"], + any: &[ + c(&["allow all for this session"]), + c(&["allow all for every session"]), + c(&["allow file for every session"]), + c(&["deny with feedback"]), + ], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "esc_cancel_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to cancel"]) + ), + ], +}; + +const AGY: Manifest = Manifest { + id: AgentKind::Agy, + aliases: &["antigravity", "antigravity-cli"], + rules: &[ + rule!( + "permission_prompt", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requesting permission for:"], + any: &[c(&["do you want to proceed?"]), c(&["tab amend", "edit command"])], + ..Gate::EMPTY + } + ), + rule!( + "spinner_working", + Working, + 100, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]+\s+\p{Alphabetic}+(?:[\d_]\w*)?ing\b"]) + ), + rule!( + "background_tasks_working", + Working, + 90, + Region::BottomNonEmpty(5), + l(&[r"·\s*[1-9]\d*\s+task"]) + ), + ], +}; + +const CLINE: Manifest = Manifest { + id: AgentKind::Cline, + aliases: &[], + rules: &[ + rule!( + "tool_permission", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["let cline use this tool"]), + c(&["[act mode]", "execute command?", "yes"]), + c(&["[act mode]", "use this tool?", "yes"]), + c(&["[plan mode]", "execute command?", "yes"]), + c(&["[plan mode]", "use this tool?", "yes"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "default_cline_working", + Working, + -10, + Region::WholeRecent, + r(&[r"[\s\S]+"]) + ), + ], +}; + +const CURSOR: Manifest = Manifest { + id: AgentKind::Cursor, + aliases: &["cursor-agent"], + rules: &[ + rule!( + "write_file_approval", + Blocked, + 320, + Region::BottomNonEmpty(8), + Gate { + contains: &["write to this file?", "proceed (y)"], + any: &[ + c(&["reject & propose changes"]), + c(&["esc or n or p"]), + c(&["add write("]), + ], + ..Gate::EMPTY + } + ), + rule!( + "approval_prompt", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["waiting for approval", "run this command?"], + any: &[c(&["run (once) (y)"]), c(&["skip (esc or n)"])], + ..Gate::EMPTY + }, + c(&["(y) (enter)"]), + l(&[r"^\s*allow .*\(y\)"]), + c(&["keep (n)"]), + c(&["skip (esc or n)"]), + c(&["(y)", "allow"]), + c(&["(y)", "run (once)"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "stop_hint_working", + Working, + 100, + Region::BottomNonEmpty(6), + c(&["ctrl+c to stop"]) + ), + rule!( + "background_task_status_working", + Working, + 95, + Region::BottomNonEmpty(5), + l(&[r"\b[1-9]\d*\s+background\s+tasks?\b"]) + ), + rule!( + "spinner_working", + Working, + 90, + Region::BottomNonEmpty(8), + l(&[r"^\s*(⬡|⬢|[\u{2800}-\u{28FF}]+)\s+\p{Alphabetic}"]) + ), + ], +}; + +const DEVIN: Manifest = Manifest { + id: AgentKind::Devin, + aliases: &["devin-cli", "devin cli"], + rules: &[ + rule!( + "workspace_trust_prompt", + Blocked, + 300, + Region::BottomNonEmpty(8), + c(&[ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", + ]) + ), + rule!( + "permission_prompt", + Blocked, + 290, + Region::BottomNonEmpty(8), + c(&["approve once", "select", "confirm", "esc cancel"]) + ), + rule!( + "running_tools_footer", + Working, + 200, + Region::BottomNonEmpty(8), + Gate { + contains: &["running tools", "esc to interrupt"], + not: &[c(&["approve once", "esc cancel"])], + ..Gate::EMPTY + } + ), + rule!( + "guide_while_working", + Working, + 190, + Region::BottomNonEmpty(6), + Gate { + contains: &["guide devin while it works"], + not: &[c(&["approve once", "esc cancel"])], + ..Gate::EMPTY + } + ), + rule!( + "welcome_prompt_footer", + Idle, + 120, + Region::BottomNonEmpty(8), + Gate { + contains: &["ask devin to build", "features, fix bugs", "your code"], + line_regex: &[r"^\s*❭ Ask Devin to build"], + not: &[ + c(&["approve once", "esc cancel"]), + c(&["running tools", "esc to interrupt"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + rule!( + "live_prompt_footer", + Idle, + 100, + Region::BottomNonEmpty(6), + Gate { + contains: &["context:"], + line_regex: &[r"^\s*❭"], + not: &[ + c(&["approve once", "esc cancel"]), + c(&["running tools", "esc to interrupt"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + ], +}; + +const DROID: Manifest = Manifest { + id: AgentKind::Droid, + aliases: &["factory droid"], + rules: &[ + rule!( + "execute_selection_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["enter to select", "esc to cancel"], + any: &[c(&["↑↓ to navigate"]), c(&["use ↑↓ to navigate"])], + all: &[Gate { + any: &[c(&["> yes, allow"]), c(&["> no, cancel"])], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "selection_menu_blocker", + Blocked, + 290, + Region::BottomNonEmpty(8), + Gate { + contains: &["enter select", "esc cancel"], + any: &[c(&["↑/↓ navigate"]), c(&["↑↓ navigate"])], + ..Gate::EMPTY + } + ), + rule!( + "spinner_stop_working", + Working, + 110, + Region::WholeRecent, + Gate { + contains: &["esc to stop"], + line_regex: &[r"^\s*[\u{2800}-\u{28FF}]"], + ..Gate::EMPTY + } + ), + rule!( + "stop_hint_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to stop"]) + ), + ], +}; + +const GEMINI: Manifest = Manifest { + id: AgentKind::Gemini, + aliases: &[], + rules: &[ + rule!( + "apply_or_allow_change", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["│ Apply this change"]), + c(&["│ Allow execution"]), + Gate { + all: &[c(&["yes"])], + any: &[ + c(&["waiting for user confirmation"]), + c(&["│ Do you want to proceed"]), + c(&["do you want to proceed?"]), + ], + ..Gate::EMPTY + }, + l(&[r"^\s*❯.*(yes|allow)"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "esc_cancel_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to cancel"]) + ), + ], +}; + +const COPILOT: Manifest = Manifest { + id: AgentKind::Copilot, + aliases: &["github-copilot", "github copilot", "ghcs"], + rules: &[ + rule!( + "selection_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + all: &[ + Gate { + any: &[c(&["esc to cancel"]), c(&["esc cancel"])], + ..Gate::EMPTY + }, + Gate { + any: &[ + c(&["enter to select"]), + c(&["enter to confirm"]), + c(&["enter to submit"]), + c(&["enter accept"]), + ], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "working_cancel_hint", + Working, + 100, + Region::WholeRecent, + Gate { + any: &[c(&["esc to cancel"]), c(&["esc cancel"]), c(&["esc again to cancel"]),], + ..Gate::EMPTY + } + ), + ], +}; + +const GROK: Manifest = Manifest { + id: AgentKind::Grok, + aliases: &["grok-build"], + rules: &[ + rule!( + "permission_scope_selector", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["yes, proceed", "no, reject"], + any: &[c(&["use ← → to choose permission whitelist scope"]), c(&["←/→:scope"]),], + ..Gate::EMPTY + } + ), + rule!( + "waiting_tool_working", + Working, + 120, + Region::WholeRecent, + Gate { + any: &[ + Gate { + all: &[c(&["ctrl+c:cancel", "ctrl+enter:interject"]), c(&["waiting"]),], + ..Gate::EMPTY + }, + l(&[r"^\s*[\u{2800}-\u{28FF}]\s+(Run|Read|Search|List)\b"]), + ], + ..Gate::EMPTY + } + ), + ], +}; + +const HERMES: Manifest = Manifest { + id: AgentKind::Hermes, + aliases: &["hermes-agent"], + rules: &[ + rule!( + "dangerous_command_approval", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["dangerous command"]), + c(&["allow once", "allow for this session", "deny"]), + ], + all: &[Gate { + any: &[ + c(&["enter to confirm"]), + c(&["↑/↓ to select"]), + c(&["show full command"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "interrupt_status_working", + Working, + 100, + Region::WholeRecent, + Gate { + any: &[c(&["msg=interrupt"]), c(&["ctrl+c cancel"])], + ..Gate::EMPTY + } + ), + ], +}; + +const KILO: Manifest = Manifest { + id: AgentKind::Kilo, + aliases: &["kilo-code", "kilo code"], + rules: &[ + rule!( + "opencode_permission", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[c(&["△ Permission required"]), c(&["esc dismiss", "enter confirm"])], + ..Gate::EMPTY + } + ), + rule!( + "esc_interrupt_working", + Working, + 100, + Region::WholeRecent, + c(&["esc interrupt"]) + ), + ], +}; + +const KIMI: Manifest = Manifest { + id: AgentKind::Kimi, + aliases: &["kimi-code", "kimi code"], + rules: &[ + rule!( + "current_approval_panel", + Blocked, + 400, + Region::WholeRecent, + Gate { + contains: &["↵ confirm"], + any: &[ + c(&["run this command?"]), + c(&["write this file?"]), + c(&["apply these edits?"]), + c(&["stop this task?"]), + c(&["ready to build with this plan?"]), + ], + all: &[ + c(&[" choose"]), + Gate { + any: &[c(&["approve"]), c(&["reject"]), c(&["revise"])], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "legacy_approval_panel", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requesting approval", "reject"], + any: &[c(&["approve once"]), c(&["approve for this session"])], + ..Gate::EMPTY + } + ), + rule!( + "moon_spinner_working", + Working, + 100, + Region::WholeRecent, + l(&[r"^\s*([🌕🌖🌗🌘🌑🌒🌓🌔])\s*$"]) + ), + rule!( + "braille_spinner_working", + Working, + 90, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]+\s*(thinking\.\.\.|working\.\.\.|using )"]) + ), + ], +}; + +const KIRO: Manifest = Manifest { + id: AgentKind::Kiro, + aliases: &["kiro-cli"], + rules: &[ + rule!( + "tool_approval", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requires approval"], + any: &[ + c(&["yes, single permission"]), + c(&["trust, always allow"]), + c(&["no (tab to edit)"]), + c(&["esc to close"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "subagent_approval", + Blocked, + 290, + Region::WholeRecent, + Gate { + contains: &["pending from subagents"], + any: &[c(&["tool approval"]), c(&["tool approvals"])], + all: &[Gate { + any: &[ + c(&["approve all pending"]), + c(&["configure individually"]), + c(&["exit (cancel subagents)"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "kiro_working_marker", + Working, + 100, + Region::WholeRecent, + c(&["kiro is working"]) + ), + rule!( + "tool_spinner_working", + Working, + 90, + Region::WholeRecent, + Gate { + contains: &["esc to cancel"], + line_regex: &[r"^\s*([◔◑◕●])\s+\p{Alphabetic}"], + ..Gate::EMPTY + } + ), + ], +}; + +const PI: Manifest = Manifest { + id: AgentKind::Pi, + aliases: &["pi agent"], + rules: &[rule!( + "working_literal", + Working, + 100, + Region::WholeRecent, + c(&["Working..."]) + )], +}; + +const QODER: Manifest = Manifest { + id: AgentKind::Qoder, + aliases: &["qoderclicn", "qoder", "qodercn"], + rules: &[ + rule!( + "confirmation_or_input_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["waiting for user confirmation"], + any: &[c(&["yes"]), c(&["no"]), c(&["allow"]), c(&["reject"])], + ..Gate::EMPTY + }, + c(&["permission required"]), + c(&["allow once or always?"]), + c(&["asking user"]), + c(&["enter your response"]), + c(&["review your answers:"]), + c(&["shell awaiting input"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "cancel_hint_working", + Working, + 100, + Region::WholeRecent, + c(&["(esc to cancel,"]) + ), + rule!( + "spinner_working", + Working, + 90, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]\s+(?:\S.*)?\p{Alphabetic}"]) + ), + ], +}; diff --git a/src-gpui/src/detector/mod.rs b/src-gpui/src/detector/mod.rs new file mode 100644 index 00000000..57487dfa --- /dev/null +++ b/src-gpui/src/detector/mod.rs @@ -0,0 +1,638 @@ +//! Agent-status detector ported from `src/features/terminal/detector`. +//! Regions, gates, idle confirmation, and per-agent manifests match the TS engine. + +mod manifests; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::time::Instant; + +use regex::Regex; + +use crate::state::{AgentKind, AgentStatus}; + +use manifests::MANIFESTS; + +const IDLE_CONFIRMATIONS: u32 = 3; +const IDLE_CONFIRM_CAP_MS: u128 = 700; +const PROMPT_MARKERS: &[&str] = &["❯", "❭", "›"]; +const SPINNER_TITLE: &str = r"^[\u{2800}-\u{28FF}] "; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Semantic { + Idle, + Working, + Blocked, + #[default] + Unknown, +} + +#[derive(Clone, Copy, Debug)] +pub enum Region { + OscTitle, + OscProgress, + AfterLastHorizontalRule, + AfterLastPromptMarker, + PromptBoxBody, + WholeRecent, + BottomLines(usize), + BottomNonEmpty(usize), +} + +#[derive(Clone, Copy, Debug)] +pub struct Gate { + pub contains: &'static [&'static str], + pub regex: &'static [&'static str], + pub line_regex: &'static [&'static str], + pub all: &'static [Gate], + pub any: &'static [Gate], + pub not: &'static [Gate], +} + +impl Gate { + pub const EMPTY: Self = Self { + contains: &[], + regex: &[], + line_regex: &[], + all: &[], + any: &[], + not: &[], + }; +} + +#[derive(Clone, Copy, Debug)] +pub struct Rule { + pub id: &'static str, + pub state: Semantic, + pub priority: i32, + pub region: Region, + pub visible_idle: bool, + pub skip_state: bool, + pub gate: Gate, +} + +#[derive(Clone, Copy, Debug)] +pub struct Manifest { + pub id: AgentKind, + pub aliases: &'static [&'static str], + pub rules: &'static [Rule], +} + +#[derive(Default)] +pub struct OscSink { + pub title: String, + pub progress: String, +} + +impl vt100::Callbacks for OscSink { + fn set_window_title(&mut self, _: &mut vt100::Screen, title: &[u8]) { + self.title = String::from_utf8_lossy(title).into_owned(); + } + + fn unhandled_osc(&mut self, _: &mut vt100::Screen, params: &[&[u8]]) { + self.progress = params + .iter() + .map(|p| String::from_utf8_lossy(p).into_owned()) + .collect::>() + .join(";"); + } +} + +#[derive(Default)] +pub struct AgentStatusDetector { + kind: AgentKind, + state: Semantic, + pending_idle_since: Option, + pending_idle_count: u32, +} + +impl AgentStatusDetector { + pub fn detect( + &mut self, + tab_title: &str, + screen: &str, + osc_title: &str, + osc_progress: &str, + ) -> (AgentKind, AgentStatus) { + let osc = if osc_title.is_empty() { tab_title } else { osc_title }; + let input = DetectionInput { + screen, + osc_title: osc, + osc_progress, + }; + let inferred = infer_agents(&input); + let match_ = find_best(self.kind, &inferred, &input); + + if match_.is_none() { + if self.kind == AgentKind::Unknown && inferred.len() == 1 { + self.kind = inferred[0]; + } + if self.kind != AgentKind::Unknown { + self.state = Semantic::Idle; + } + self.pending_idle_since = None; + self.pending_idle_count = 0; + return (self.kind, status_for(self.state, AgentStatus::Idle)); + } + + let (kind, rule) = match_.unwrap(); + self.kind = kind; + if rule.skip_state { + return (self.kind, status_for(self.state, AgentStatus::Idle)); + } + if let Some(next) = self.confirm_idle(rule) { + self.state = next; + } + (self.kind, status_for(self.state, AgentStatus::Idle)) + } + + fn confirm_idle(&mut self, rule: &Rule) -> Option { + if rule.state != Semantic::Idle || self.state != Semantic::Working || rule.visible_idle { + self.pending_idle_since = None; + self.pending_idle_count = 0; + return Some(rule.state); + } + let now = Instant::now(); + if self.pending_idle_since.is_none() { + self.pending_idle_since = Some(now); + self.pending_idle_count = 1; + return None; + } + self.pending_idle_count += 1; + let elapsed = self.pending_idle_since.unwrap().elapsed().as_millis(); + if self.pending_idle_count >= IDLE_CONFIRMATIONS || elapsed >= IDLE_CONFIRM_CAP_MS { + self.pending_idle_since = None; + self.pending_idle_count = 0; + return Some(Semantic::Idle); + } + None + } +} + +struct DetectionInput<'a> { + screen: &'a str, + osc_title: &'a str, + osc_progress: &'a str, +} + +pub fn detect(title: &str, screen: &str, previous: AgentStatus) -> (AgentKind, AgentStatus) { + let mut det = AgentStatusDetector { + kind: AgentKind::Unknown, + state: match previous { + AgentStatus::Running => Semantic::Working, + AgentStatus::Waiting => Semantic::Blocked, + AgentStatus::Completed | AgentStatus::Idle => Semantic::Idle, + }, + pending_idle_since: None, + pending_idle_count: 0, + }; + let (kind, status) = det.detect(title, screen, title, ""); + if matches!(previous, AgentStatus::Running | AgentStatus::Waiting) && status == AgentStatus::Idle { + (kind, AgentStatus::Completed) + } else { + (kind, status) + } +} + +fn status_for(state: Semantic, fallback: AgentStatus) -> AgentStatus { + match state { + Semantic::Blocked => AgentStatus::Waiting, + Semantic::Working => AgentStatus::Running, + Semantic::Idle => AgentStatus::Idle, + Semantic::Unknown => fallback, + } +} + +fn infer_agents(input: &DetectionInput) -> Vec { + let bottom = bottom_lines(input.screen, 20, true); + let hay = format!("{}\n{}\n{bottom}", input.osc_title, input.osc_progress); + if hay.to_ascii_lowercase().contains("action required") { + return vec![AgentKind::Codex]; + } + let named: Vec = MANIFESTS.iter().filter(|m| mentions(&hay, m)).map(|m| m.id).collect(); + if !named.is_empty() { + return named; + } + if regex_is_match(SPINNER_TITLE, input.osc_title) { + return vec![AgentKind::Claude, AgentKind::Codex]; + } + Vec::new() +} + +fn mentions(text: &str, manifest: &Manifest) -> bool { + let lower = text.to_ascii_lowercase(); + std::iter::once(kind_name(manifest.id)) + .chain(manifest.aliases.iter().copied()) + .any(|name| lower.contains(&name.to_ascii_lowercase())) +} + +fn kind_name(kind: AgentKind) -> &'static str { + match kind { + AgentKind::Claude => "claude", + AgentKind::Codex => "codex", + AgentKind::Gemini => "gemini", + AgentKind::Cursor => "cursor", + AgentKind::Copilot => "copilot", + AgentKind::Amp => "amp", + AgentKind::Cline => "cline", + AgentKind::OpenCode => "opencode", + AgentKind::Grok => "grok", + AgentKind::Kimi => "kimi", + AgentKind::Devin => "devin", + AgentKind::Droid => "droid", + AgentKind::Hermes => "hermes", + AgentKind::Kilo => "kilo", + AgentKind::Kiro => "kiro", + AgentKind::Pi => "pi", + AgentKind::Qoder => "qodercli", + AgentKind::Agy => "agy", + AgentKind::OpenClaw => "openclaw", + AgentKind::Other | AgentKind::Unknown => "", + } +} + +fn find_best(current: AgentKind, inferred: &[AgentKind], input: &DetectionInput) -> Option<(AgentKind, &'static Rule)> { + let mut candidates = Vec::new(); + if current != AgentKind::Unknown { + candidates.push(current); + } + for kind in inferred { + if !candidates.contains(kind) { + candidates.push(*kind); + } + } + let mut best: Option<(AgentKind, &'static Rule)> = None; + for kind in candidates { + let Some(manifest) = MANIFESTS.iter().find(|m| m.id == kind) else { + continue; + }; + if let Some(rule) = evaluate(manifest, input) { + if best.is_none_or(|(_, b)| rule.priority > b.priority) { + best = Some((kind, rule)); + } + } + } + best +} + +fn evaluate(manifest: &Manifest, input: &DetectionInput) -> Option<&'static Rule> { + let mut best = None; + for rule in manifest.rules { + let text = select_region(input, rule.region); + if gate_matches(&rule.gate, &text) && best.is_none_or(|b: &Rule| rule.priority > b.priority) { + best = Some(rule); + } + } + best +} + +fn select_region(input: &DetectionInput, region: Region) -> String { + match region { + Region::OscTitle => input.osc_title.to_string(), + Region::OscProgress => input.osc_progress.to_string(), + Region::AfterLastHorizontalRule => after_last_hr(input.screen), + Region::AfterLastPromptMarker => after_last_prompt(input.screen), + Region::PromptBoxBody => { + let after = after_last_prompt(&after_last_hr(input.screen)); + bottom_lines(&after, 12, false) + } + Region::WholeRecent => input.screen.to_string(), + Region::BottomLines(n) => bottom_lines(input.screen, n, false), + Region::BottomNonEmpty(n) => bottom_lines(input.screen, n, true), + } +} + +fn bottom_lines(text: &str, count: usize, non_empty: bool) -> String { + let lines: Vec<&str> = if non_empty { + text.lines().filter(|l| !l.trim().is_empty()).collect() + } else { + text.lines().collect() + }; + let start = lines.len().saturating_sub(count); + lines[start..].join("\n") +} + +fn after_last_hr(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + for i in (0..lines.len()).rev() { + if regex_is_match(r"^\s*[─━═-]{3,}\s*$", lines[i]) { + return lines[i + 1..].join("\n"); + } + } + text.to_string() +} + +fn after_last_prompt(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + for i in (0..lines.len()).rev() { + if PROMPT_MARKERS.iter().any(|m| lines[i].contains(m)) { + return lines[i..].join("\n"); + } + } + text.to_string() +} + +fn gate_matches(gate: &Gate, text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + for needle in gate.contains { + if !lower.contains(&needle.to_ascii_lowercase()) { + return false; + } + } + for pat in gate.regex { + if !regex_is_match(pat, text) { + return false; + } + } + if !gate.line_regex.is_empty() { + let lines: Vec<&str> = text.lines().collect(); + for pat in gate.line_regex { + if !lines.iter().any(|line| regex_is_match(pat, line)) { + return false; + } + } + } + if !gate.all.is_empty() && !gate.all.iter().all(|g| gate_matches(g, text)) { + return false; + } + if !gate.any.is_empty() && !gate.any.iter().any(|g| gate_matches(g, text)) { + return false; + } + if gate.not.iter().any(|g| gate_matches(g, text)) { + return false; + } + true +} + +fn regex_is_match(pat: &str, text: &str) -> bool { + thread_local! { + static CACHE: RefCell>> = RefCell::new(HashMap::new()); + } + CACHE.with(|cache| { + let mut map = cache.borrow_mut(); + let re = map + .entry(pat.to_string()) + .or_insert_with(|| Regex::new(pat).ok()) + .as_ref(); + re.is_some_and(|r| r.is_match(text)) + }) +} + +pub fn clickable_tokens(screen: &str) -> Vec { + let mut out: Vec = clickable_hits(screen).into_iter().map(|h| h.token).collect(); + out.dedup(); + out.into_iter().take(8).collect() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClickHit { + pub token: Clickable, + pub row: u16, + pub col: usize, + pub len: usize, +} + +impl ClickHit { + pub fn contains(&self, row: u16, col: usize) -> bool { + self.row == row && col >= self.col && col < self.col + self.len + } +} + +pub fn clickable_hits(screen: &str) -> Vec { + let mut out = Vec::new(); + for (row, line) in screen.lines().enumerate() { + let mut col = 0usize; + for part in line.split_inclusive(char::is_whitespace) { + let token = part.trim_end_matches(char::is_whitespace); + let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); + if let Some(extra) = token.find(clean).filter(|_| !clean.is_empty()) { + let hit = if clean.starts_with("http://") || clean.starts_with("https://") { + Some(Clickable::Url(clean.to_string())) + } else if looks_like_path(clean) { + Some(Clickable::Path(clean.to_string())) + } else { + None + }; + if let Some(token) = hit { + out.push(ClickHit { + token, + row: row as u16, + col: col + extra, + len: clean.chars().count(), + }); + } + } + col += part.chars().count(); + } + } + out +} + +/// OSC 9;4 progress used by xterm's ProgressAddon: `(state, value)`. +/// state 0 hide, 1 set, 2 error, 3 indeterminate, 4 pause. +pub fn parse_osc_progress(raw: &str) -> Option<(u8, u8)> { + if raw.is_empty() { + return None; + } + let parts: Vec<&str> = raw.split(';').collect(); + for i in 0..parts.len() { + if parts[i] == "9" && parts.get(i + 1) == Some(&"4") { + let state = parts.get(i + 2)?.parse().ok()?; + let value = parts.get(i + 3).and_then(|v| v.parse().ok()).unwrap_or(0); + return Some((state, value)); + } + } + if parts.len() >= 2 { + if let (Ok(state), Ok(value)) = (parts[0].parse::(), parts[1].parse::()) { + if state <= 4 { + return Some((state, value)); + } + } + } + None +} + +/// iTerm2 inline images (`OSC 1337 ; File=…`). Leftover xterm ImageAddon. +pub fn extract_iterm2_images(carry: &mut Vec, incoming: &[u8]) -> Vec> { + carry.extend_from_slice(incoming); + if carry.len() > 4 * 1024 * 1024 { + carry.drain(..carry.len() - 64); + } + let mut images = Vec::new(); + loop { + let Some(start) = carry.windows(2).position(|w| w == [0x1b, b']']) else { + if !carry.is_empty() && carry[carry.len() - 1] != 0x1b { + carry.clear(); + } else if carry.len() > 1 { + carry.drain(..carry.len() - 1); + } + break; + }; + if start > 0 { + carry.drain(..start); + } + let Some(end) = osc_terminator(carry) else { + break; + }; + let seq: Vec = carry.drain(..end).collect(); + if let Some(img) = parse_iterm2_osc(&seq) { + images.push(img); + } + } + images +} + +fn osc_terminator(buf: &[u8]) -> Option { + for i in 2..buf.len() { + if buf[i] == 0x07 { + return Some(i + 1); + } + if buf[i] == b'\\' && buf[i - 1] == 0x1b { + return Some(i + 1); + } + } + None +} + +pub fn parse_iterm2_file(payload: &str) -> Option> { + let body = payload.strip_prefix("1337;").unwrap_or(payload); + let rest = body.strip_prefix("File=")?; + let (meta, data) = rest.rsplit_once(':')?; + if meta.split(';').any(|part| part.eq_ignore_ascii_case("inline=0")) { + return None; + } + decode_base64(data) +} + +fn parse_iterm2_osc(seq: &[u8]) -> Option> { + let inner = seq.strip_prefix(&[0x1b, b']'])?; + let inner = inner + .strip_suffix(&[0x07]) + .or_else(|| inner.strip_suffix(&[0x1b, b'\\']))?; + parse_iterm2_file(&String::from_utf8_lossy(inner)) +} + +fn decode_base64(input: &str) -> Option> { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes: Vec = input + .bytes() + .filter(|b| !b.is_ascii_whitespace() && *b != b'=') + .collect(); + if bytes.is_empty() { + return None; + } + let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 1); + for chunk in bytes.chunks(4) { + let a = val(chunk[0])?; + let b = val(*chunk.get(1)?)?; + out.push((a << 2) | (b >> 4)); + if chunk.len() >= 3 { + let c = val(chunk[2])?; + out.push((b << 4) | (c >> 2)); + if chunk.len() == 4 { + let d = val(chunk[3])?; + out.push((c << 6) | d); + } + } + } + Some(out) +} + +pub fn image_format(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + Some("png") + } else if bytes.starts_with(&[0xFF, 0xD8]) { + Some("jpeg") + } else if bytes.starts_with(b"GIF8") { + Some("gif") + } else if bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + Some("webp") + } else { + None + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Clickable { + Url(String), + Path(String), +} + +fn looks_like_path(token: &str) -> bool { + if token.len() < 3 || token.contains("://") { + return false; + } + (token.contains('/') || token.contains('.')) + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '~')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_claude_waiting() { + let (kind, status) = detect( + "claude", + "do you want to proceed?\nesc to cancel\n1. yes", + AgentStatus::Running, + ); + assert_eq!(kind, AgentKind::Claude); + assert_eq!(status, AgentStatus::Waiting); + } + + #[test] + fn detects_codex_working() { + let (kind, status) = detect("codex", "press enter to confirm or esc to cancel", AgentStatus::Idle); + assert_eq!(kind, AgentKind::Codex); + assert_eq!(status, AgentStatus::Waiting); + } + + #[test] + fn extracts_urls_and_paths() { + let tokens = clickable_tokens("see https://example.com and src/app.rs please"); + assert!(tokens.contains(&Clickable::Url("https://example.com".into()))); + assert!(tokens.contains(&Clickable::Path("src/app.rs".into()))); + let hits = clickable_hits("see https://example.com and src/app.rs please"); + assert_eq!(hits[0].col, 4); + assert_eq!(hits[0].len, "https://example.com".len()); + assert_eq!(hits[1].col, 4 + "https://example.com".len() + 5); + } + + #[test] + fn parses_xterm_osc_progress() { + assert_eq!(parse_osc_progress("9;4;1;40"), Some((1, 40))); + assert_eq!(parse_osc_progress("1;80"), Some((1, 80))); + assert_eq!(parse_osc_progress("0;0"), Some((0, 0))); + assert_eq!(parse_osc_progress(""), None); + } + + #[test] + fn parses_iterm2_inline_png() { + const PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + let decoded = parse_iterm2_file(&format!("1337;File=inline=1;name=dot:{PNG}")).unwrap(); + assert_eq!(image_format(&decoded), Some("png")); + assert!(parse_iterm2_file(&format!("1337;File=inline=0:{PNG}")).is_none()); + + let mut carry = Vec::new(); + let mut seq = vec![0x1b, b']']; + seq.extend(format!("1337;File=inline=1:{PNG}").bytes()); + seq.push(0x07); + let imgs = extract_iterm2_images(&mut carry, &seq); + assert_eq!(imgs.len(), 1); + assert_eq!(image_format(&imgs[0]), Some("png")); + assert!(carry.is_empty()); + } +} diff --git a/src-gpui/src/diff.rs b/src-gpui/src/diff.rs new file mode 100644 index 00000000..d5e0f504 --- /dev/null +++ b/src-gpui/src/diff.rs @@ -0,0 +1,234 @@ +/// A single side of a split-diff row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffLineKind { + Context, + Add, + Del, + Header, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitRow { + pub left: Option<(DiffLineKind, String)>, + pub right: Option<(DiffLineKind, String)>, +} + +/// Pair consecutive `-` / `+` runs in a unified diff into two-column rows. +/// Context and hunk headers appear on both sides; deletions stay left; additions stay right. +pub fn split_rows(diff: &str) -> Vec { + let mut rows = Vec::new(); + let mut dels = Vec::new(); + let mut adds = Vec::new(); + + fn flush(rows: &mut Vec, dels: &mut Vec, adds: &mut Vec) { + let n = dels.len().max(adds.len()); + for i in 0..n { + rows.push(SplitRow { + left: dels.get(i).cloned().map(|s| (DiffLineKind::Del, s)), + right: adds.get(i).cloned().map(|s| (DiffLineKind::Add, s)), + }); + } + dels.clear(); + adds.clear(); + } + + for line in diff.lines() { + if is_header(line) { + flush(&mut rows, &mut dels, &mut adds); + let text = line.to_string(); + rows.push(SplitRow { + left: Some((DiffLineKind::Header, text.clone())), + right: Some((DiffLineKind::Header, text)), + }); + } else if line.starts_with('+') { + adds.push(line.to_string()); + } else if line.starts_with('-') { + dels.push(line.to_string()); + } else { + flush(&mut rows, &mut dels, &mut adds); + let text = line.to_string(); + rows.push(SplitRow { + left: Some((DiffLineKind::Context, text.clone())), + right: Some((DiffLineKind::Context, text)), + }); + } + } + flush(&mut rows, &mut dels, &mut adds); + rows +} + +pub fn rename_paths(diff: &str) -> Option<(String, String)> { + let mut from = None; + let mut to = None; + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("rename from ") { + from = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("rename to ") { + to = Some(rest.to_string()); + } + } + match (from, to) { + (Some(old), Some(new)) if old != new => Some((old, new)), + _ => None, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnnotatedLine { + pub raw: String, + pub kind: DiffLineKind, + pub old_no: Option, + pub new_no: Option, +} + +pub fn annotate_unified(diff: &str) -> Vec { + let mut old = 0u32; + let mut new = 0u32; + let mut lines = Vec::new(); + for line in diff.lines() { + if line.starts_with("@@") { + if let Some((o, n)) = parse_hunk_header(line) { + old = o; + new = n; + } + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Header, + old_no: None, + new_no: None, + }); + } else if is_header(line) { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Header, + old_no: None, + new_no: None, + }); + } else if line.starts_with('+') { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Add, + old_no: None, + new_no: Some(new), + }); + new = new.saturating_add(1); + } else if line.starts_with('-') { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Del, + old_no: Some(old), + new_no: None, + }); + old = old.saturating_add(1); + } else { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Context, + old_no: Some(old), + new_no: Some(new), + }); + old = old.saturating_add(1); + new = new.saturating_add(1); + } + } + lines +} + +pub fn parse_hunk_header(line: &str) -> Option<(u32, u32)> { + let rest = line.strip_prefix("@@ ")?; + let mut parts = rest.split_whitespace(); + let old = parts.next()?.strip_prefix('-')?; + let new = parts.next()?.strip_prefix('+')?; + let old_n = old.split(',').next()?.parse().ok()?; + let new_n = new.split(',').next()?.parse().ok()?; + Some((old_n, new_n)) +} + +fn is_header(line: &str) -> bool { + line.starts_with("diff ") + || line.starts_with("index ") + || line.starts_with("---") + || line.starts_with("+++") + || line.starts_with("@@") + || line.starts_with("new file") + || line.starts_with("deleted file") + || line.starts_with("rename ") + || line.starts_with("similarity ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pairs_replacement_on_one_row() { + let rows = split_rows("-old\n+new\n"); + assert_eq!( + rows, + vec![SplitRow { + left: Some((DiffLineKind::Del, "-old".into())), + right: Some((DiffLineKind::Add, "+new".into())), + }] + ); + } + + #[test] + fn context_appears_on_both_sides() { + let rows = split_rows(" keep\n"); + assert_eq!( + rows, + vec![SplitRow { + left: Some((DiffLineKind::Context, " keep".into())), + right: Some((DiffLineKind::Context, " keep".into())), + }] + ); + } + + #[test] + fn unmatched_deletion_leaves_right_empty() { + let rows = split_rows("-gone\n keep\n"); + assert_eq!( + rows, + vec![ + SplitRow { + left: Some((DiffLineKind::Del, "-gone".into())), + right: None, + }, + SplitRow { + left: Some((DiffLineKind::Context, " keep".into())), + right: Some((DiffLineKind::Context, " keep".into())), + }, + ] + ); + } + + #[test] + fn hunk_header_spans_both_columns() { + let rows = split_rows("@@ -1,2 +1,2 @@\n-a\n+b\n"); + assert_eq!(rows[0].left.as_ref().unwrap().0, DiffLineKind::Header); + assert_eq!(rows[0].right.as_ref().unwrap().0, DiffLineKind::Header); + assert_eq!(rows[1].left.as_ref().unwrap().0, DiffLineKind::Del); + assert_eq!(rows[1].right.as_ref().unwrap().0, DiffLineKind::Add); + } + + #[test] + fn rename_paths_reads_git_headers() { + let diff = "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n"; + assert_eq!(rename_paths(diff), Some(("old.rs".into(), "new.rs".into()))); + assert_eq!(rename_paths("diff --git a/a.rs b/a.rs\n"), None); + } + + #[test] + fn annotate_unified_tracks_old_and_new_line_numbers() { + let lines = annotate_unified("@@ -10,2 +12,3 @@\n keep\n-old\n+new\n+more\n"); + assert_eq!(parse_hunk_header("@@ -10,2 +12,3 @@ fn"), Some((10, 12))); + assert_eq!(lines[1].kind, DiffLineKind::Context); + assert_eq!(lines[1].old_no, Some(10)); + assert_eq!(lines[1].new_no, Some(12)); + assert_eq!(lines[2].kind, DiffLineKind::Del); + assert_eq!(lines[2].old_no, Some(11)); + assert_eq!(lines[3].kind, DiffLineKind::Add); + assert_eq!(lines[3].new_no, Some(13)); + assert_eq!(lines[4].new_no, Some(14)); + } +} diff --git a/src-gpui/src/i18n.rs b/src-gpui/src/i18n.rs new file mode 100644 index 00000000..c8ad764b --- /dev/null +++ b/src-gpui/src/i18n.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Locale { + #[default] + En, + Zh, +} + +impl Locale { + pub fn as_code(self) -> &'static str { + match self { + Self::En => "en", + Self::Zh => "zh", + } + } + + pub fn from_code(code: &str) -> Self { + if code.starts_with("zh") { + Self::Zh + } else { + Self::En + } + } +} + +fn table(locale: Locale) -> &'static HashMap { + fn parse(raw: &str) -> HashMap { + #[derive(Deserialize)] + struct Loose(HashMap); + let Loose(map) = serde_json::from_str(raw).unwrap_or_else(|_| Loose(HashMap::new())); + map.into_iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) + .collect() + } + + match locale { + Locale::En => { + static EN: OnceLock> = OnceLock::new(); + EN.get_or_init(|| parse(include_str!("../../messages/en.json"))) + } + Locale::Zh => { + static ZH: OnceLock> = OnceLock::new(); + ZH.get_or_init(|| parse(include_str!("../../messages/zh.json"))) + } + } +} + +pub fn t(locale: Locale, key: &str) -> String { + table(locale) + .get(key) + .cloned() + .or_else(|| table(Locale::En).get(key).cloned()) + .unwrap_or_else(|| key.to_string()) +} + +pub fn tf(locale: Locale, key: &str, pairs: &[(&str, &str)]) -> String { + let mut s = t(locale, key); + for (k, v) in pairs { + s = s.replace(&format!("{{{k}}}"), v); + } + s +} diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs new file mode 100644 index 00000000..18462500 --- /dev/null +++ b/src-gpui/src/main.rs @@ -0,0 +1,68 @@ +mod app; +mod backend; +mod detector; +mod diff; +mod i18n; +mod platform; +mod prefs; +mod review; +mod state; +mod timefmt; +mod ui; +mod updater; + +use gpui::{point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions}; +use gpui_component::Root; +use tracing_subscriber::prelude::*; + +use crate::app::AppView; +use crate::backend::Backend; + +fn main() { + let (log_layer, log_handle) = infra::logger::ChannelLayer::new(); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer()) + .with(log_layer) + .init(); + + let backend = match Backend::init(log_handle) { + Ok(b) => b, + Err(err) => { + eprintln!("failed to init 2code backend: {err}"); + std::process::exit(1); + } + }; + + let app = Application::new(); + app.run(move |cx| { + gpui_component::init(cx); + AppView::bind_keys(cx); + + let bounds = Bounds::centered(None, size(px(1440.), px(900.)), cx); + let backend = backend.clone(); + cx.spawn(async move |cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(960.), px(600.))), + titlebar: Some(TitlebarOptions { + title: Some("2code".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(16.), px(24.))), + }), + ..Default::default() + }, + move |window, cx| { + let view = cx.new(|cx| AppView::new(backend.clone(), window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }, + )?; + Ok::<_, anyhow::Error>(()) + }) + .detach(); + cx.activate(true); + }); +} diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs new file mode 100644 index 00000000..c3bfaa54 --- /dev/null +++ b/src-gpui/src/platform.rs @@ -0,0 +1,607 @@ +use std::sync::OnceLock; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetectedApp { + pub id: &'static str, + pub name: &'static str, + pub command: &'static str, +} + +pub fn command_exists(command: &str) -> bool { + if command.is_empty() { + return false; + } + if let Some(paths) = std::env::var_os("PATH") { + if std::env::split_paths(&paths).any(|dir| dir.join(command).is_file()) { + return true; + } + } + #[cfg(target_os = "macos")] + { + let apps = [ + ("cursor", "/Applications/Cursor.app"), + ("code", "/Applications/Visual Studio Code.app"), + ("windsurf", "/Applications/Windsurf.app"), + ("zed", "/Applications/Zed.app"), + ("subl", "/Applications/Sublime Text.app"), + ("ghostty", "/Applications/Ghostty.app"), + ("kitty", "/Applications/kitty.app"), + ("warp", "/Applications/Warp.app"), + ("iterm2", "/Applications/iTerm.app"), + ("firefox", "/Applications/Firefox.app"), + ("chrome", "/Applications/Google Chrome.app"), + ("safari", "/Applications/Safari.app"), + ("edge", "/Applications/Microsoft Edge.app"), + ("brave", "/Applications/Brave Browser.app"), + ("arc", "/Applications/Arc.app"), + ("vivaldi", "/Applications/Vivaldi.app"), + ("orion", "/Applications/Orion.app"), + ("github", "/Applications/GitHub Desktop.app"), + ]; + if let Some((_, path)) = apps.iter().find(|(id, _)| *id == command) { + return std::path::Path::new(path).exists(); + } + } + false +} + +pub fn installed_editors() -> Vec { + [ + DetectedApp { + id: "vscode", + name: "VS Code", + command: "code", + }, + DetectedApp { + id: "cursor", + name: "Cursor", + command: "cursor", + }, + DetectedApp { + id: "windsurf", + name: "Windsurf", + command: "windsurf", + }, + DetectedApp { + id: "zed", + name: "Zed", + command: "zed", + }, + DetectedApp { + id: "sublime-text", + name: "Sublime Text", + command: "subl", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command)) + .collect() +} + +pub fn installed_terminals() -> Vec { + [ + DetectedApp { + id: "ghostty", + name: "Ghostty", + command: "ghostty", + }, + DetectedApp { + id: "iterm2", + name: "iTerm2", + command: "iterm2", + }, + DetectedApp { + id: "kitty", + name: "Kitty", + command: "kitty", + }, + DetectedApp { + id: "warp", + name: "Warp", + command: "warp", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command)) + .collect() +} + +pub fn installed_browsers() -> Vec { + [ + DetectedApp { + id: "safari", + name: "Safari", + command: "safari", + }, + DetectedApp { + id: "chrome", + name: "Google Chrome", + command: "google-chrome", + }, + DetectedApp { + id: "chrome-canary", + name: "Google Chrome Canary", + command: "google-chrome-canary", + }, + DetectedApp { + id: "edge", + name: "Microsoft Edge", + command: "microsoft-edge", + }, + DetectedApp { + id: "firefox", + name: "Firefox", + command: "firefox", + }, + DetectedApp { + id: "arc", + name: "Arc", + command: "arc", + }, + DetectedApp { + id: "brave", + name: "Brave Browser", + command: "brave", + }, + DetectedApp { + id: "vivaldi", + name: "Vivaldi", + command: "vivaldi", + }, + DetectedApp { + id: "orion", + name: "Orion", + command: "orion", + }, + DetectedApp { + id: "chromium", + name: "Chromium", + command: "chromium", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command) || command_exists(app.id)) + .collect() +} + +pub fn list_shells() -> Vec { + let mut shells = Vec::new(); + if let Ok(text) = std::fs::read_to_string("/etc/shells") { + for line in text.lines() { + let line = line.trim(); + if line.starts_with('/') && std::path::Path::new(line).is_file() { + shells.push(line.to_string()); + } + } + } + for fallback in ["/bin/zsh", "/bin/bash", "/bin/sh", "/usr/bin/fish"] { + if std::path::Path::new(fallback).is_file() && !shells.iter().any(|s| s == fallback) { + shells.push(fallback.to_string()); + } + } + shells +} + +#[derive(Clone, Debug)] +pub struct SystemFont { + pub family: String, + pub is_mono: bool, +} + +pub fn list_system_fonts() -> Vec { + static FONTS: OnceLock> = OnceLock::new(); + FONTS.get_or_init(load_fonts).clone() +} + +pub fn list_mono_fonts() -> Vec { + let mut fonts: Vec = filter_fonts(&list_system_fonts(), false); + if fonts.is_empty() { + fonts = list_system_fonts().into_iter().map(|f| f.family).collect(); + } + fonts +} + +pub fn visible_font_families(show_all: bool) -> Vec { + if show_all { + list_system_fonts().into_iter().map(|f| f.family).collect() + } else { + list_mono_fonts() + } +} + +pub fn filter_fonts(fonts: &[SystemFont], show_all: bool) -> Vec { + fonts + .iter() + .filter(|font| show_all || font.is_mono) + .map(|font| font.family.clone()) + .collect() +} + +fn load_fonts() -> Vec { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let mut families = std::collections::BTreeMap::new(); + for face in db.faces() { + for (family, _) in &face.families { + let family = family.trim(); + if family.is_empty() { + continue; + } + families + .entry(family.to_string()) + .and_modify(|mono: &mut bool| *mono = *mono || face.monospaced) + .or_insert(face.monospaced); + } + } + families + .into_iter() + .map(|(family, is_mono)| SystemFont { family, is_mono }) + .collect() +} + +pub fn list_system_sounds() -> Vec { + static SOUNDS: OnceLock> = OnceLock::new(); + SOUNDS.get_or_init(load_sounds).clone() +} + +pub fn play_system_sound(name: &str) -> Result<(), String> { + if name.is_empty() { + return Ok(()); + } + play_sound_name(name) +} + +pub fn send_notification(title: &str, body: &str) { + if title.is_empty() { + return; + } + #[cfg(target_os = "macos")] + { + let script = format!( + "display notification {} with title {}", + apple_script_string(body), + apple_script_string(title) + ); + let _ = std::process::Command::new("osascript").args(["-e", &script]).spawn(); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("notify-send").args([title, body]).spawn(); + } + #[cfg(target_os = "windows")] + { + let title = title.replace('\'', "''"); + let body = body.replace('\'', "''"); + let script = format!( + "[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(4000, '{title}', '{body}', 'Info')" + ); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .spawn(); + } +} + +fn apple_script_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +fn is_valid_sound_name(name: &str) -> bool { + !name.is_empty() && !name.contains("..") && !name.contains('/') && !name.contains('\\') +} + +fn load_sounds() -> Vec { + #[cfg(target_os = "macos")] + { + return list_macos_sounds(); + } + #[cfg(target_os = "linux")] + { + return list_linux_sounds(); + } + #[cfg(target_os = "windows")] + { + return list_windows_sounds(); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + Vec::new() + } +} + +fn play_sound_name(name: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + return play_macos_sound(name); + } + #[cfg(target_os = "linux")] + { + return play_linux_sound(name); + } + #[cfg(target_os = "windows")] + { + return play_windows_sound(name); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = name; + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn list_macos_sounds() -> Vec { + let mut sounds: Vec = std::fs::read_dir("/System/Library/Sounds") + .into_iter() + .flatten() + .filter_map(|entry| { + let name = entry.ok()?.file_name().into_string().ok()?; + name.strip_suffix(".aiff").map(|s| s.to_string()) + }) + .collect(); + sounds.sort(); + sounds +} + +#[cfg(target_os = "macos")] +fn play_macos_sound(name: &str) -> Result<(), String> { + if !is_valid_sound_name(name) { + return Err(format!("Sound not found: {name}")); + } + let path = std::path::Path::new("/System/Library/Sounds").join(format!("{name}.aiff")); + if !path.exists() { + return Err(format!("Sound not found: {name}")); + } + std::process::Command::new("afplay") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +const LINUX_SOUND_EXTENSIONS: &[&str] = &["oga", "ogg", "wav", "aiff", "aif"]; + +#[cfg(target_os = "linux")] +fn list_linux_sounds() -> Vec { + use std::collections::BTreeSet; + let mut sounds = BTreeSet::new(); + for root in linux_sound_roots() { + collect_linux_sounds(&root, &mut sounds); + } + sounds.into_iter().collect() +} + +#[cfg(target_os = "linux")] +fn play_linux_sound(name: &str) -> Result<(), String> { + let path = find_linux_sound_file(name).ok_or_else(|| format!("Sound not found: {name}"))?; + if command_in_path("canberra-gtk-play") { + return spawn_player("canberra-gtk-play", &["-i", name]); + } + let path = path + .to_str() + .ok_or_else(|| format!("Sound path is not valid UTF-8: {}", path.display()))?; + if command_in_path("pw-play") { + return spawn_player("pw-play", &[path]); + } + if command_in_path("paplay") { + return spawn_player("paplay", &[path]); + } + Err("No Linux sound player found.".into()) +} + +#[cfg(target_os = "linux")] +fn linux_sound_roots() -> Vec { + let mut dirs = Vec::new(); + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + dirs.push(std::path::PathBuf::from(data_home)); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push(std::path::PathBuf::from(home).join(".local/share")); + } + if let Some(data_dirs) = std::env::var_os("XDG_DATA_DIRS") { + dirs.extend(std::env::split_paths(&data_dirs)); + } else { + dirs.push(std::path::PathBuf::from("/usr/local/share")); + dirs.push(std::path::PathBuf::from("/usr/share")); + } + dirs.into_iter() + .map(|dir| dir.join("sounds")) + .filter(|dir| dir.is_dir()) + .collect() +} + +#[cfg(target_os = "linux")] +fn collect_linux_sounds(dir: &std::path::Path, sounds: &mut std::collections::BTreeSet) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_linux_sounds(&path, sounds); + continue; + } + if !is_linux_sound_file(&path) { + continue; + } + if let Some(name) = path.file_stem().and_then(|name| name.to_str()) { + sounds.insert(name.to_string()); + } + } +} + +#[cfg(target_os = "linux")] +fn find_linux_sound_file(name: &str) -> Option { + if !is_valid_sound_name(name) { + return None; + } + for root in linux_sound_roots() { + if let Some(path) = find_linux_sound_file_in_dir(&root, name) { + return Some(path); + } + } + None +} + +#[cfg(target_os = "linux")] +fn find_linux_sound_file_in_dir(dir: &std::path::Path, name: &str) -> Option { + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_linux_sound_file_in_dir(&path, name) { + return Some(found); + } + continue; + } + if is_linux_sound_file(&path) && path.file_stem().and_then(|stem| stem.to_str()) == Some(name) { + return Some(path); + } + } + None +} + +#[cfg(target_os = "linux")] +fn is_linux_sound_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + LINUX_SOUND_EXTENSIONS + .iter() + .any(|allowed| extension.eq_ignore_ascii_case(allowed)) + }) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn command_in_path(command: &str) -> bool { + command_exists(command) +} + +#[cfg(target_os = "linux")] +fn spawn_player(program: &str, args: &[&str]) -> Result<(), String> { + std::process::Command::new(program) + .args(args) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_sounds_dir() -> std::path::PathBuf { + std::path::PathBuf::from(r"C:\Windows\Media") +} + +#[cfg(target_os = "windows")] +fn list_windows_sounds() -> Vec { + use std::collections::BTreeSet; + let mut sounds = BTreeSet::new(); + collect_windows_sounds(&windows_sounds_dir(), &mut sounds); + sounds.into_iter().collect() +} + +#[cfg(target_os = "windows")] +fn collect_windows_sounds(dir: &std::path::Path, sounds: &mut std::collections::BTreeSet) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_windows_sounds(&path, sounds); + continue; + } + if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("wav")) + { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + sounds.insert(name.to_string()); + } + } + } +} + +#[cfg(target_os = "windows")] +fn play_windows_sound(name: &str) -> Result<(), String> { + if !is_valid_sound_name(name) { + return Err(format!("Sound not found: {name}")); + } + let path = find_windows_sound_file(name).ok_or_else(|| format!("Sound not found: {name}"))?; + std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!("(New-Object Media.SoundPlayer '{}').PlaySync()", path.display()), + ]) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn find_windows_sound_file(name: &str) -> Option { + fn walk(dir: &std::path::Path, name: &str) -> Option { + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = walk(&path, name) { + return Some(found); + } + } else if path.file_stem().and_then(|s| s.to_str()) == Some(name) { + return Some(path); + } + } + None + } + walk(&windows_sounds_dir(), name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_command_does_not_exist() { + assert!(!command_exists("")); + } + + #[test] + fn sh_or_bash_exists_on_unix() { + assert!(command_exists("sh") || std::path::Path::new("/bin/sh").is_file()); + } + + #[test] + fn list_shells_includes_a_real_shell() { + let shells = list_shells(); + assert!( + shells + .iter() + .any(|s| s.ends_with("/sh") || s.ends_with("/bash") || s.ends_with("/zsh")), + "{shells:?}" + ); + } + + #[test] + fn apple_script_string_escapes_quotes() { + assert_eq!(apple_script_string(r#"say "hi""#), r#""say \"hi\"""#); + } + + #[test] + fn filter_fonts_respects_show_all() { + let fonts = [ + SystemFont { + family: "Menlo".into(), + is_mono: true, + }, + SystemFont { + family: "Inter".into(), + is_mono: false, + }, + ]; + assert_eq!(filter_fonts(&fonts, false), vec!["Menlo".to_string()]); + assert_eq!( + filter_fonts(&fonts, true), + vec!["Menlo".to_string(), "Inter".to_string()] + ); + } +} diff --git a/src-gpui/src/prefs.rs b/src-gpui/src/prefs.rs new file mode 100644 index 00000000..57d4e7f0 --- /dev/null +++ b/src-gpui/src/prefs.rs @@ -0,0 +1,348 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::i18n::Locale; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ThemePref { + #[default] + System, + Light, + Dark, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RadiusPref { + None, + Small, + #[default] + Medium, + Large, + XLarge, +} + +impl RadiusPref { + pub fn label_key(self) -> &'static str { + match self { + Self::None => "radiusNone", + Self::Small => "radiusSmall", + Self::Medium => "radiusMedium", + Self::Large => "radiusLarge", + Self::XLarge => "radiusXLarge", + } + } + + pub fn scale(self) -> f32 { + match self { + Self::None => 0.0, + Self::Small => 0.6, + Self::Medium => 1.0, + Self::Large => 1.4, + Self::XLarge => 1.8, + } + } + + pub fn all() -> [Self; 5] { + [Self::None, Self::Small, Self::Medium, Self::Large, Self::XLarge] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalTemplatePref { + pub id: String, + pub name: String, + pub shell: String, + pub cwd: String, + pub commands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prefs { + pub language: Locale, + pub theme: ThemePref, + pub radius: RadiusPref, + pub worktree_dir: String, + pub debug_mode: bool, + pub performance_profile: bool, + pub show_avatars: bool, + pub sidebar_width: f32, + pub sidebar_collapsed: bool, + pub collapsed_groups: Vec, + pub profile_sidebar_width: f32, + pub profile_sidebar_open: bool, + pub terminal_theme_dark: String, + pub terminal_theme_light: String, + pub sync_terminal_theme: bool, + pub font_family: String, + pub font_size: f32, + pub default_shell: String, + pub custom_shell: String, + pub notifications: bool, + pub notification_sound: String, + pub topbar_controls: Vec, + pub editor_app: String, + pub terminal_app: String, + pub accept_beta: bool, + pub templates: Vec, + pub collapsed_projects: Vec, + #[serde(default)] + pub show_all_fonts: bool, +} + +impl Default for Prefs { + fn default() -> Self { + Self { + language: Locale::En, + theme: ThemePref::System, + radius: RadiusPref::Medium, + worktree_dir: String::new(), + debug_mode: false, + performance_profile: false, + show_avatars: true, + sidebar_width: 250.0, + sidebar_collapsed: false, + collapsed_groups: Vec::new(), + profile_sidebar_width: 208.0, + profile_sidebar_open: true, + terminal_theme_dark: "GitHub Dark".into(), + terminal_theme_light: "GitHub Light".into(), + sync_terminal_theme: false, + font_family: "JetBrains Mono".into(), + font_size: 13.0, + default_shell: String::new(), + custom_shell: String::new(), + notifications: false, + notification_sound: "Ping".into(), + topbar_controls: default_topbar_controls(), + editor_app: "code".into(), + terminal_app: String::new(), + accept_beta: false, + templates: Vec::new(), + collapsed_projects: Vec::new(), + show_all_fonts: false, + } + } +} + +impl Prefs { + pub fn load(app_data_dir: &Path) -> Self { + let path = app_data_dir.join("gpui-prefs.json"); + std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() + } + + pub fn save(&self, app_data_dir: &Path) { + let path = app_data_dir.join("gpui-prefs.json"); + if let Ok(raw) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, raw); + } + } + + pub fn effective_shell(&self) -> String { + if self.default_shell == "custom" && !self.custom_shell.is_empty() { + return self.custom_shell.clone(); + } + if !self.default_shell.is_empty() && self.default_shell != "custom" { + return self.default_shell.clone(); + } + crate::backend::default_shell() + } +} + +pub fn default_topbar_controls() -> Vec { + vec![ + "github-desktop".into(), + "editor".into(), + "terminal".into(), + "pr-status".into(), + ] +} + +pub fn move_topbar_control(controls: &mut Vec, id: &str, to: usize) { + if let Some(from) = controls.iter().position(|item| item == id) { + let item = controls.remove(from); + let to = to.min(controls.len()); + controls.insert(to, item); + return; + } + controls.insert(to.min(controls.len()), id.to_string()); +} + +pub fn upsert_template( + templates: &mut Vec, + id: Option<&str>, + name: String, + shell: String, + cwd: String, + commands: Vec, +) { + if let Some(id) = id { + if let Some(template) = templates.iter_mut().find(|t| t.id == id) { + template.name = name; + template.shell = shell; + template.cwd = cwd; + template.commands = commands; + return; + } + } + templates.push(TerminalTemplatePref { + id: uuid::Uuid::new_v4().to_string(), + name, + shell, + cwd, + commands, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_template_adds_then_updates() { + let mut templates = Vec::new(); + upsert_template( + &mut templates, + None, + "Dev".into(), + "/bin/zsh".into(), + ".".into(), + vec!["npm run dev".into()], + ); + assert_eq!(templates.len(), 1); + let id = templates[0].id.clone(); + upsert_template( + &mut templates, + Some(&id), + "Dev Server".into(), + "/bin/bash".into(), + "apps/web".into(), + vec!["pnpm dev".into()], + ); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].name, "Dev Server"); + assert_eq!(templates[0].shell, "/bin/bash"); + assert_eq!(templates[0].cwd, "apps/web"); + assert_eq!(templates[0].commands, vec!["pnpm dev".to_string()]); + } + + #[test] + fn topbar_defaults_and_drag_move() { + assert_eq!( + default_topbar_controls(), + vec![ + "github-desktop".to_string(), + "editor".to_string(), + "terminal".to_string(), + "pr-status".to_string() + ] + ); + let mut controls = default_topbar_controls(); + move_topbar_control(&mut controls, "pr-status", 0); + assert_eq!(controls[0], "pr-status"); + move_topbar_control(&mut controls, "editor", 3); + assert_eq!(controls.last().map(String::as_str), Some("editor")); + controls.retain(|id| id != "terminal"); + move_topbar_control(&mut controls, "terminal", 1); + assert_eq!(controls[1], "terminal"); + } + + #[test] + fn leftover_notification_defaults_match_inventory() { + let prefs = Prefs::default(); + assert!(!prefs.notifications); + assert_eq!(prefs.notification_sound, "Ping"); + } + + #[test] + fn missing_show_all_fonts_defaults_false() { + let raw = r#"{"language":"en","theme":"system","radius":"medium","worktree_dir":"","debug_mode":false,"performance_profile":false,"show_avatars":true,"sidebar_width":250.0,"sidebar_collapsed":false,"collapsed_groups":[],"profile_sidebar_width":208.0,"profile_sidebar_open":true,"terminal_theme_dark":"GitHub Dark","terminal_theme_light":"GitHub Light","sync_terminal_theme":false,"font_family":"Menlo","font_size":13.0,"default_shell":"","custom_shell":"","notifications":true,"notification_sound":"","topbar_controls":[],"editor_app":"","terminal_app":"","accept_beta":false,"templates":[],"collapsed_projects":[]}"#; + let prefs: Prefs = serde_json::from_str(raw).expect("old prefs still load"); + assert!(!prefs.show_all_fonts); + assert_eq!(prefs.font_family, "Menlo"); + } +} + +#[derive(Debug, Clone, Copy)] +pub struct TermTheme { + pub name: &'static str, + pub bg: u32, + pub fg: u32, + pub cursor: u32, + pub is_light: bool, +} + +pub const TERM_THEMES: &[TermTheme] = &[ + TermTheme { + name: "GitHub Dark", + bg: 0x0d1117, + fg: 0xc9d1d9, + cursor: 0x58a6ff, + is_light: false, + }, + TermTheme { + name: "GitHub Light", + bg: 0xffffff, + fg: 0x1f2328, + cursor: 0x0969da, + is_light: true, + }, + TermTheme { + name: "Dracula", + bg: 0x282a36, + fg: 0xf8f8f2, + cursor: 0xff79c6, + is_light: false, + }, + TermTheme { + name: "Ayu Dark", + bg: 0x0a0e14, + fg: 0xb3b1ad, + cursor: 0xffb454, + is_light: false, + }, + TermTheme { + name: "Ayu Light", + bg: 0xfafafa, + fg: 0x5c6773, + cursor: 0xff6a00, + is_light: true, + }, + TermTheme { + name: "Solarized Dark", + bg: 0x002b36, + fg: 0x839496, + cursor: 0x268bd2, + is_light: false, + }, + TermTheme { + name: "Solarized Light", + bg: 0xfdf6e3, + fg: 0x657b83, + cursor: 0x268bd2, + is_light: true, + }, + TermTheme { + name: "One Dark", + bg: 0x282c34, + fg: 0xabb2bf, + cursor: 0x61afef, + is_light: false, + }, + TermTheme { + name: "One Light", + bg: 0xfafafa, + fg: 0x383a42, + cursor: 0x4078f2, + is_light: true, + }, +]; + +pub fn term_theme_by_name(name: &str) -> &'static TermTheme { + TERM_THEMES.iter().find(|t| t.name == name).unwrap_or(&TERM_THEMES[0]) +} diff --git a/src-gpui/src/review.rs b/src-gpui/src/review.rs new file mode 100644 index 00000000..52df49d8 --- /dev/null +++ b/src-gpui/src/review.rs @@ -0,0 +1,278 @@ +use crate::diff::{self, DiffLineKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReviewSide { + #[default] + Additions, + Deletions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReviewRange { + pub start: u32, + pub end: u32, + pub side: ReviewSide, + pub end_side: ReviewSide, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewComment { + pub id: String, + pub file_name: String, + pub display_name: String, + pub prev_name: Option, + pub range: ReviewRange, + pub selected_text: String, + pub body: String, + pub created_at: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewSelection { + pub file: String, + pub display_name: String, + pub prev_name: Option, + pub range: ReviewRange, +} + +pub fn format_review_range(range: ReviewRange) -> String { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if start == end { + format!("{start}") + } else { + format!("{start}-{end}") + } +} + +pub fn normalize_clipboard_block(text: &str) -> String { + let mut lines: Vec = text.split('\n').map(|line| line.trim_end().to_string()).collect(); + while lines.first().is_some_and(|l| l.is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + lines.join("\n") +} + +pub fn selected_text_from_sides(additions: &[&str], deletions: &[&str], range: ReviewRange) -> String { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if range.side == range.end_side { + return side_lines( + if range.side == ReviewSide::Additions { + additions + } else { + deletions + }, + range.side, + start, + end, + ); + } + let deletion_text = side_lines(deletions, ReviewSide::Deletions, start, end); + let addition_text = side_lines(additions, ReviewSide::Additions, start, end); + [ + if deletion_text.is_empty() { + String::new() + } else { + format!("# deletions\n{deletion_text}") + }, + if addition_text.is_empty() { + String::new() + } else { + format!("# additions\n{addition_text}") + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") +} + +fn side_lines(lines: &[&str], side: ReviewSide, start: u32, end: u32) -> String { + let prefix = match side { + ReviewSide::Additions => '+', + ReviewSide::Deletions => '-', + }; + let start_ix = start.saturating_sub(1) as usize; + let end_ix = end as usize; + lines + .get(start_ix..end_ix.min(lines.len())) + .unwrap_or(&[]) + .iter() + .map(|line| format!("{prefix}{}", line.trim_end())) + .collect::>() + .join("\n") +} + +pub fn selected_text_from_hunk(hunk: &str, range: ReviewRange) -> String { + let annotated = diff::annotate_unified(hunk); + let additions: Vec = annotated + .iter() + .filter(|l| l.kind == DiffLineKind::Add) + .map(|l| strip_diff_prefix(&l.raw)) + .collect(); + let deletions: Vec = annotated + .iter() + .filter(|l| l.kind == DiffLineKind::Del) + .map(|l| strip_diff_prefix(&l.raw)) + .collect(); + let add_refs: Vec<&str> = additions.iter().map(String::as_str).collect(); + let del_refs: Vec<&str> = deletions.iter().map(String::as_str).collect(); + selected_text_from_sides(&add_refs, &del_refs, range) +} + +fn strip_diff_prefix(raw: &str) -> String { + raw.chars().next().map(|_| raw[1..].to_string()).unwrap_or_default() +} + +pub fn create_review_comment( + file_name: impl Into, + prev_name: Option, + range: ReviewRange, + selected_text: String, + body: &str, +) -> ReviewComment { + let file_name = file_name.into(); + let display_name = match &prev_name { + Some(prev) if prev != &file_name => format!("{prev} -> {file_name}"), + _ => file_name.clone(), + }; + ReviewComment { + id: uuid::Uuid::new_v4().to_string(), + file_name, + display_name, + prev_name, + range, + selected_text, + body: body.to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + } +} + +pub fn format_review_comments_for_agent(comments: &[ReviewComment]) -> String { + let mut lines = vec!["Please address these review comments:".to_string(), String::new()]; + for (index, comment) in comments.iter().enumerate() { + lines.push(format!( + "{}. {}:{}", + index + 1, + comment.file_name, + format_review_range(comment.range) + )); + lines.push("Selected diff:".into()); + lines.push("```diff".into()); + let selected = normalize_clipboard_block(&comment.selected_text); + lines.push(if selected.is_empty() { + "(no selected text available)".into() + } else { + selected + }); + lines.push("```".into()); + lines.push("Comment:".into()); + lines.push(normalize_clipboard_block(&comment.body)); + lines.push(String::new()); + } + lines.join("\n") +} + +pub fn line_in_range(range: ReviewRange, side: ReviewSide, line_no: u32) -> bool { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if line_no < start || line_no > end { + return false; + } + if range.side == range.end_side { + range.side == side + } else { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_review_range_collapses_single_line() { + assert_eq!( + format_review_range(ReviewRange { + start: 4, + end: 4, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }), + "4" + ); + assert_eq!( + format_review_range(ReviewRange { + start: 3, + end: 1, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }), + "1-3" + ); + } + + #[test] + fn copies_selected_diff_without_extra_prefix_spaces_or_trailing_whitespace() { + let comment = create_review_comment( + "src/example.ts", + None, + ReviewRange { + start: 1, + end: 3, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + selected_text_from_sides( + &[" const value = 1; ", "", " return value;"], + &[], + ReviewRange { + start: 1, + end: 3, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + ), + "\n tighten this up \n\n", + ); + assert!(format_review_comments_for_agent(&[comment]).contains( + [ + "1. src/example.ts:1-3", + "Selected diff:", + "```diff", + "+ const value = 1;", + "+", + "+ return value;", + "```", + "Comment:", + " tighten this up", + ] + .join("\n") + .as_str() + )); + } + + #[test] + fn display_name_uses_rename_arrow() { + let comment = create_review_comment( + "new.rs", + Some("old.rs".into()), + ReviewRange { + start: 1, + end: 1, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + "+fn main() {}".into(), + "ok", + ); + assert_eq!(comment.display_name, "old.rs -> new.rs"); + } +} diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs new file mode 100644 index 00000000..abbde281 --- /dev/null +++ b/src-gpui/src/state.rs @@ -0,0 +1,1486 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use model::debug::LogEntry; +use model::filesystem::FileSearchResult; +use model::profile::Profile; +use model::project::{ + GitBranchInfo, GitCommit, GitDiffStats, GitPullRequestStatus, ProjectConfig, ProjectWithProfiles, +}; +use model::project_group::ProjectGroup; + +use crate::i18n::Locale; +use crate::prefs::Prefs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Route { + #[default] + Home, + Workspace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarMode { + #[default] + Files, + Git, + Notes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SettingsTab { + #[default] + General, + Terminal, + Templates, + Notification, + TopBar, + About, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitDiffTab { + #[default] + Changes, + History, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MdMenu { + Command, + Table, + Link, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffPreviewMode { + #[default] + Unified, + Split, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentStatus { + #[default] + Idle, + Running, + Waiting, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentKind { + #[default] + Unknown, + Claude, + Codex, + Gemini, + Cursor, + Copilot, + Amp, + Cline, + OpenCode, + Grok, + Kimi, + Devin, + Droid, + Hermes, + Kilo, + Kiro, + Pi, + Qoder, + Agy, + OpenClaw, + Other, +} + +impl AgentKind { + pub fn label(self) -> &'static str { + match self { + Self::Claude => "Claude", + Self::Codex => "Codex", + Self::Gemini => "Gemini", + Self::Cursor => "Cursor", + Self::Copilot => "Copilot", + Self::Amp => "Amp", + Self::Cline => "Cline", + Self::OpenCode => "OpenCode", + Self::Grok => "Grok", + Self::Kimi => "Kimi", + Self::Devin => "Devin", + Self::Droid => "Droid", + Self::Hermes => "Hermes", + Self::Kilo => "Kilo", + Self::Kiro => "Kiro", + Self::Pi => "Pi", + Self::Qoder => "Qoder", + Self::Agy => "Agy", + Self::OpenClaw => "OpenClaw", + Self::Other | Self::Unknown => "Agent", + } + } + + /// Inventory §9.1: leftover tab icons come only from these title keywords. + pub fn from_tab_title(title: &str) -> Option { + let lower = title.to_ascii_lowercase(); + const KEYS: &[(&str, AgentKind)] = &[ + ("openclaw", AgentKind::OpenClaw), + ("opencode", AgentKind::OpenCode), + ("claude", AgentKind::Claude), + ("codex", AgentKind::Codex), + ("gemini", AgentKind::Gemini), + ("kimi", AgentKind::Kimi), + ("cline", AgentKind::Cline), + ("qoder", AgentKind::Qoder), + ]; + KEYS.iter().find(|(key, _)| lower.contains(key)).map(|(_, kind)| *kind) + } + + pub fn tab_icon_kind(title: &str, _detected: Self) -> Self { + Self::from_tab_title(title).unwrap_or(Self::Unknown) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastAction { + OpenAbout, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogKind { + CreateProject, + DeleteProject, + RenameProject, + ProjectSettings, + CreateProfile, + DeleteProfile, + CloseUnsaved, + SwitchBranch, + OpenLink, + ChooseFile, + EditTemplate, + ReviewQueue, + DebugLog, + CreateGroup, +} + +#[derive(Debug, Clone)] +pub enum ContextMenu { + Project { id: String }, + Profile { id: String, project_id: String }, + File { path: String }, + GitFile { path: String }, + TreeBlank, + NewTerminal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarNavItem { + Home, + Project(String), + Profile { project_id: String, profile_id: String }, +} + +#[derive(Debug, Clone)] +pub struct Toast { + pub id: u64, + pub kind: ToastKind, + pub title: String, + pub body: String, + pub action: Option, + pub created: Instant, +} + +impl Toast { + pub fn ttl_secs(&self) -> u64 { + if self.action.is_some() { + 12 + } else { + 5 + } + } + + pub fn alive(&self) -> bool { + self.created.elapsed().as_secs() < self.ttl_secs() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Success, + Info, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct OpenFileTab { + pub path: String, + pub title: String, + pub content: String, + pub draft: String, + pub preview: bool, + pub preview_kind: String, + pub binary_note: String, + pub preview_path: String, + pub archive_entries: Vec<(String, String)>, + pub load_error: Option, +} + +impl OpenFileTab { + pub fn dirty(&self) -> bool { + !self.preview && self.draft != self.content + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnifiedTab { + Terminal { index: usize }, + File { index: usize }, +} + +pub struct TermSession { + pub id: String, + pub title: String, + pub profile_id: String, + pub parser: vt100::Parser, + pub detector: crate::detector::AgentStatusDetector, + pub search_open: bool, + pub search_ix: usize, + pub search_query: String, + pub agent: AgentStatus, + pub agent_kind: AgentKind, + pub completed_hidden: bool, + pub cols: u16, + pub rows: u16, + pub selection: Option<((u16, usize), (u16, usize))>, + pub selecting: bool, + pub click_cell: Option<(u16, usize)>, + pub osc_carry: Vec, + pub images: Vec, +} + +#[derive(Clone)] +pub struct TermImage { + pub row: u16, + pub col: u16, + pub bytes: Vec, +} + +impl TermSession { + pub fn new(id: String, title: String, profile_id: String) -> Self { + Self { + id, + title, + profile_id, + parser: vt100::Parser::new_with_callbacks(32, 120, 10_000, crate::detector::OscSink::default()), + detector: crate::detector::AgentStatusDetector::default(), + search_open: false, + search_ix: 0, + search_query: String::new(), + agent: AgentStatus::Idle, + agent_kind: AgentKind::Unknown, + completed_hidden: false, + cols: 120, + rows: 32, + selection: None, + selecting: false, + click_cell: None, + osc_carry: Vec::new(), + images: Vec::new(), + } + } + + pub fn osc_progress(&self) -> String { + self.parser.callbacks().progress.clone() + } + + pub fn clear_screen(&mut self) { + self.parser = + vt100::Parser::new_with_callbacks(self.rows, self.cols, 10_000, crate::detector::OscSink::default()); + self.selection = None; + self.selecting = false; + self.click_cell = None; + self.osc_carry.clear(); + self.images.clear(); + } + + pub fn feed(&mut self, bytes: &[u8]) { + let images = crate::detector::extract_iterm2_images(&mut self.osc_carry, bytes); + self.parser.process(bytes); + if !images.is_empty() { + let (row, col) = self.parser.screen().cursor_position(); + for data in images { + self.images.push(TermImage { row, col, bytes: data }); + } + if self.images.len() > 16 { + let extra = self.images.len() - 16; + self.images.drain(0..extra); + } + } + self.detect_agent(); + } + + pub fn set_size(&mut self, rows: u16, cols: u16) -> bool { + if self.rows == rows && self.cols == cols { + return false; + } + self.rows = rows; + self.cols = cols; + self.parser.screen_mut().set_size(rows, cols); + true + } + + fn detect_agent(&mut self) { + let text = self.parser.screen().contents(); + let osc = self.parser.callbacks(); + let prev = self.agent; + let (kind, mut status) = self.detector.detect(&self.title, &text, &osc.title, &osc.progress); + if matches!(prev, AgentStatus::Running | AgentStatus::Waiting) && status == AgentStatus::Idle { + status = AgentStatus::Completed; + } + if kind != AgentKind::Unknown { + self.agent_kind = kind; + } + if status != prev && matches!(status, AgentStatus::Completed) { + self.completed_hidden = false; + } + self.agent = status; + } + + pub fn screen_text(&self) -> String { + self.parser.screen().contents() + } + + pub fn begin_selection(&mut self, row: u16, col: usize, extend: bool) { + if extend { + if let Some((anchor, _)) = self.selection { + self.selection = Some((anchor, (row, col))); + self.selecting = true; + return; + } + } + self.selection = Some(((row, col), (row, col))); + self.selecting = true; + } + + pub fn extend_selection(&mut self, row: u16, col: usize) { + if !self.selecting { + return; + } + if let Some((anchor, _)) = self.selection { + self.selection = Some((anchor, (row, col))); + } + } + + pub fn finish_selection(&mut self) -> bool { + self.selecting = false; + !self.selected_text().is_empty() + } + + pub fn has_selection(&self) -> bool { + !self.selected_text().is_empty() + } + + pub fn cell_selected(&self, row: u16, col: usize) -> bool { + let Some(((ar, ac), (br, bc))) = self.normalized_selection() else { + return false; + }; + if row < ar || row > br { + return false; + } + if ar == br { + return col >= ac && col < bc; + } + if row == ar { + return col >= ac; + } + if row == br { + return col < bc; + } + true + } + + pub fn normalized_selection(&self) -> Option<((u16, usize), (u16, usize))> { + let (a, b) = self.selection?; + if (a.0, a.1) <= (b.0, b.1) { + Some((a, b)) + } else { + Some((b, a)) + } + } + + pub fn selected_text(&self) -> String { + selected_from_lines(&self.screen_text(), self.selection) + } + + pub fn search_hits(&self, query: &str) -> Vec<(u16, usize, usize)> { + if query.is_empty() { + return Vec::new(); + } + let needle = query.to_ascii_lowercase(); + self.screen_text() + .lines() + .enumerate() + .flat_map(|(row, line)| { + let hay = line.to_ascii_lowercase(); + hay.match_indices(&needle) + .map(|(col, _)| (row as u16, col, needle.len())) + .collect::>() + }) + .collect() + } +} + +pub fn selected_from_lines(text: &str, sel: Option<((u16, usize), (u16, usize))>) -> String { + let Some((a, b)) = sel else { + return String::new(); + }; + let (start, end) = if (a.0, a.1) <= (b.0, b.1) { (a, b) } else { (b, a) }; + if start == end { + return String::new(); + } + let lines: Vec<&str> = text.lines().collect(); + let mut out = String::new(); + for row in start.0..=end.0 { + let line = lines.get(row as usize).copied().unwrap_or(""); + let from = if row == start.0 { start.1.min(line.len()) } else { 0 }; + let to = if row == end.0 { + end.1.min(line.len()) + } else { + line.len() + }; + if row > start.0 { + out.push('\n'); + } + if from < to { + out.extend(line.chars().skip(from).take(to - from)); + } + } + out +} + +#[derive(Debug, Clone)] +pub struct TreeNode { + pub path: String, + pub name: String, + pub is_dir: bool, + pub expanded: bool, + pub children_loaded: bool, + pub children: Vec, +} + +/// Inventory §8.1: dirty/untracked paths show even when their parent was never scanned. +pub fn inject_git_paths(tree: &mut HashMap, files: impl IntoIterator>) { + if !tree.contains_key("") { + return; + } + for path in files { + let path = path.as_ref(); + if path.is_empty() || tree.contains_key(path) { + continue; + } + let mut parent = String::new(); + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + for (i, part) in parts.iter().enumerate() { + let current = if parent.is_empty() { + (*part).to_string() + } else { + format!("{parent}/{part}") + }; + let is_last = i + 1 == parts.len(); + tree.entry(current.clone()).or_insert_with(|| TreeNode { + path: current.clone(), + name: (*part).to_string(), + is_dir: !is_last, + expanded: false, + children_loaded: is_last, + children: Vec::new(), + }); + if let Some(pnode) = tree.get_mut(&parent) { + if !pnode.children.contains(¤t) { + pnode.children.push(current.clone()); + } + } + parent = current; + } + } +} + +pub struct Workspace { + pub project_id: String, + pub profile_id: String, + pub branch: String, + pub worktree: String, + pub project_name: String, + pub is_default: bool, + pub sidebar_mode: SidebarMode, + pub sidebar_open: bool, + pub terminals: Vec, + pub files: Vec, + pub active: Option, + pub tree: HashMap, + pub tree_selected: HashSet, + pub tree_anchor: Option, + pub tree_error: Option, + pub tree_scroll_y: f32, + pub git_files: Vec<(String, String)>, + pub git_included: HashSet, + pub git_stats: GitDiffStats, + pub git_ahead: u32, + pub notes: String, + pub notes_status: NotesStatus, + pub pr: Option, + pub pr_error: Option, + pub avatar: Option, + pub config: ProjectConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotesStatus { + #[default] + Saved, + Saving, + Failed, +} + +impl Workspace { + pub fn active_terminal(&self) -> Option<&TermSession> { + match self.active { + Some(UnifiedTab::Terminal { index }) => self.terminals.get(index), + _ => None, + } + } + + pub fn active_terminal_mut(&mut self) -> Option<&mut TermSession> { + match self.active { + Some(UnifiedTab::Terminal { index }) => self.terminals.get_mut(index), + _ => None, + } + } + + pub fn active_file(&self) -> Option<&OpenFileTab> { + match self.active { + Some(UnifiedTab::File { index }) => self.files.get(index), + _ => None, + } + } + + pub fn active_file_mut(&mut self) -> Option<&mut OpenFileTab> { + match self.active { + Some(UnifiedTab::File { index }) => self.files.get_mut(index), + _ => None, + } + } + + pub fn has_tabs(&self) -> bool { + !self.terminals.is_empty() || !self.files.is_empty() + } +} + +#[derive(Default)] +pub struct OverlayState { + pub dialog: Option, + pub dialog_project: Option, + pub dialog_profile: Option, + pub dialog_file: Option, + pub dialog_url: Option, + pub dialog_folder: Option, + pub dialog_error: Option, + pub dialog_busy: bool, + pub delete_warning: Option, + pub context_menu: Option<(ContextMenu, f32, f32)>, + pub palette_open: bool, + pub palette_results: Vec, + pub palette_index: usize, + pub palette_query: String, + pub palette_error: Option, + pub git_diff_open: bool, + pub git_diff_tab: GitDiffTab, + pub git_diff_mode: DiffPreviewMode, + pub git_diff_text: String, + pub git_diff_file: Option, + pub git_commits: Vec, + pub git_commit_files: Vec, + pub git_selected_commit: Option, + pub git_file_index: usize, + pub git_commit_index: Option, + pub git_commit_file_index: usize, + pub git_large_revealed: HashSet, + pub review_comments: Vec, + pub review_selection: Option, + pub review_edit_id: Option, + pub branches: Vec, + pub fuzzy_files: Vec, + pub onboarding: bool, + pub sort_mode: bool, + pub settings_open: bool, + pub settings_tab: SettingsTab, + pub debug_open: bool, + pub debug_logs: Vec, + pub debug_auto_scroll: bool, + pub debug_scroll_y: f32, + pub collapsed_projects: HashSet, + pub pending_close_file: Option, + pub editing_template: Option, + pub template_dialog_open: bool, + pub project_settings_tab: usize, + pub sidebar_drag: Option<(f32, f32)>, + pub profile_sidebar_drag: Option<(f32, f32)>, + pub sidebar_resize_focus: Option, + pub sidebar_nav: Option, + pub drag_project: Option, + pub drag_file: Option, + pub renaming_path: Option, + pub update_checked: bool, + pub group_menu_creating: bool, + pub file_search_open: bool, + pub file_search_ix: usize, + pub md_menu: Option, + pub md_preview: bool, + pub new_terminal_hover: bool, + pub delete_check_failed: bool, + pub open_link_menu: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupMenuRow { + Empty, + Group { id: String, name: String, current: bool }, + Remove, + Create, + CreateInput, +} + +pub fn project_group_menu_rows( + groups: &[(String, String)], + current_group: Option<&str>, + creating: bool, +) -> Vec { + let mut rows = Vec::new(); + if groups.is_empty() { + rows.push(GroupMenuRow::Empty); + } else { + for (id, name) in groups { + rows.push(GroupMenuRow::Group { + id: id.clone(), + name: name.clone(), + current: current_group == Some(id.as_str()), + }); + } + } + if current_group.is_some() { + rows.push(GroupMenuRow::Remove); + } + if creating || groups.is_empty() { + rows.push(GroupMenuRow::CreateInput); + } else { + rows.push(GroupMenuRow::Create); + } + rows +} + +pub struct AppData { + pub prefs: Prefs, + pub projects: Vec, + pub groups: Vec, + pub route: Route, + pub current_project: Option, + pub current_profile: Option, + pub workspaces: HashMap, + pub overlay: OverlayState, + pub toasts: Vec, + pub toast_seq: u64, + pub sidebar_error: Option, + pub locale: Locale, + pub notes_dirty_since: Option, + pub notes_bound_profile: Option, + pub file_dirty_since: Option, + pub avatars: HashMap, +} + +impl AppData { + pub fn current_ws(&self) -> Option<&Workspace> { + self.current_profile.as_ref().and_then(|id| self.workspaces.get(id)) + } + + pub fn current_ws_mut(&mut self) -> Option<&mut Workspace> { + let id = self.current_profile.clone()?; + self.workspaces.get_mut(&id) + } + + pub fn default_profile_of(&self, project_id: &str) -> Option { + self.projects.iter().find(|p| p.id == project_id).and_then(|p| { + p.profiles + .iter() + .find(|pr| pr.is_default) + .cloned() + .or_else(|| p.profiles.first().cloned()) + }) + } + + pub fn project(&self, id: &str) -> Option<&ProjectWithProfiles> { + self.projects.iter().find(|p| p.id == id) + } + + pub fn push_toast(&mut self, kind: ToastKind, title: impl Into, body: impl Into) { + self.push_toast_action(kind, title, body, None); + } + + pub fn push_toast_action( + &mut self, + kind: ToastKind, + title: impl Into, + body: impl Into, + action: Option, + ) { + self.toast_seq += 1; + self.toasts.push(Toast { + id: self.toast_seq, + kind, + title: title.into(), + body: body.into(), + action, + created: Instant::now(), + }); + } + + pub fn expire_toasts(&mut self) { + self.toasts.retain(Toast::alive); + } +} + +pub fn leftover_profile_sublist_open(has_extras: bool, collapsed: bool) -> bool { + has_extras && !collapsed +} + +pub fn leftover_project_row_active(has_extras: bool, default_is_current: bool) -> bool { + !has_extras && default_is_current +} + +pub fn leftover_rename_disabled(name: &str, init: &str) -> bool { + let trimmed = name.trim(); + trimmed.is_empty() || trimmed == init +} + +pub fn leftover_delete_profile_warning(parts: &[String]) -> Option { + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +pub fn leftover_new_terminal_split(has_templates: bool, empty_cta: bool) -> bool { + empty_cta && has_templates +} + +pub fn leftover_dialog_width(kind: DialogKind) -> f32 { + match kind { + DialogKind::ReviewQueue => 896.0, + DialogKind::SwitchBranch => 448.0, + DialogKind::ProjectSettings | DialogKind::DebugLog | DialogKind::EditTemplate => 512.0, + DialogKind::ChooseFile => 576.0, + _ => 384.0, + } +} + +pub fn leftover_template_draft_width() -> f32 { + 512.0 +} + +pub fn leftover_text_to_commands(text: &str) -> Vec { + text.lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect() +} + +pub fn leftover_command_preview(commands_text: &str) -> String { + let lines = leftover_text_to_commands(commands_text); + match lines.as_slice() { + [] => String::new(), + [one] => one.clone(), + [first, rest @ ..] => format!("{} +{}", first, rest.len()), + } +} + +pub fn leftover_template_draft_can_save(name: &str) -> bool { + !name.trim().is_empty() +} + +pub fn leftover_normalize_global_template(name: &str, commands_text: &str) -> Option<(String, Vec)> { + let name = name.trim(); + let commands = leftover_text_to_commands(commands_text); + if name.is_empty() || commands.is_empty() { + None + } else { + Some((name.to_string(), commands)) + } +} + +pub fn leftover_template_display_name(name: &str, fallback: &str) -> String { + if name.is_empty() { + fallback.to_string() + } else { + name.to_string() + } +} + +pub fn leftover_debug_auto_scroll(scroll_height: f32, scroll_top: f32, client_height: f32) -> bool { + scroll_height - scroll_top - client_height < 40.0 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeftoverTreeRow { + pub path: String, + pub is_dir: bool, +} + +pub fn leftover_visible_tree_rows(tree: &HashMap) -> Vec { + let mut rows = Vec::new(); + fn walk(tree: &HashMap, path: &str, rows: &mut Vec) { + let Some(node) = tree.get(path) else { + return; + }; + if !path.is_empty() { + rows.push(LeftoverTreeRow { + path: path.to_string(), + is_dir: node.is_dir, + }); + } + if node.is_dir && node.expanded { + for child in &node.children { + walk(tree, child, rows); + } + } + } + walk(tree, "", &mut rows); + rows +} + +pub fn leftover_sticky_folder(rows: &[LeftoverTreeRow], scroll_y: f32, row_height: f32) -> Option { + if rows.is_empty() || row_height <= 0.0 { + return None; + } + let first_visible = ((scroll_y / row_height).floor() as usize).min(rows.len()); + rows[..first_visible] + .iter() + .rev() + .find(|row| row.is_dir) + .map(|row| row.path.clone()) +} + +pub fn leftover_tree_row_height() -> f32 { + 22.0 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeftoverTemplateRow { + EmptyTitle, + EmptyHint, + ProjectHeader, + Project { index: usize, name: String, cwd: String }, + GlobalHeader, + Global { index: usize, name: String }, +} + +pub fn leftover_template_rows( + project: &[(String, String)], + global: &[String], + show_empty: bool, +) -> Vec { + if project.is_empty() && global.is_empty() { + return if show_empty { + vec![LeftoverTemplateRow::EmptyTitle, LeftoverTemplateRow::EmptyHint] + } else { + Vec::new() + }; + } + let mut rows = Vec::new(); + if !project.is_empty() { + rows.push(LeftoverTemplateRow::ProjectHeader); + for (index, (name, cwd)) in project.iter().enumerate() { + rows.push(LeftoverTemplateRow::Project { + index, + name: name.clone(), + cwd: cwd.clone(), + }); + } + } + if !global.is_empty() { + rows.push(LeftoverTemplateRow::GlobalHeader); + for (index, name) in global.iter().enumerate() { + rows.push(LeftoverTemplateRow::Global { + index, + name: name.clone(), + }); + } + } + rows +} + +pub fn leftover_browser_name(id: &str) -> &'static str { + match leftover_normalize_app_id(id) { + "safari" => "Safari", + "chrome" => "Google Chrome", + "chrome-canary" => "Google Chrome Canary", + "edge" => "Microsoft Edge", + "firefox" => "Firefox", + "arc" => "Arc", + "brave" => "Brave Browser", + "vivaldi" => "Vivaldi", + "orion" => "Orion", + "chromium" => "Chromium", + _ => "", + } +} + +pub fn leftover_normalize_app_id(id: &str) -> &str { + match id { + "code" | "vscode" => "vscode", + "sublime" | "subl" | "sublime-text" => "sublime-text", + "google-chrome" | "chrome" => "chrome", + other => other, + } +} + +pub const LEFTOVER_EDITOR_APP_IDS: &[&str] = &["vscode", "windsurf", "cursor", "zed", "sublime-text"]; +pub const LEFTOVER_TERMINAL_APP_IDS: &[&str] = &["ghostty", "iterm2", "kitty", "warp"]; + +pub fn leftover_configured_app<'a>(preferred: &str, category: &[&'a str], installed: &[&str]) -> Option<&'a str> { + let preferred = leftover_normalize_app_id(preferred); + if installed.iter().any(|id| leftover_normalize_app_id(id) == preferred) { + return category.iter().copied().find(|id| *id == preferred); + } + category.iter().copied().find(|id| { + installed + .iter() + .any(|installed| leftover_normalize_app_id(installed) == *id) + }) +} + +pub fn leftover_launch_app_i18n(id: &str) -> &'static str { + match leftover_normalize_app_id(id) { + "vscode" => "topbarVscode", + "windsurf" => "topbarWindsurf", + "cursor" => "topbarCursor", + "zed" => "topbarZed", + "sublime-text" => "topbarSublimeText", + "ghostty" => "topbarGhostty", + "iterm2" => "topbarIterm2", + "kitty" => "topbarKitty", + "warp" => "topbarWarp", + "github-desktop" => "topbarGithubDesktop", + _ => "topbarEditor", + } +} + +pub fn leftover_topbar_app_tooltip(kind: &str, app_label: &str) -> String { + format!("{kind} · {app_label}") +} + +pub fn leftover_pr_visible(has_pr: bool) -> bool { + has_pr +} + +pub fn leftover_pr_label(number: u32, state_label: &str) -> String { + format!("#{number} {state_label}") +} + +pub fn leftover_about_copyright(year: i32) -> String { + format!("© {year} AkaraChen") +} + +pub fn leftover_about_max_width() -> f32 { + 672.0 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverUpdateBadge { + Hidden, + Available, + NotAvailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeftoverSelectOption { + pub value: String, + pub label: String, +} + +pub fn leftover_select_label<'a>(options: &'a [LeftoverSelectOption], value: &'a str) -> &'a str { + options + .iter() + .find(|option| option.value == value) + .map(|option| option.label.as_str()) + .unwrap_or(value) +} + +pub fn leftover_native_select_height() -> f32 { + 32.0 +} + +pub fn leftover_update_badge(has_update: bool, checked_not_available: bool) -> LeftoverUpdateBadge { + if has_update { + LeftoverUpdateBadge::Available + } else if checked_not_available { + LeftoverUpdateBadge::NotAvailable + } else { + LeftoverUpdateBadge::Hidden + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeftoverFileMenuFlags { + pub can_open: bool, + pub can_open_default: bool, + pub can_reveal: bool, + pub can_rename: bool, + pub can_delete: bool, +} + +pub fn leftover_file_menu_flags(is_file: bool, exists: bool) -> LeftoverFileMenuFlags { + LeftoverFileMenuFlags { + can_open: is_file && exists, + can_open_default: exists, + can_reveal: exists, + can_rename: exists, + can_delete: exists, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverFileMenuRow { + Open, + OpenDefault, + Reveal, + Refresh, + NewFile, + NewFolder, + Rename, + CopyRel, + CopyAbs, + Delete, + Separator, +} + +pub fn leftover_file_menu_rows() -> &'static [LeftoverFileMenuRow] { + &[ + LeftoverFileMenuRow::Open, + LeftoverFileMenuRow::OpenDefault, + LeftoverFileMenuRow::Reveal, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::Refresh, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::NewFile, + LeftoverFileMenuRow::NewFolder, + LeftoverFileMenuRow::Rename, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::CopyRel, + LeftoverFileMenuRow::CopyAbs, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::Delete, + ] +} + +pub fn collect_sidebar_nav_items( + projects: &[ProjectWithProfiles], + groups: &[ProjectGroup], + collapsed_groups: &[String], + collapsed_projects: &HashSet, + _current_project: Option<&str>, +) -> Vec { + let mut items = Vec::new(); + if projects.is_empty() { + items.push(SidebarNavItem::Home); + return items; + } + let mut push_project = |project: &ProjectWithProfiles| { + items.push(SidebarNavItem::Project(project.id.clone())); + let has_extras = project.profiles.iter().any(|p| !p.is_default); + if leftover_profile_sublist_open(has_extras, collapsed_projects.contains(&project.id)) { + for profile in &project.profiles { + items.push(SidebarNavItem::Profile { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + }); + } + } + }; + for project in projects.iter().filter(|p| p.pinned_at.is_some()) { + push_project(project); + } + for group in groups { + if collapsed_groups.contains(&group.id) { + continue; + } + for project in projects + .iter() + .filter(|p| p.group_id.as_deref() == Some(group.id.as_str()) && p.pinned_at.is_none()) + { + push_project(project); + } + } + for project in projects + .iter() + .filter(|p| p.pinned_at.is_none() && p.group_id.is_none()) + { + push_project(project); + } + items +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inject_git_paths_adds_unscanned_dirty_files() { + let mut tree = HashMap::new(); + tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: String::new(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src".into()], + }, + ); + tree.insert( + "src".into(), + TreeNode { + path: "src".into(), + name: "src".into(), + is_dir: true, + expanded: false, + children_loaded: false, + children: Vec::new(), + }, + ); + inject_git_paths(&mut tree, ["src/new.rs", "notes.md"]); + assert!(tree.contains_key("src/new.rs")); + assert!(tree.contains_key("notes.md")); + assert!(tree["src"].children.contains(&"src/new.rs".to_string())); + assert!(tree[""].children.contains(&"notes.md".to_string())); + assert!(!tree["src/new.rs"].is_dir); + assert!(tree["src"].is_dir); + } + + #[test] + fn empty_sidebar_starts_at_home() { + assert_eq!( + collect_sidebar_nav_items(&[], &[], &[], &HashSet::new(), None), + vec![SidebarNavItem::Home] + ); + } + + #[test] + fn leftover_profile_sublist_only_when_extras_exist() { + assert!(!leftover_profile_sublist_open(false, false)); + assert!(!leftover_profile_sublist_open(false, true)); + assert!(leftover_profile_sublist_open(true, false)); + assert!(!leftover_profile_sublist_open(true, true)); + assert!(leftover_project_row_active(false, true)); + assert!(!leftover_project_row_active(true, true)); + assert!(!leftover_project_row_active(false, false)); + } + + #[test] + fn leftover_dialog_chrome_matches_inventory() { + assert_eq!(leftover_dialog_width(DialogKind::CreateProject), 384.0); + assert_eq!(leftover_dialog_width(DialogKind::CreateProfile), 384.0); + assert_eq!(leftover_dialog_width(DialogKind::ProjectSettings), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::EditTemplate), 512.0); + assert_eq!(leftover_template_draft_width(), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::SwitchBranch), 448.0); + assert_eq!(leftover_dialog_width(DialogKind::DebugLog), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::ReviewQueue), 896.0); + assert_eq!(leftover_dialog_width(DialogKind::ChooseFile), 576.0); + assert!(leftover_rename_disabled("", "App")); + assert!(leftover_rename_disabled("App", "App")); + assert!(leftover_rename_disabled(" App ", "App")); + assert!(!leftover_rename_disabled("Next", "App")); + assert_eq!(leftover_delete_profile_warning(&[]), None); + assert_eq!( + leftover_delete_profile_warning(&["local".into(), "ahead".into()]), + Some("local ahead".into()) + ); + assert!(!leftover_new_terminal_split(true, false)); + assert!(leftover_new_terminal_split(true, true)); + assert!(!leftover_new_terminal_split(false, true)); + } + + #[test] + fn leftover_topbar_and_browser_chrome_match_inventory() { + assert_eq!(leftover_browser_name("chrome"), "Google Chrome"); + assert_eq!(leftover_browser_name("google-chrome"), "Google Chrome"); + assert_eq!(leftover_browser_name("firefox"), "Firefox"); + assert_eq!(leftover_browser_name("safari"), "Safari"); + assert_eq!(leftover_browser_name("brave"), "Brave Browser"); + assert_eq!(leftover_normalize_app_id("code"), "vscode"); + assert_eq!(leftover_normalize_app_id("sublime"), "sublime-text"); + assert_eq!( + leftover_configured_app("code", LEFTOVER_EDITOR_APP_IDS, &["cursor", "vscode"]), + Some("vscode") + ); + assert_eq!( + leftover_configured_app("missing", LEFTOVER_EDITOR_APP_IDS, &["zed"]), + Some("zed") + ); + assert_eq!(leftover_configured_app("cursor", LEFTOVER_EDITOR_APP_IDS, &[]), None); + assert_eq!(leftover_topbar_app_tooltip("Editor", "VS Code"), "Editor · VS Code"); + assert!(!leftover_pr_visible(false)); + assert!(leftover_pr_visible(true)); + assert_eq!(leftover_pr_label(12, "Open"), "#12 Open"); + assert_eq!(leftover_launch_app_i18n("code"), "topbarVscode"); + assert_eq!(leftover_about_copyright(2026), "© 2026 AkaraChen"); + assert_eq!(leftover_about_max_width(), 672.0); + assert_eq!(leftover_update_badge(true, false), LeftoverUpdateBadge::Available); + assert_eq!(leftover_update_badge(false, true), LeftoverUpdateBadge::NotAvailable); + assert_eq!(leftover_update_badge(false, false), LeftoverUpdateBadge::Hidden); + assert_eq!(leftover_native_select_height(), 32.0); + let options = [ + LeftoverSelectOption { + value: "en".into(), + label: "English".into(), + }, + LeftoverSelectOption { + value: "zh".into(), + label: "中文".into(), + }, + ]; + assert_eq!(leftover_select_label(&options, "zh"), "中文"); + assert_eq!(leftover_select_label(&options, "missing"), "missing"); + } + + #[test] + fn leftover_file_menu_matches_inventory() { + let file = leftover_file_menu_flags(true, true); + assert!(file.can_open && file.can_rename && file.can_delete); + let folder = leftover_file_menu_flags(false, true); + assert!(!folder.can_open); + assert!(folder.can_reveal && folder.can_rename); + let missing = leftover_file_menu_flags(true, false); + assert!(!missing.can_open && !missing.can_delete); + assert!(leftover_file_menu_rows().contains(&LeftoverFileMenuRow::Separator)); + assert_eq!( + leftover_file_menu_rows() + .iter() + .filter(|row| matches!(row, LeftoverFileMenuRow::Separator)) + .count(), + 4 + ); + } + + #[test] + fn leftover_template_preview_matches_inventory() { + assert_eq!(leftover_command_preview(""), ""); + assert_eq!(leftover_command_preview("bun test"), "bun test"); + assert_eq!( + leftover_command_preview("bun install\nbun test\nbun lint"), + "bun install +2" + ); + assert_eq!( + leftover_text_to_commands(" bun test \n\n bun lint\n"), + vec!["bun test".to_string(), "bun lint".to_string()] + ); + assert!(leftover_template_draft_can_save("Dev")); + assert!(!leftover_template_draft_can_save(" ")); + assert_eq!( + leftover_normalize_global_template(" Dev ", "bun test\n"), + Some(("Dev".into(), vec!["bun test".into()])) + ); + assert_eq!(leftover_normalize_global_template("Dev", ""), None); + assert_eq!(leftover_template_display_name("", "Terminal Template"), "Terminal Template"); + assert_eq!(leftover_template_display_name("Dev", "Terminal Template"), "Dev"); + } + + #[test] + fn leftover_debug_auto_scroll_and_sticky_folder_match_inventory() { + assert!(leftover_debug_auto_scroll(400.0, 360.0, 40.0)); + assert!(leftover_debug_auto_scroll(400.0, 361.0, 40.0)); + assert!(!leftover_debug_auto_scroll(400.0, 300.0, 40.0)); + let mut tree = HashMap::new(); + tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: String::new(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src".into(), "README.md".into()], + }, + ); + tree.insert( + "src".into(), + TreeNode { + path: "src".into(), + name: "src".into(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src/lib.rs".into()], + }, + ); + tree.insert( + "src/lib.rs".into(), + TreeNode { + path: "src/lib.rs".into(), + name: "lib.rs".into(), + is_dir: false, + expanded: false, + children_loaded: true, + children: Vec::new(), + }, + ); + tree.insert( + "README.md".into(), + TreeNode { + path: "README.md".into(), + name: "README.md".into(), + is_dir: false, + expanded: false, + children_loaded: true, + children: Vec::new(), + }, + ); + let rows = leftover_visible_tree_rows(&tree); + assert_eq!( + rows.iter().map(|r| r.path.as_str()).collect::>(), + vec!["src", "src/lib.rs", "README.md"] + ); + assert_eq!(leftover_sticky_folder(&rows, 0.0, leftover_tree_row_height()), None); + assert_eq!( + leftover_sticky_folder(&rows, leftover_tree_row_height() + 1.0, leftover_tree_row_height()), + Some("src".into()) + ); + } + + #[test] + fn leftover_template_dropdown_matches_inventory() { + assert_eq!( + leftover_template_rows(&[], &[], true), + vec![LeftoverTemplateRow::EmptyTitle, LeftoverTemplateRow::EmptyHint] + ); + assert!(leftover_template_rows(&[], &[], false).is_empty()); + assert_eq!( + leftover_template_rows(&[("Dev".into(), "apps/web".into())], &["zsh".into()], true), + vec![ + LeftoverTemplateRow::ProjectHeader, + LeftoverTemplateRow::Project { + index: 0, + name: "Dev".into(), + cwd: "apps/web".into(), + }, + LeftoverTemplateRow::GlobalHeader, + LeftoverTemplateRow::Global { + index: 0, + name: "zsh".into(), + }, + ] + ); + } + + #[test] + fn project_group_menu_rows_match_inventory() { + assert_eq!( + project_group_menu_rows(&[], None, false), + vec![GroupMenuRow::Empty, GroupMenuRow::CreateInput] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], None, false), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: false, + }, + GroupMenuRow::Create, + ] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], Some("g1"), false), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: true, + }, + GroupMenuRow::Remove, + GroupMenuRow::Create, + ] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], Some("g1"), true), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: true, + }, + GroupMenuRow::Remove, + GroupMenuRow::CreateInput, + ] + ); + } + + #[test] + fn update_toasts_live_twelve_seconds() { + let update = Toast { + id: 1, + kind: ToastKind::Info, + title: "update".into(), + body: String::new(), + action: Some(ToastAction::OpenAbout), + created: Instant::now(), + }; + let plain = Toast { + id: 2, + kind: ToastKind::Success, + title: "ok".into(), + body: String::new(), + action: None, + created: Instant::now(), + }; + assert_eq!(update.ttl_secs(), 12); + assert_eq!(plain.ttl_secs(), 5); + assert!(update.alive()); + assert!(plain.alive()); + } + + #[test] + fn terminal_tab_icon_follows_title_keywords() { + assert_eq!(AgentKind::from_tab_title("claude --resume"), Some(AgentKind::Claude)); + assert_eq!(AgentKind::from_tab_title("Codex"), Some(AgentKind::Codex)); + assert_eq!(AgentKind::from_tab_title("gemini-cli"), Some(AgentKind::Gemini)); + assert_eq!(AgentKind::from_tab_title("kimi"), Some(AgentKind::Kimi)); + assert_eq!(AgentKind::from_tab_title("cline agent"), Some(AgentKind::Cline)); + assert_eq!(AgentKind::from_tab_title("openclaw"), Some(AgentKind::OpenClaw)); + assert_eq!(AgentKind::from_tab_title("opencode"), Some(AgentKind::OpenCode)); + assert_eq!(AgentKind::from_tab_title("qoder"), Some(AgentKind::Qoder)); + assert_eq!(AgentKind::from_tab_title("zsh"), None); + assert_eq!(AgentKind::tab_icon_kind("zsh", AgentKind::Cursor), AgentKind::Unknown); + assert_eq!(AgentKind::tab_icon_kind("claude", AgentKind::Codex), AgentKind::Claude); + } + + #[test] + fn terminal_selection_extracts_range_and_normalizes() { + assert_eq!(selected_from_lines("hello\nworld", None), ""); + assert_eq!(selected_from_lines("hello\nworld", Some(((0, 1), (0, 4)))), "ell"); + assert_eq!(selected_from_lines("hello\nworld", Some(((0, 3), (1, 3)))), "lo\nwor"); + assert_eq!(selected_from_lines("hello\nworld", Some(((1, 3), (0, 3)))), "lo\nwor"); + assert_eq!(selected_from_lines("hello", Some(((0, 2), (0, 2)))), ""); + let mut term = TermSession::new("1".into(), "t".into(), "p".into()); + term.begin_selection(0, 1, false); + term.extend_selection(0, 4); + assert!(term.cell_selected(0, 1)); + assert!(term.cell_selected(0, 3)); + assert!(!term.cell_selected(0, 4)); + assert!(!term.cell_selected(1, 0)); + } +} diff --git a/src-gpui/src/timefmt.rs b/src-gpui/src/timefmt.rs new file mode 100644 index 00000000..d026acf8 --- /dev/null +++ b/src-gpui/src/timefmt.rs @@ -0,0 +1,305 @@ +use crate::i18n::Locale; + +const EN_MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +pub fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub fn leftover_utc_year(now_secs: i64) -> i32 { + let mut year = 2026; + for _ in 0..8 { + let start = civil_to_unix(year, 1, 1); + let next = civil_to_unix(year + 1, 1, 1); + if now_secs >= start && now_secs < next { + return year; + } + if now_secs < start { + year -= 1; + } else { + year += 1; + } + } + year +} + +pub fn unix_now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +pub fn parse_iso8601_secs(raw: &str) -> Option { + let s = raw.trim(); + if !s.contains('T') { + return parse_ymd(s).map(|(y, m, d)| civil_to_unix(y, m, d)); + } + let (date, time_tz) = s.split_once('T')?; + let (year, month, day) = parse_ymd(date)?; + let (time, offset) = if let Some(time) = time_tz.strip_suffix('Z').or_else(|| time_tz.strip_suffix('z')) { + (time, 0i64) + } else if let Some(idx) = time_tz.rfind('+') { + (&time_tz[..idx], parse_tz_offset(&time_tz[idx..])?) + } else if let Some(rel) = time_tz.find('-').filter(|&i| i >= 8) { + (&time_tz[..rel], parse_tz_offset(&time_tz[rel..])?) + } else { + (time_tz, 0) + }; + let time = time.split('.').next()?; + let mut tp = time.split(':'); + let hour: i64 = tp.next()?.parse().ok()?; + let minute: i64 = tp.next()?.parse().ok()?; + let second: i64 = tp.next().unwrap_or("0").parse().ok()?; + Some(civil_to_unix(year, month, day) + hour * 3600 + minute * 60 + second - offset) +} + +fn parse_ymd(raw: &str) -> Option<(i32, i32, i32)> { + let date = raw.get(..10)?; + if date.as_bytes().get(4) != Some(&b'-') || date.as_bytes().get(7) != Some(&b'-') { + return None; + } + Some(( + date[0..4].parse().ok()?, + date[5..7].parse().ok()?, + date[8..10].parse().ok()?, + )) +} + +fn parse_tz_offset(raw: &str) -> Option { + let (sign, rest) = if let Some(rest) = raw.strip_prefix('+') { + (1i64, rest) + } else if let Some(rest) = raw.strip_prefix('-') { + (-1, rest) + } else { + return None; + }; + let compact = rest.replace(':', ""); + if compact.len() < 2 { + return None; + } + let hours: i64 = compact[..2].parse().ok()?; + let minutes: i64 = if compact.len() >= 4 { + compact[2..4].parse().ok()? + } else { + 0 + }; + Some(sign * (hours * 3600 + minutes * 60)) +} + +pub fn civil_to_unix(year: i32, month: i32, day: i32) -> i64 { + let (mut year, mut month) = (year, month); + if month <= 2 { + year -= 1; + month += 9; + } else { + month -= 3; + } + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = (year - era * 400) as u32; + let doy = (153 * month as u32 + 2) / 5 + day as u32 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era as i64 * 146097 + doe as i64 - 719468) * 86400 +} + +/// Leftover CommitList buckets: `just now`, `Nm ago`, `Nh ago`, `Nd ago`, `Nmo ago`, `Ny ago`. +pub fn format_relative_time(iso: &str, now_secs: i64) -> String { + let Some(then) = parse_iso8601_secs(iso) else { + return iso.to_string(); + }; + let diff_sec = now_secs - then; + if diff_sec < 60 { + return "just now".into(); + } + let diff_min = diff_sec / 60; + if diff_min < 60 { + return format!("{diff_min}m ago"); + } + let diff_hr = diff_min / 60; + if diff_hr < 24 { + return format!("{diff_hr}h ago"); + } + let diff_day = diff_hr / 24; + if diff_day < 30 { + return format!("{diff_day}d ago"); + } + let diff_month = diff_day / 30; + if diff_month < 12 { + return format!("{diff_month}mo ago"); + } + format!("{}y ago", diff_month / 12) +} + +/// Leftover `getRelativeTimeValue` — signed, future-positive, no week bucket. +fn js_round(value: f64) -> i64 { + (value + 0.5).floor() as i64 +} + +pub fn relative_time_value(then_ms: i64, now_ms: i64) -> (i64, &'static str) { + let diff_seconds = js_round((then_ms - now_ms) as f64 / 1000.0); + let abs = diff_seconds.unsigned_abs(); + if abs < 60 { + (diff_seconds, "second") + } else if abs < 60 * 60 { + (js_round(diff_seconds as f64 / 60.0), "minute") + } else if abs < 60 * 60 * 24 { + (js_round(diff_seconds as f64 / (60.0 * 60.0)), "hour") + } else if abs < 60 * 60 * 24 * 30 { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0)), "day") + } else if abs < 60 * 60 * 24 * 365 { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0 * 30.0)), "month") + } else { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0 * 365.0)), "year") + } +} + +fn format_medium_date(raw: &str, locale: Locale) -> Option { + let (year, month, day) = parse_ymd(raw)?; + if !(1..=12).contains(&month) { + return None; + } + Some(match locale { + Locale::En => format!("{} {}, {year}", EN_MONTHS[(month as usize) - 1], day), + Locale::Zh => format!("{year}年{month}月{day}日"), + }) +} + +fn format_long_relative(value: i64, unit: &str, locale: Locale) -> String { + if value == 0 { + return match locale { + Locale::En => "now".into(), + Locale::Zh => "现在".into(), + }; + } + match (locale, value, unit) { + (Locale::En, 1, "day") => "tomorrow".into(), + (Locale::En, -1, "day") => "yesterday".into(), + (Locale::En, 1, "month") => "next month".into(), + (Locale::En, -1, "month") => "last month".into(), + (Locale::En, 1, "year") => "next year".into(), + (Locale::En, -1, "year") => "last year".into(), + (Locale::Zh, 1, "day") => "明天".into(), + (Locale::Zh, -1, "day") => "昨天".into(), + (Locale::Zh, 1, "month") => "下个月".into(), + (Locale::Zh, -1, "month") => "上个月".into(), + (Locale::Zh, 1, "year") => "明年".into(), + (Locale::Zh, -1, "year") => "去年".into(), + (Locale::En, n, unit) => { + let abs = n.unsigned_abs(); + let word = if abs == 1 { unit.to_string() } else { format!("{unit}s") }; + if n > 0 { + format!("in {abs} {word}") + } else { + format!("{abs} {word} ago") + } + } + (Locale::Zh, n, unit) => { + let abs = n.unsigned_abs(); + let word = match unit { + "second" => "秒", + "minute" => "分钟", + "hour" => "小时", + "day" => "天", + "month" => "个月", + _ => "年", + }; + if n > 0 { + format!("{abs}{word}后") + } else { + format!("{abs}{word}前") + } + } + } +} + +/// Leftover About `formatReleaseDate`: medium date + long relative, e.g. `May 15, 2026 (in 2 months)`. +pub fn format_release_date_display(raw: &str, locale: Locale) -> String { + format_release_date_at(raw, locale, unix_now_ms()) +} + +pub fn format_release_date_at(raw: &str, locale: Locale, now_ms: i64) -> String { + if raw.trim().is_empty() { + return raw.to_string(); + } + let Some(then) = parse_iso8601_secs(raw) else { + return raw.to_string(); + }; + let absolute = format_medium_date(raw, locale).unwrap_or_else(|| raw.to_string()); + let (value, unit) = relative_time_value(then * 1000, now_ms); + format!("{absolute} ({})", format_long_relative(value, unit, locale)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_relative_time_matches_leftover_commit_list() { + let then = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 30), "just now"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 5 * 60), "5m ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 3 * 3600), "3h ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 2 * 86400), "2d ago"); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 30 * 86400), + "1mo ago" + ); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 400 * 86400), + "1y ago" + ); + } + + #[test] + fn parse_iso8601_respects_timezone_offset() { + let utc = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + let plus8 = parse_iso8601_secs("2026-04-09T20:00:00+08:00").unwrap(); + assert_eq!(utc, plus8); + assert_eq!(utc, civil_to_unix(2026, 4, 9) + 12 * 3600); + } + + #[test] + fn format_relative_time_falls_back_to_raw_date() { + assert_eq!(format_relative_time("not-a-date", 0), "not-a-date"); + } + + #[test] + fn leftover_release_date_uses_medium_and_long_relative() { + let now = parse_iso8601_secs("2026-05-15T00:00:00Z").unwrap() * 1000; + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::En, now + 30_000), + "May 15, 2026 (30 seconds ago)" + ); + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::En, now - 90_000), + "May 15, 2026 (in 2 minutes)" + ); + assert_eq!( + format_release_date_at("2026-07-15T00:00:00Z", Locale::En, now), + "Jul 15, 2026 (in 2 months)" + ); + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::Zh, now + 90_000), + "2026年5月15日 (1分钟前)" + ); + assert_eq!(format_release_date_at("not-a-date", Locale::En, now), "not-a-date"); + } + + #[test] + fn leftover_utc_year_matches_inventory() { + assert_eq!(leftover_utc_year(civil_to_unix(2026, 9, 3)), 2026); + assert_eq!(leftover_utc_year(civil_to_unix(2027, 1, 1)), 2027); + } + + #[test] + fn leftover_relative_time_value_buckets() { + let now = parse_iso8601_secs("2026-05-15T00:00:00Z").unwrap() * 1000; + assert_eq!(relative_time_value(now + 30_000, now), (30, "second")); + assert_eq!(relative_time_value(now - 90_000, now), (-1, "minute")); + } +} diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs new file mode 100644 index 00000000..f16dbdde --- /dev/null +++ b/src-gpui/src/ui/debug.rs @@ -0,0 +1,216 @@ +use gpui::{div, prelude::*, px, Context, ScrollWheelEvent, Window}; +use gpui_component::button::{Button, ButtonRounded, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName}; + +use crate::app::AppView; +use crate::backend; + +pub fn leftover_debug_count(filtered: usize, total: usize) -> String { + format!("{filtered} / {total}") +} + +pub fn leftover_debug_level_variant(level: &str) -> &'static str { + match level { + "ERROR" => "destructive", + "WARN" => "secondary", + "INFO" => "default", + _ => "secondary", + } +} + +fn leftover_level_colors(level: &str, theme: &gpui_component::Theme) -> (gpui::Hsla, gpui::Hsla) { + match leftover_debug_level_variant(level) { + "destructive" => (theme.danger, theme.danger_foreground), + "default" => (theme.primary, theme.primary_foreground), + _ => (theme.secondary, theme.secondary_foreground), + } +} + +pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.prefs.debug_mode { + return div().id("debug-fab-off").into_any_element(); + } + let view = cx.entity(); + div() + .id("debug-fab") + .absolute() + .right(px(64.)) + .bottom(px(16.)) + .child( + Button::new("debug-toggle") + .primary() + .rounded(ButtonRounded::Size(px(16.))) + .tooltip(app.t("debugLog")) + .child(crate::ui::leftover_wrench_glyph(cx.theme().primary_foreground)) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.open_debug_log(window, cx); + cx.notify(); + }); + }), + ) + .into_any_element() +} + +pub fn render_dialog_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let q = app.inputs.debug_search.read(cx).value().to_string(); + let total = app.data.overlay.debug_logs.len(); + let logs: Vec<_> = app + .data + .overlay + .debug_logs + .iter() + .filter(|l| backend::leftover_debug_log_matches(l, &q)) + .cloned() + .collect(); + let filtered = logs.len(); + v_flex() + .id("debug-log-body") + .w_full() + .min_h(px(280.)) + .max_h(px(520.)) + .gap_2() + .child( + h_flex() + .w_full() + .gap_2() + .px_1() + .child(div().flex_1().child(Input::new(&app.inputs.debug_search))) + .child( + Button::new("debug-clear") + .ghost() + .icon(IconName::Delete) + .tooltip(app.t("debugClear")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.debug_logs.clear(); + app.data.overlay.debug_auto_scroll = true; + app.data.overlay.debug_scroll_y = 0.0; + cx.notify(); + }); + } + }), + ), + ) + .child(if logs.is_empty() { + div() + .flex_1() + .w_full() + .py_8() + .flex() + .items_center() + .justify_center() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("debugNoLogs")), + ) + .into_any_element() + } else { + v_flex() + .id("debug-log-list") + .flex_1() + .w_full() + .min_h(px(160.)) + .overflow_y_scroll() + .on_scroll_wheel({ + let view = view.clone(); + let filtered = filtered; + move |ev: &ScrollWheelEvent, _, cx| { + view.update(cx, |app, cx| { + let delta = match ev.delta.pixel_delta(px(20.)) { + gpui::Point { y, .. } => f32::from(y), + }; + let content = (filtered as f32) * 20.0; + let viewport = 280.0; + let max_top = (content - viewport).max(0.0); + let scroll_top = (app.data.overlay.debug_scroll_y - delta).clamp(0.0, max_top); + app.data.overlay.debug_scroll_y = scroll_top; + app.data.overlay.debug_auto_scroll = + crate::state::leftover_debug_auto_scroll(content.max(viewport), scroll_top, viewport); + cx.notify(); + }); + } + }) + .children(logs.into_iter().map(|l| { + let (badge_bg, badge_fg) = leftover_level_colors(&l.level, &theme); + h_flex() + .id(crate::ui::eid(format!("dbg-{}-{}", l.timestamp, l.source))) + .w_full() + .items_start() + .gap_2() + .px_3() + .py(px(2.)) + .hover(|el| el.bg(theme.muted)) + .child( + div() + .flex_shrink_0() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(backend::format_debug_time(l.timestamp)), + ) + .child( + div() + .flex_shrink_0() + .h(px(16.)) + .px(px(6.)) + .rounded_md() + .bg(badge_bg) + .text_color(badge_fg) + .text_xs() + .font_family("monospace") + .child(l.level.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(l.source.clone()), + ) + .child( + div() + .flex_1() + .text_xs() + .font_family("monospace") + .child(l.message.clone()), + ) + })) + .into_any_element() + }) + .child( + h_flex().w_full().justify_end().px_1().py_1().child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(leftover_debug_count(filtered, total)), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::{leftover_debug_count, leftover_debug_level_variant}; + + #[test] + fn leftover_debug_footer_matches_inventory() { + assert_eq!(leftover_debug_count(3, 10), "3 / 10"); + assert_eq!(leftover_debug_count(0, 0), "0 / 0"); + } + + #[test] + fn leftover_debug_level_badges() { + assert_eq!(leftover_debug_level_variant("ERROR"), "destructive"); + assert_eq!(leftover_debug_level_variant("WARN"), "secondary"); + assert_eq!(leftover_debug_level_variant("INFO"), "default"); + assert_eq!(leftover_debug_level_variant("TRACE"), "secondary"); + } +} diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs new file mode 100644 index 00000000..13ceba73 --- /dev/null +++ b/src-gpui/src/ui/dialogs.rs @@ -0,0 +1,1769 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::spinner::Spinner; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; + +use crate::app::AppView; +use crate::backend; +use crate::state::{ + leftover_command_preview, leftover_dialog_width, leftover_file_menu_flags, leftover_file_menu_rows, + leftover_rename_disabled, leftover_template_display_name, leftover_template_draft_can_save, + leftover_template_draft_width, leftover_template_rows, project_group_menu_rows, + ContextMenu, DialogKind, GroupMenuRow, LeftoverFileMenuRow, LeftoverTemplateRow, +}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("overlays") + .absolute() + .inset_0() + .child(context_menu(app, cx)) + .child(dialog(app, window, cx)) + .child(leftover_project_template_draft(app, window, cx)) +} + +fn leftover_project_template_draft( + app: &mut AppView, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + if !app.data.overlay.template_dialog_open || app.data.overlay.dialog != Some(DialogKind::ProjectSettings) { + return div().id("no-ptpl-draft").into_any_element(); + } + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = app.data.overlay.editing_template.is_some(); + let can_save = leftover_template_draft_can_save(&app.inputs.template_name.read(cx).value()); + div() + .id("ptpl-draft-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_project_template_draft(); + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("ptpl-draft-panel") + .w(px(leftover_template_draft_width())) + .p_4() + .gap_4() + .rounded_xl() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))) + .child( + div().font_semibold().child(if editing { + app.t("editTerminalTemplate") + } else { + app.t("addTerminalTemplate") + }), + ), + ) + .child( + v_flex() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateName"))) + .child(Input::new(&app.inputs.template_name)), + ) + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateCwd"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCwdDescription")), + ) + .child(Input::new(&app.inputs.template_cwd)), + ) + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateCommands"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCommandsDescription")), + ) + .child(Input::new(&app.inputs.template_commands)), + ), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Button::new("ptpl-cancel") + .outline() + .small() + .label(app.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_project_template_draft(); + cx.notify(); + }); + } + }), + ) + .when(editing, |el| { + el.child( + Button::new("ptpl-delete") + .danger() + .small() + .label(app.t("delete")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.delete_editing_project_template(); + cx.notify(); + }); + } + }), + ) + }), + ) + .child( + Button::new("ptpl-save") + .primary() + .small() + .label(app.t("save")) + .disabled(!can_save) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.add_project_template(cx); + app.data.overlay.template_dialog_open = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .into_any_element() +} + +fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(kind) = app.data.overlay.dialog else { + return div().id("no-dialog").into_any_element(); + }; + let theme = cx.theme().clone(); + let view = cx.entity(); + let title = match kind { + DialogKind::CreateProject => app.t("createProject"), + DialogKind::DeleteProject => app.t("deleteProject"), + DialogKind::RenameProject => app.t("renameProject"), + DialogKind::ProjectSettings => app.t("projectSettings"), + DialogKind::CreateProfile => app.t("createProfile"), + DialogKind::DeleteProfile => app.t("deleteProfile"), + DialogKind::CloseUnsaved => app.t("closeUnsavedFileTitle"), + DialogKind::SwitchBranch => app.t("switchBranchTitle"), + DialogKind::OpenLink => app.t("terminalOpenLink"), + DialogKind::ChooseFile => app.t("terminalChooseFilePath"), + DialogKind::EditTemplate => { + if app.data.overlay.editing_template.is_some() { + app.t("editTerminalTemplate") + } else { + app.t("addTerminalTemplate") + } + } + DialogKind::ReviewQueue => app.t("reviewQueue"), + DialogKind::DebugLog => app.t("debugLog"), + DialogKind::CreateGroup => app.t("createProjectGroup"), + }; + + div() + .id("dialog-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + app.data.overlay.template_dialog_open = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("dialog-panel") + .w(px(leftover_dialog_width(kind))) + .max_h(px(if matches!(kind, DialogKind::DebugLog) { + 630. + } else { + 640. + })) + .p_4() + .gap_4() + .rounded_xl() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .items_center() + .when(kind == DialogKind::CreateProject, |el| { + el.child(crate::ui::leftover_folder_plus_mark(16., 9.)) + }) + .when(kind == DialogKind::CreateProfile, |el| { + el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + }) + .when(kind == DialogKind::RenameProject, |el| { + el.child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) + }) + .when(kind == DialogKind::ProjectSettings, |el| { + el.child(Icon::new(IconName::Settings).w(px(16.))) + }) + .when( + kind == DialogKind::DeleteProfile || kind == DialogKind::DeleteProject, + |el| el.child(Icon::new(IconName::Delete).w(px(16.))), + ) + .when(kind == DialogKind::ReviewQueue, |el| { + el.child(Icon::new(IconName::Inbox).w(px(16.))) + }) + .when(kind == DialogKind::SwitchBranch, |el| { + el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + }) + .when(kind == DialogKind::CloseUnsaved, |el| { + el.child(Icon::new(IconName::TriangleAlert).w(px(16.))) + }) + .when(kind == DialogKind::OpenLink, |el| { + el.child(Icon::new(IconName::ExternalLink).w(px(16.))) + }) + .when(kind == DialogKind::ChooseFile, |el| { + el.child(Icon::new(IconName::File).w(px(16.))) + }) + .when(kind == DialogKind::EditTemplate, |el| { + el.child(Icon::new(IconName::SquareTerminal).w(px(16.))) + }) + .child(div().font_semibold().child(title)), + ) + .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + app.data.overlay.template_dialog_open = false; + cx.notify(); + }); + } + })), + ) + .child(dialog_body(app, kind, window, cx)) + .child(dialog_footer(app, kind, cx)), + ) + .into_any_element() +} + +fn dialog_body( + app: &mut AppView, + kind: DialogKind, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + match kind { + DialogKind::CreateProject => { + let folder = app.data.overlay.dialog_folder.clone(); + let hint = if folder.is_none() { + app.t("createProjectChooseFolderHint") + } else if app.inputs.project_name.read(cx).value().is_empty() { + app.t("createProjectHintFolderEmpty") + } else { + app.t("createProjectHintFolderNamed") + }; + v_flex() + .gap(px(20.)) + .child(if let Some(folder) = folder.clone() { + v_flex() + .gap(px(6.)) + .child( + h_flex() + .justify_between() + .gap_3() + .child( + div() + .text_xs() + .font_medium() + .text_color(theme.muted_foreground) + .child(app.t("folder")), + ) + .child( + Button::new("rechoose") + .outline() + .xsmall() + .label(format!("✎ {}", app.t("chooseFolder"))) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.apply_picked_folder(p, window, cx); + } + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.muted) + .font_family("monospace") + .text_sm() + .whitespace_nowrap() + .overflow_hidden() + .child(folder), + ) + .into_any_element() + } else { + div() + .id("choose-folder-big") + .w_full() + .px_4() + .py(px(24.)) + .rounded_lg() + .border_1() + .border_dashed() + .border_color(theme.border) + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.apply_picked_folder(p, window, cx); + } + cx.notify(); + }); + } + }) + .child( + Icon::new(IconName::Folder) + .w(px(24.)) + .text_color(theme.muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("chooseFolder")), + ) + .into_any_element() + }) + .child(div().text_sm().child(app.t("projectName"))) + .child(Input::new(&app.inputs.project_name)) + .child(div().text_xs().text_color(theme.muted_foreground).child(hint)) + .into_any_element() + } + DialogKind::DeleteProject => div().text_sm().child(app.t("confirmDeleteProject")).into_any_element(), + DialogKind::RenameProject => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("newName"))) + .child(Input::new(&app.inputs.rename)) + .into_any_element(), + DialogKind::ProjectSettings => v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .child( + Button::new("ps-scripts") + .small() + .selected(app.data.overlay.project_settings_tab == 0) + .label(format!(" {}", app.t("scripts"))) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.project_settings_tab = 0; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("ps-templates") + .small() + .selected(app.data.overlay.project_settings_tab == 1) + .icon(IconName::SquareTerminal) + .label(app.t("templates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.project_settings_tab = 1; + cx.notify(); + }); + } + }), + ), + ) + .child(if app.data.overlay.project_settings_tab == 0 { + v_flex() + .gap_2() + .child(div().text_sm().child(app.t("projectWorktreeDir"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("projectWorktreeDirDesc")), + ) + .child(Input::new(&app.inputs.worktree)) + .child(div().text_sm().child(app.t("initScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("initScriptDesc")), + ) + .child(Input::new(&app.inputs.init_script)) + .child(div().text_sm().child(app.t("setupScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("setupScriptDesc")), + ) + .child(Input::new(&app.inputs.setup_script)) + .child(div().text_sm().child(app.t("teardownScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("teardownScriptDesc")), + ) + .child(Input::new(&app.inputs.teardown_script)) + .into_any_element() + } else { + let templates = app + .data + .workspaces + .values() + .find(|w| { + Some(w.project_id.as_str()) == app.data.overlay.dialog_project.as_deref() + || Some(w.project_id.as_str()) == app.data.current_project.as_deref() + }) + .map(|w| w.config.terminal_templates.clone()) + .unwrap_or_default(); + let fallback = app.t("terminalTemplate"); + v_flex() + .gap_4() + .child( + h_flex() + .items_start() + .justify_between() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_semibold().text_sm().child(app.t("projectTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("projectTerminalTemplatesDescription")), + ), + ) + .child( + Button::new("add-project-tpl") + .outline() + .small() + .label(app.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.open_project_template_create(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .child(if templates.is_empty() { + v_flex() + .min_h(px(96.)) + .items_center() + .justify_center() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("noTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("projectTerminalTemplatesDescription")), + ) + .into_any_element() + } else { + v_flex() + .gap_2() + .children(templates.into_iter().map(|t| { + let id = t.id.clone(); + let preview = leftover_command_preview(&t.commands.join("\n")); + let name = leftover_template_display_name(&t.name, &fallback); + h_flex() + .id(crate::ui::eid(format!("ptpl-{id}"))) + .items_center() + .justify_between() + .gap_4() + .rounded_lg() + .border_1() + .border_color(theme.border) + .px_4() + .py_3() + .child( + v_flex() + .min_w_0() + .gap_1() + .child(div().font_medium().truncate().child(name)) + .when(!preview.is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) + .child(preview), + ) + }) + .when(!t.cwd.trim().is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(t.cwd.clone()), + ) + }), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new(crate::ui::eid(format!("ptpl-edit-{id}"))) + .ghost() + .xsmall() + .tooltip(app.t("editTerminalTemplate")) + .child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.load_project_template(&id, window, cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("ptpl-del-{id}"))) + .ghost() + .xsmall() + .tooltip(app.t("deleteTerminalTemplate")) + .child(Icon::new(IconName::Delete).w(px(14.)).text_color(theme.danger)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.remove_project_template(&id); + cx.notify(); + }); + } + }), + ), + ) + })) + .into_any_element() + }) + .into_any_element() + }) + .into_any_element(), + DialogKind::CreateProfile => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("branchName"))) + .child(Input::new(&app.inputs.profile_branch)) + .into_any_element(), + DialogKind::DeleteProfile => v_flex() + .gap_3() + .child(div().text_sm().child(app.t("confirmDeleteProfile"))) + .when(app.data.overlay.dialog_busy, |el| { + el.child( + h_flex() + .gap_2() + .items_center() + .text_color(theme.muted_foreground) + .child(Spinner::new().with_size(gpui_component::Size::Small)) + .child(div().text_sm().child(app.t("deleteProfileCheckingGitStatus"))), + ) + }) + .when_some(app.data.overlay.delete_warning.clone(), |el, warn| { + el.child(leftover_alert(app.t("deleteProfileGitWarningTitle"), warn, &theme)) + }) + .when( + app.data.overlay.delete_check_failed && !app.data.overlay.dialog_busy, + |el| { + el.child(leftover_alert( + app.t("deleteProfileGitCheckFailedTitle"), + app.t("deleteProfileGitCheckFailedDescription"), + &theme, + )) + }, + ) + .into_any_element(), + DialogKind::CloseUnsaved => div() + .text_sm() + .child(crate::i18n::tf( + app.data.locale, + "closeUnsavedFileDescription", + &[("file", app.data.overlay.dialog_file.as_deref().unwrap_or(""))], + )) + .into_any_element(), + DialogKind::SwitchBranch => { + let q = app.inputs.branch_search.read(cx).value().to_string(); + let branches: Vec<_> = app + .data + .overlay + .branches + .iter() + .filter(|b| q.is_empty() || b.name.to_ascii_lowercase().contains(&q.to_ascii_lowercase())) + .cloned() + .collect(); + v_flex() + .gap_2() + .child(Input::new(&app.inputs.branch_search)) + .child(if branches.is_empty() { + div() + .py_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noBranchesFound")) + .into_any_element() + } else { + v_flex() + .max_h(px(384.)) + .gap_0() + .children( + branches + .into_iter() + .map(|b| leftover_branch_row(app, &b, theme.muted, theme.muted_foreground, &view)), + ) + .into_any_element() + }) + .into_any_element() + } + DialogKind::OpenLink => v_flex() + .gap_1() + .child(div().text_sm().child(app.t("terminalOpenLinkConfirmDescription"))) + .child( + div() + .mt_3() + .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("terminalOpenLinkUrlLabel")), + ) + .child( + div() + .font_family("monospace") + .text_sm() + .child(app.data.overlay.dialog_url.clone().unwrap_or_default()), + ) + .into_any_element(), + DialogKind::ChooseFile => v_flex() + .gap_2() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("terminalChooseFilePathDescription")), + ) + .child( + div() + .id("choose-file-list") + .max_h(px(450.)) + .overflow_y_scroll() + .rounded_md() + .border_1() + .border_color(theme.border) + .children(app.data.overlay.fuzzy_files.iter().map(|f| { + let path = f.path.clone(); + h_flex() + .id(crate::ui::eid(format!("fuzzy-{path}"))) + .min_h(px(44.)) + .px_3() + .py_2() + .gap_3() + .border_b_1() + .border_color(theme.border) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let path = path.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(pid) = app.data.current_profile.clone() { + app.open_file(&pid, &path, window, cx); + } + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }) + .child(crate::ui::file_icons::file_glyph(&f.name, false, false, 16.)) + .child( + v_flex() + .min_w_0() + .flex_1() + .child(div().text_sm().font_medium().child(f.name.clone())) + .child( + div() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(f.relative_path.clone()), + ), + ) + .child(Icon::new(IconName::File).w(px(14.))) + })), + ) + .into_any_element(), + DialogKind::EditTemplate => v_flex() + .gap_2() + .child(Input::new(&app.inputs.template_name)) + .child(Input::new(&app.inputs.template_shell)) + .child(Input::new(&app.inputs.template_cwd)) + .child(Input::new(&app.inputs.template_commands)) + .into_any_element(), + DialogKind::ReviewQueue => review_queue_body(app, theme.muted_foreground, cx).into_any_element(), + DialogKind::DebugLog => crate::ui::debug::render_dialog_body(app, cx).into_any_element(), + DialogKind::CreateGroup => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("projectGroupName"))) + .child(Input::new(&app.inputs.group_name)) + .into_any_element(), + } +} + +fn review_queue_body(app: &AppView, muted: gpui::Hsla, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = app.data.overlay.review_edit_id.clone(); + v_flex() + .id("review-queue") + .gap_3() + .max_h(px(480.)) + .children(app.data.overlay.review_comments.iter().cloned().map(|comment| { + let id = comment.id.clone(); + let editing_this = editing.as_deref() == Some(id.as_str()); + v_flex() + .id(crate::ui::eid(format!("review-card-{id}"))) + .gap_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .child( + h_flex() + .px_3() + .py_2() + .gap_2() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .font_family("monospace") + .font_semibold() + .text_sm() + .child(comment.display_name.clone()), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .px_1() + .text_xs() + .font_family("monospace") + .rounded_md() + .bg(gpui::hsla(0.58, 0.4, 0.5, 0.18)) + .child(crate::review::format_review_range(comment.range)), + ) + .child(div().text_xs().text_color(muted).child("Selected diff")), + ), + ) + .child( + Button::new(crate::ui::eid(format!("review-del-{id}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("Delete review comment".to_string()) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.delete_review_comment(&id); + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .mx_3() + .max_h(px(128.)) + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(theme.border) + .p_2() + .font_family("monospace") + .text_xs() + .child(if comment.selected_text.is_empty() { + "(no selected text available)".to_string() + } else { + comment.selected_text.clone() + }), + ) + .child( + div() + .px_3() + .text_xs() + .font_semibold() + .text_color(muted) + .child("Comment"), + ) + .child(if editing_this { + div() + .px_3() + .pb_3() + .child(Input::new(&app.inputs.review_comment)) + .into_any_element() + } else { + div() + .id(crate::ui::eid(format!("review-body-{id}"))) + .px_3() + .pb_3() + .text_sm() + .cursor(gpui::CursorStyle::PointingHand) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.begin_review_edit(&id, window, cx); + cx.notify(); + }); + } + }) + .child(if comment.body.is_empty() { + " ".to_string() + } else { + comment.body + }) + .into_any_element() + }) + })) + .when(app.data.overlay.review_comments.is_empty(), |el| { + el.child(div().text_color(muted).child("—")) + }) +} + +fn leftover_alert(title: String, body: String, theme: &gpui_component::Theme) -> impl IntoElement { + v_flex() + .w_full() + .gap(px(2.)) + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .px(px(10.)) + .py_2() + .child(div().font_medium().text_sm().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(body)) +} + +fn leftover_chip(text: &str, fg: gpui::Hsla, bg: gpui::Hsla, border: gpui::Hsla) -> impl IntoElement { + div() + .h(px(16.)) + .px(px(6.)) + .rounded_sm() + .border_1() + .border_color(border) + .bg(bg) + .text_color(fg) + .text_xs() + .child(text.to_string()) +} + +fn leftover_branch_row( + app: &AppView, + branch: &model::project::GitBranchInfo, + muted: gpui::Hsla, + muted_fg: gpui::Hsla, + view: &gpui::Entity, +) -> impl IntoElement { + let name = branch.name.clone(); + let disabled = branch.is_current || branch.is_used; + let amber = gpui::hsla(38. / 360., 0.92, 0.50, 1.); + h_flex() + .id(crate::ui::eid(format!("br-{name}"))) + .w_full() + .min_w_0() + .px_3() + .py_2() + .gap_2() + .rounded_md() + .when(branch.is_current, |el| el.bg(muted)) + .when(disabled && !branch.is_current, |el| el.opacity(0.6)) + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + let name = name.clone(); + move |_, _, cx| { + if disabled { + return; + } + view.update(cx, |app, cx| { + app.checkout_branch(&name); + cx.notify(); + }); + } + }) + .child(crate::ui::leftover_branch_glyph(muted_fg)) + .child( + div() + .min_w_0() + .flex_1() + .text_sm() + .overflow_hidden() + .when(branch.is_current, |el| el.font_medium()) + .child(name), + ) + .when(branch.is_current, |el| { + el.child(leftover_chip(&app.t("branchCurrentLabel"), muted_fg, muted, muted)) + }) + .when(branch.is_trunk, |el| { + el.child(leftover_chip( + &app.t("branchTrunkLabel"), + muted_fg, + gpui::hsla(0., 0., 0., 0.), + muted_fg.opacity(0.3), + )) + }) + .when(branch.is_used, |el| { + el.child(leftover_chip( + &app.t("branchUsedLabel"), + amber, + amber.opacity(0.1), + amber.opacity(0.3), + )) + }) + .when(!branch.is_current && (branch.ahead > 0 || branch.behind > 0), |el| { + el.child( + h_flex() + .flex_none() + .gap_1() + .font_family("monospace") + .text_xs() + .when(branch.ahead > 0, |row| { + row.child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("↑{}", branch.ahead)), + ) + }) + .when(branch.behind > 0, |row| { + row.child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("↓{}", branch.behind)), + ) + }), + ) + }) +} + +fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> impl IntoElement { + if kind == DialogKind::DebugLog { + return div().id("no-debug-footer").into_any_element(); + } + let view = cx.entity(); + let theme = cx.theme().clone(); + let danger = matches!( + kind, + DialogKind::DeleteProject | DialogKind::DeleteProfile | DialogKind::CloseUnsaved + ); + let ok = match kind { + DialogKind::CreateProject => app.t("create"), + DialogKind::DeleteProject => app.t("delete"), + DialogKind::RenameProject => app.t("rename"), + DialogKind::ProjectSettings => app.t("save"), + DialogKind::CreateProfile => app.t("create"), + DialogKind::DeleteProfile => { + if app.data.overlay.delete_warning.is_some() { + app.t("deleteProfileAnyway") + } else { + app.t("delete") + } + } + DialogKind::CloseUnsaved => app.t("discardChanges"), + DialogKind::OpenLink => app.t("browserOpenDefault"), + DialogKind::CreateGroup => app.t("create"), + DialogKind::EditTemplate => app.t("save"), + _ => app.t("cancel"), + }; + let show_ok = !matches!( + kind, + DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile | DialogKind::ReviewQueue + ); + let review_empty = app.data.overlay.review_comments.is_empty(); + let rename_init = app + .data + .overlay + .dialog_project + .as_ref() + .and_then(|id| app.data.projects.iter().find(|p| p.id == *id)) + .map(|p| p.name.clone()) + .unwrap_or_default(); + let rename_disabled = kind == DialogKind::RenameProject + && leftover_rename_disabled(&app.inputs.rename.read(cx).value(), &rename_init); + + h_flex() + .justify_end() + .gap_2() + .when( + kind != DialogKind::ReviewQueue && kind != DialogKind::SwitchBranch && kind != DialogKind::DebugLog, + |el| { + el.child( + Button::new("dlg-cancel") + .outline() + .small() + .label(app.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + app.data.overlay.open_link_menu = false; + app.data.overlay.template_dialog_open = false; + cx.notify(); + }); + } + }), + ) + }, + ) + .when(kind == DialogKind::ReviewQueue, |el| { + el.child( + Button::new("dlg-copy") + .outline() + .small() + .icon(IconName::Copy) + .label("Copy".to_string()) + .disabled(review_empty) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.copy_review_comments(false, window, cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("dlg-copy-clear") + .small() + .danger() + .icon(IconName::Copy) + .label("Copy and clear all".to_string()) + .disabled(review_empty) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.copy_review_comments(true, window, cx); + cx.notify(); + }); + } + }), + ) + }) + .when(app.data.overlay.dialog_busy && show_ok, |el| { + el.child(Spinner::new().with_size(gpui_component::Size::Small)) + }) + .when(show_ok, |el| { + el.child( + Button::new("dlg-ok") + .small() + .when(danger, |b| b.danger()) + .when(!danger, |b| b.primary()) + .label(ok) + .disabled( + rename_disabled + || (matches!(kind, DialogKind::CreateProject) + && (app.data.overlay.dialog_folder.is_none() || app.data.overlay.dialog_busy)) + || (app.data.overlay.dialog_busy + && matches!( + kind, + DialogKind::DeleteProject + | DialogKind::DeleteProfile | DialogKind::ProjectSettings + | DialogKind::CreateProfile + )), + ) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + match kind { + DialogKind::CreateProject => app.create_project_from_dialog(window, cx), + DialogKind::DeleteProject => app.delete_current_dialog_project(), + DialogKind::RenameProject => app.rename_dialog_project(cx), + DialogKind::ProjectSettings => app.save_project_settings(cx), + DialogKind::CreateProfile => app.create_profile_from_dialog(cx), + DialogKind::DeleteProfile => app.delete_dialog_profile(), + DialogKind::EditTemplate => app.save_editing_template(cx), + DialogKind::CloseUnsaved => { + if let (Some(pid), Some(path)) = ( + app.data.current_profile.clone(), + app.data.overlay.pending_close_file.clone(), + ) { + app.close_file(&pid, &path); + } + app.data.overlay.dialog = None; + } + DialogKind::OpenLink => { + app.open_url_with(""); + } + DialogKind::CreateGroup => { + app.submit_create_group(None, cx); + } + _ => app.data.overlay.dialog = None, + } + cx.notify(); + }); + } + }), + ) + .when(kind == DialogKind::OpenLink, |el| { + el.child( + Button::new("dlg-open-with") + .primary() + .small() + .icon(IconName::ChevronDown) + .tooltip(app.t("browserOpenWith")) + .disabled(crate::platform::installed_browsers().is_empty()) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.open_link_menu = !app.data.overlay.open_link_menu; + cx.notify(); + }); + } + }), + ) + .when(app.data.overlay.open_link_menu, |el| { + el.child( + v_flex() + .id("open-link-browsers") + .min_w(px(208.)) + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .children(crate::platform::installed_browsers().into_iter().map(|browser| { + let cmd = browser.command; + div() + .id(crate::ui::eid(format!("open-with-{cmd}"))) + .px_2() + .py_1() + .rounded_md() + .hover(|row| row.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_url_with(cmd); + app.data.overlay.open_link_menu = false; + cx.notify(); + }); + } + }) + .child(div().text_sm().child(if browser.name.is_empty() { + crate::state::leftover_browser_name(browser.id) + } else { + browser.name + })) + })), + ) + }) + }) + }) + .into_any_element() +} + +fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { + let Some((menu, x, y)) = app.data.overlay.context_menu.clone() else { + return div().id("no-ctx").into_any_element(); + }; + let theme = cx.theme().clone(); + let view = cx.entity(); + let items = menu_items(app, &menu); + + div() + .id("ctx-mask") + .absolute() + .inset_0() + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = None; + app.data.overlay.group_menu_creating = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("ctx-menu") + .absolute() + .left(px(x)) + .top(px(y)) + .min_w(px(224.)) + .py_1() + .rounded_lg() + .bg(theme.background) + .border_1() + .border_color(theme.border) + .shadow_md() + .on_click(|_, _, _| {}) + .children(items.into_iter().map(|item| { + if item.header && item.label.is_empty() { + return div() + .id(crate::ui::eid(item.id.clone())) + .h(px(1.)) + .mx_1() + .my_1() + .bg(theme.border) + .into_any_element(); + } + if item.input { + return div() + .id(crate::ui::eid(item.id.clone())) + .px_2() + .py_1() + .child(Input::new(&app.inputs.group_name)) + .into_any_element(); + } + let muted = item.muted || item.header; + let disabled = item.disabled; + let keep_open = item.keep_open || item.header || item.muted || item.disabled; + div() + .id(crate::ui::eid(item.id.clone())) + .px_3() + .py_1() + .text_sm() + .when(item.danger, |el| el.text_color(theme.danger)) + .when(muted, |el| el.text_color(theme.muted_foreground)) + .when(item.disabled, |el| el.opacity(0.55)) + .when(!muted && !disabled, |el| el.hover(|el| el.bg(theme.muted))) + .on_click({ + let view = view.clone(); + let menu = menu.clone(); + let action = item.action.clone(); + move |_, window, cx| { + if disabled { + return; + } + view.update(cx, |app, cx| { + run_menu(app, &menu, action.clone(), window, cx); + if !keep_open { + app.data.overlay.context_menu = None; + app.data.overlay.group_menu_creating = false; + } + cx.notify(); + }); + } + }) + .child(if item.checked || matches!(item.action, MenuAction::AssignGroup(_)) { + h_flex() + .gap_2() + .child(div().w(px(12.)).child(if item.checked { "✓" } else { "" })) + .child(item.label) + .into_any_element() + } else { + div().child(item.label).into_any_element() + }) + .into_any_element() + })), + ) + .into_any_element() +} + +#[derive(Clone)] +enum MenuAction { + AssignGroup(String), + StartCreateGroup, + SubmitCreateGroup, + RemoveGroup, + ProjectSettings, + Rename, + DeleteProject, + DeleteProfile, + Open, + OpenDefault, + Reveal, + Refresh, + NewFile, + NewFolder, + RenamePath, + CopyRel, + CopyAbs, + DeletePath, + Template(usize), + ProjectTemplate(usize), + NewTerm, + Header, + DiscardGitFile, +} + +struct MenuItem { + id: String, + label: String, + danger: bool, + disabled: bool, + checked: bool, + muted: bool, + header: bool, + keep_open: bool, + input: bool, + action: MenuAction, +} + +fn item(id: impl Into, label: impl Into, action: MenuAction) -> MenuItem { + MenuItem { + id: id.into(), + label: label.into(), + danger: false, + disabled: false, + checked: false, + muted: false, + header: false, + keep_open: false, + input: false, + action, + } +} + +fn danger(mut item: MenuItem) -> MenuItem { + item.danger = true; + item +} + +fn header(id: impl Into, label: impl Into) -> MenuItem { + let mut item = item(id, label, MenuAction::Header); + item.header = true; + item.muted = true; + item +} + +fn leftover_sep(id: impl Into) -> MenuItem { + header(id, "") +} + +fn leftover_disabled(mut item: MenuItem, disabled: bool) -> MenuItem { + item.disabled = disabled; + item +} + +fn leftover_file_context_items(app: &AppView, path: &str) -> Vec { + let node = app.data.current_ws().and_then(|ws| ws.tree.get(path)); + let flags = leftover_file_menu_flags(node.is_some_and(|n| !n.is_dir), node.is_some()); + leftover_file_menu_rows() + .iter() + .enumerate() + .map(|(ix, row)| match row { + LeftoverFileMenuRow::Open => leftover_disabled( + item("open", app.t("fileTreeContextMenuOpen"), MenuAction::Open), + !flags.can_open, + ), + LeftoverFileMenuRow::OpenDefault => leftover_disabled( + item( + "open-default", + app.t("fileTreeContextMenuOpenInDefaultApp"), + MenuAction::OpenDefault, + ), + !flags.can_open_default, + ), + LeftoverFileMenuRow::Reveal => leftover_disabled( + item( + "reveal", + app.t("fileTreeContextMenuRevealInFileManager"), + MenuAction::Reveal, + ), + !flags.can_reveal, + ), + LeftoverFileMenuRow::Refresh => item("refresh", app.t("fileTreeContextMenuRefresh"), MenuAction::Refresh), + LeftoverFileMenuRow::NewFile => item("new-file", app.t("fileTreeContextMenuNewFile"), MenuAction::NewFile), + LeftoverFileMenuRow::NewFolder => item( + "new-folder", + app.t("fileTreeContextMenuNewFolder"), + MenuAction::NewFolder, + ), + LeftoverFileMenuRow::Rename => leftover_disabled( + item("rename-path", app.t("rename"), MenuAction::RenamePath), + !flags.can_rename, + ), + LeftoverFileMenuRow::CopyRel => item( + "copy-rel", + app.t("fileTreeContextMenuCopyRelativePath"), + MenuAction::CopyRel, + ), + LeftoverFileMenuRow::CopyAbs => item( + "copy-abs", + app.t("fileTreeContextMenuCopyAbsolutePath"), + MenuAction::CopyAbs, + ), + LeftoverFileMenuRow::Delete => leftover_disabled( + danger(item("del-path", app.t("delete"), MenuAction::DeletePath)), + !flags.can_delete, + ), + LeftoverFileMenuRow::Separator => leftover_sep(format!("file-sep-{ix}")), + }) + .collect() +} + +fn project_menu_items(app: &AppView, project_id: &str) -> Vec { + let current = app + .data + .projects + .iter() + .find(|p| p.id == project_id) + .and_then(|p| p.group_id.clone()); + let groups: Vec<(String, String)> = app.data.groups.iter().map(|g| (g.id.clone(), g.name.clone())).collect(); + let rows = project_group_menu_rows(&groups, current.as_deref(), app.data.overlay.group_menu_creating); + let mut items = vec![header("add-group-h", app.t("addToProjectGroup"))]; + for row in rows { + match row { + GroupMenuRow::Empty => { + let mut empty = item("no-groups", app.t("noProjectGroups"), MenuAction::Header); + empty.muted = true; + items.push(empty); + } + GroupMenuRow::Group { id, name, current } => { + let mut row = item(format!("grp-{id}"), name, MenuAction::AssignGroup(id)); + row.checked = current; + row.disabled = current; + items.push(row); + } + GroupMenuRow::Remove => items.push(item( + "remove-group", + app.t("removeFromProjectGroup"), + MenuAction::RemoveGroup, + )), + GroupMenuRow::Create => { + let mut create = item( + "create-group", + app.t("createProjectGroup"), + MenuAction::StartCreateGroup, + ); + create.keep_open = true; + items.push(create); + } + GroupMenuRow::CreateInput => { + items.push(MenuItem { + id: "create-group-input".into(), + label: String::new(), + danger: false, + disabled: false, + checked: false, + muted: false, + header: false, + keep_open: true, + input: true, + action: MenuAction::SubmitCreateGroup, + }); + } + } + } + items.push(item( + "proj-settings", + app.t("projectSettings"), + MenuAction::ProjectSettings, + )); + items.push(item("rename-proj", app.t("renameProject"), MenuAction::Rename)); + items.push(danger(item( + "del-proj", + app.t("deleteProject"), + MenuAction::DeleteProject, + ))); + items +} + +fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { + match menu { + ContextMenu::Project { id } => project_menu_items(app, id), + ContextMenu::Profile { .. } => vec![danger(item( + "del-prof", + app.t("deleteProfile"), + MenuAction::DeleteProfile, + ))], + ContextMenu::GitFile { .. } => vec![danger(item( + "discard-git-file", + app.t("gitDiscardFileAction"), + MenuAction::DiscardGitFile, + ))], + ContextMenu::File { path } => leftover_file_context_items(app, path), + ContextMenu::TreeBlank => vec![ + item( + "blank-new-file", + app.t("fileTreeContextMenuNewFile"), + MenuAction::NewFile, + ), + item( + "blank-new-folder", + app.t("fileTreeContextMenuNewFolder"), + MenuAction::NewFolder, + ), + item( + "blank-refresh", + app.t("fileTreeContextMenuRefresh"), + MenuAction::Refresh, + ), + item( + "blank-reveal", + app.t("fileTreeContextMenuRevealInFileManager"), + MenuAction::Reveal, + ), + item( + "blank-copy-rel", + app.t("fileTreeContextMenuCopyRelativePath"), + MenuAction::CopyRel, + ), + item( + "blank-copy-abs", + app.t("fileTreeContextMenuCopyAbsolutePath"), + MenuAction::CopyAbs, + ), + ], + ContextMenu::NewTerminal => leftover_template_rows( + &app.data + .current_ws() + .map(|w| { + w.config + .terminal_templates + .iter() + .map(|t| (t.name.clone(), t.cwd.clone())) + .collect::>() + }) + .unwrap_or_default(), + &app.data + .prefs + .templates + .iter() + .map(|t| t.name.clone()) + .collect::>(), + true, + ) + .into_iter() + .map(|row| match row { + LeftoverTemplateRow::EmptyTitle => header("no-tpl", app.t("noTerminalTemplates")), + LeftoverTemplateRow::EmptyHint => { + let mut hint = item("no-tpl-hint", app.t("noTemplatesDropdownHint"), MenuAction::Header); + hint.muted = true; + hint + } + LeftoverTemplateRow::ProjectHeader => header("proj-templates", app.t("projectTerminalTemplates")), + LeftoverTemplateRow::Project { index, name, cwd } => item( + format!("pt-{index}"), + if cwd.is_empty() { + name + } else { + format!("{name} — {cwd}") + }, + MenuAction::ProjectTemplate(index), + ), + LeftoverTemplateRow::GlobalHeader => header("global-templates", app.t("globalTerminalTemplates")), + LeftoverTemplateRow::Global { index, name } => { + item(format!("gt-{index}"), name, MenuAction::Template(index)) + } + }) + .collect(), + } +} + +fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: &mut Window, cx: &mut Context) { + match (menu, action) { + (ContextMenu::Project { id }, MenuAction::DeleteProject) => { + app.data.overlay.dialog = Some(DialogKind::DeleteProject); + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::Rename) => { + app.data.overlay.dialog = Some(DialogKind::RenameProject); + app.data.overlay.dialog_project = Some(id.clone()); + if let Some(p) = app.data.project(id) { + app.inputs.rename.update(cx, |s, cx| { + s.set_value(p.name.clone(), window, cx); + }); + } + } + (ContextMenu::Project { id }, MenuAction::ProjectSettings) => { + app.data.overlay.dialog = Some(DialogKind::ProjectSettings); + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::StartCreateGroup) => { + app.data.overlay.group_menu_creating = true; + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::SubmitCreateGroup) => { + app.submit_create_group(Some(id), cx); + } + (ContextMenu::Project { id }, MenuAction::AssignGroup(group_id)) => { + app.assign_project_to_group(id, Some(group_id)); + } + (ContextMenu::Project { id }, MenuAction::RemoveGroup) => { + app.assign_project_to_group(id, None); + } + (ContextMenu::Profile { id, .. }, MenuAction::DeleteProfile) => { + app.prepare_delete_profile(id); + } + (ContextMenu::GitFile { path }, MenuAction::DiscardGitFile) => { + app.discard_file(path); + } + (ContextMenu::File { path }, MenuAction::Open) => { + if let Some(pid) = app.data.current_profile.clone() { + if crate::app::git_status_kind( + &app.data + .workspaces + .get(&pid) + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path).map(|(_, s)| s.clone())) + .unwrap_or_default(), + ) != crate::app::GitStatusKind::Deleted + { + app.open_file(&pid, path, window, cx); + } + } + } + (ContextMenu::File { path }, MenuAction::OpenDefault) => app.open_external(path), + (ContextMenu::File { path }, MenuAction::Reveal) => app.reveal(Some(path)), + (_, MenuAction::Reveal) => app.reveal(None), + (_, MenuAction::Refresh) => { + if let Some(pid) = app.data.current_profile.clone() { + app.load_tree_root(&pid); + } + } + (ContextMenu::File { path }, MenuAction::RenamePath) => { + app.start_rename_path(path, window, cx); + } + (ContextMenu::File { path }, MenuAction::NewFile) => { + let parent = app + .data + .current_ws() + .and_then(|w| crate::app::create_target_directory(&w.tree, Some(path))); + app.create_path(false, parent.as_deref(), window, cx) + } + (ContextMenu::File { path }, MenuAction::NewFolder) => { + let parent = app + .data + .current_ws() + .and_then(|w| crate::app::create_target_directory(&w.tree, Some(path))); + app.create_path(true, parent.as_deref(), window, cx) + } + (_, MenuAction::NewFile) => app.create_path(false, None, window, cx), + (_, MenuAction::NewFolder) => app.create_path(true, None, window, cx), + (ContextMenu::File { path }, MenuAction::CopyRel) => app.copy_path(path, false, cx), + (ContextMenu::File { path }, MenuAction::CopyAbs) => app.copy_path(path, true, cx), + (ContextMenu::TreeBlank, MenuAction::CopyRel) => app.copy_path(".", false, cx), + (ContextMenu::TreeBlank, MenuAction::CopyAbs) => app.copy_path(".", true, cx), + (ContextMenu::File { path }, MenuAction::DeletePath) => { + let paths = app + .data + .current_ws() + .map(|w| crate::app::context_action_paths(&w.tree_selected, path)) + .unwrap_or_else(|| vec![path.clone()]); + app.delete_tree_paths(&paths); + } + (_, MenuAction::NewTerm) => app.create_terminal(&app.t("newTerminal"), "", Vec::new()), + (_, MenuAction::Template(i)) => { + if let Some(t) = app.data.prefs.templates.get(i).cloned() { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + } + (_, MenuAction::Header) => {} + (_, MenuAction::ProjectTemplate(i)) => { + if let Some(t) = app + .data + .current_ws() + .and_then(|w| w.config.terminal_templates.get(i).cloned()) + { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + } + _ => {} + } +} diff --git a/src-gpui/src/ui/file_icons.rs b/src-gpui/src/ui/file_icons.rs new file mode 100644 index 00000000..6caaa2a8 --- /dev/null +++ b/src-gpui/src/ui/file_icons.rs @@ -0,0 +1,229 @@ +use gpui::{div, prelude::*, px, rgb, AnyElement, IntoElement}; +use gpui_component::{Icon, IconName, StyledExt}; + +/// Language-colored file-type mark used by the tree, tabs, palette, and Git list. +/// Pierre's SVG sprite is not available in GPUI; Lucide names plus a two-letter +/// language badge keep types distinguishable the way the complete icon set did. +pub fn file_icon(path: &str, is_dir: bool, expanded: bool) -> IconName { + if is_dir { + return if expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + } + match file_kind(path) { + FileKind::Markdown => IconName::BookOpen, + FileKind::Text => IconName::File, + FileKind::Config => IconName::Settings, + FileKind::Git => IconName::GitHub, + FileKind::Shell => IconName::SquareTerminal, + FileKind::Image => IconName::GalleryVerticalEnd, + FileKind::Html => IconName::Globe, + FileKind::Css => IconName::Palette, + FileKind::Archive => IconName::Frame, + FileKind::Pdf => IconName::File, + FileKind::Lock => IconName::Asterisk, + FileKind::Code | FileKind::Other => IconName::File, + } +} + +pub fn file_icon_color(path: &str, is_dir: bool) -> u32 { + if is_dir { + return 0xc9a227; + } + match file_kind(path) { + FileKind::Markdown => 0x519aba, + FileKind::Text => 0xa0a0a0, + FileKind::Config => 0xcbcb41, + FileKind::Git => 0xf05032, + FileKind::Shell => 0x89e051, + FileKind::Image => 0xa074c4, + FileKind::Html => 0xe34c26, + FileKind::Css => 0x563d7c, + FileKind::Archive => 0xcb8b41, + FileKind::Pdf => 0xe23b2d, + FileKind::Lock => 0x8b949e, + FileKind::Code => code_color(path), + FileKind::Other => 0x8b949e, + } +} + +pub fn file_glyph(path: &str, is_dir: bool, expanded: bool, size: f32) -> AnyElement { + if is_dir || !matches!(file_kind(path), FileKind::Code) { + return Icon::new(file_icon(path, is_dir, expanded)) + .w(px(size)) + .text_color(rgb(file_icon_color(path, is_dir))) + .into_any_element(); + } + let (label, color) = lang_mark(path); + div() + .size(px(size)) + .rounded_sm() + .bg(rgb(color)) + .text_color(gpui::white()) + .flex() + .items_center() + .justify_center() + .text_size(px((size * 0.55).max(7.))) + .font_semibold() + .child(label) + .into_any_element() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileKind { + Markdown, + Text, + Config, + Git, + Shell, + Image, + Html, + Css, + Archive, + Pdf, + Lock, + Code, + Other, +} + +pub(crate) fn file_kind(path: &str) -> FileKind { + let name = file_name(path); + if matches!( + name.as_str(), + ".gitignore" | ".gitattributes" | ".gitmodules" | ".gitkeep" + ) { + return FileKind::Git; + } + if matches!( + name.as_str(), + "dockerfile" | "docker-compose.yml" | "docker-compose.yaml" + ) { + return FileKind::Shell; + } + if name.ends_with(".lock") || name == "bun.lockb" || name == "package-lock.json" || name == "pnpm-lock.yaml" { + return FileKind::Lock; + } + let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); + match ext { + "md" | "mdx" | "markdown" => FileKind::Markdown, + "txt" | "rst" | "rtf" | "log" => FileKind::Text, + "json" | "jsonc" | "toml" | "yml" | "yaml" | "ini" | "env" | "editorconfig" => FileKind::Config, + "sh" | "zsh" | "bash" | "fish" | "ps1" | "bat" | "cmd" => FileKind::Shell, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => FileKind::Image, + "html" | "htm" => FileKind::Html, + "css" | "scss" | "less" => FileKind::Css, + "zip" | "tar" | "gz" | "tgz" | "rar" | "7z" | "bz2" | "xz" => FileKind::Archive, + "pdf" => FileKind::Pdf, + "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go" | "java" | "kt" | "c" | "cc" | "cpp" + | "cxx" | "h" | "hpp" | "cs" | "rb" | "php" | "swift" | "scala" | "lua" | "zig" | "nim" | "ex" | "exs" + | "erl" | "hs" | "ml" | "clj" | "dart" | "vue" | "svelte" | "sql" | "graphql" | "proto" => FileKind::Code, + _ => FileKind::Other, + } +} + +pub(crate) fn lang_mark(path: &str) -> (&'static str, u32) { + let ext = file_name(path) + .rsplit_once('.') + .map(|(_, e)| e.to_string()) + .unwrap_or_default(); + match ext.as_str() { + "rs" => ("RS", 0xdea584), + "ts" | "tsx" => ("TS", 0x3178c6), + "js" | "jsx" | "mjs" | "cjs" => ("JS", 0xf1e05a), + "py" => ("PY", 0x3572a5), + "go" => ("GO", 0x00add8), + "java" => ("JV", 0xb07219), + "kt" => ("KT", 0xb07219), + "c" | "h" => ("C", 0x555555), + "cc" | "cpp" | "cxx" | "hpp" => ("C+", 0xf34b7d), + "cs" => ("C#", 0x178600), + "rb" => ("RB", 0x701516), + "php" => ("PH", 0x4f5d95), + "swift" => ("SW", 0xf05138), + "scala" => ("SC", 0xc22d40), + "lua" => ("LU", 0x000080), + "zig" => ("ZG", 0xec915c), + "nim" => ("NI", 0xffe953), + "ex" | "exs" => ("EX", 0x6e4a7e), + "erl" => ("ER", 0xb83998), + "hs" => ("HS", 0x5e5086), + "ml" => ("ML", 0xe37933), + "clj" => ("CL", 0x5881d8), + "dart" => ("DA", 0x00b4ab), + "vue" => ("VU", 0x41b883), + "svelte" => ("SV", 0xff3e00), + "sql" => ("SQ", 0xe38c00), + "graphql" => ("GQ", 0xe10098), + "proto" => ("PB", 0x4fc1ff), + _ => ("•", 0x4fc1ff), + } +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_ascii_lowercase() +} + +fn code_color(path: &str) -> u32 { + lang_mark(path).1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directories_use_folder_kind_colors() { + assert_eq!(file_icon_color("src", true), 0xc9a227); + } + + #[test] + fn extensions_map_to_kinds() { + assert_eq!(file_kind("README.md"), FileKind::Markdown); + assert_eq!(file_kind("Cargo.toml"), FileKind::Config); + assert_eq!(file_kind(".gitignore"), FileKind::Git); + assert_eq!(file_kind("setup.sh"), FileKind::Shell); + assert_eq!(file_kind("logo.png"), FileKind::Image); + assert_eq!(file_kind("main.rs"), FileKind::Code); + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("app.css"), FileKind::Css); + assert_eq!(file_kind("dist.zip"), FileKind::Archive); + assert_eq!(file_kind("spec.pdf"), FileKind::Pdf); + assert_eq!(file_kind("Cargo.lock"), FileKind::Lock); + assert_eq!(file_kind("notes.unknown"), FileKind::Other); + } + + #[test] + fn rust_and_typescript_use_distinct_colors() { + assert_ne!(file_icon_color("lib.rs", false), file_icon_color("app.ts", false)); + } + + #[test] + fn language_marks_are_distinct_for_common_code() { + assert_eq!(lang_mark("lib.rs").0, "RS"); + assert_eq!(lang_mark("app.ts").0, "TS"); + assert_eq!(lang_mark("main.py").0, "PY"); + assert_eq!(lang_mark("mod.go").0, "GO"); + assert_ne!(lang_mark("lib.rs").1, lang_mark("app.ts").1); + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("app.css"), FileKind::Css); + assert_eq!(file_kind("dist.zip"), FileKind::Archive); + } + + #[test] + fn html_css_and_archives_use_distinct_kinds() { + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("theme.scss"), FileKind::Css); + assert_eq!(file_kind("bundle.tar.gz"), FileKind::Archive); + assert_ne!( + file_icon_color("index.html", false), + file_icon_color("theme.scss", false) + ); + assert_ne!(file_icon_color("dist.zip", false), file_icon_color("main.rs", false)); + } +} diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs new file mode 100644 index 00000000..3f1e0556 --- /dev/null +++ b/src-gpui/src/ui/file_tree.rs @@ -0,0 +1,290 @@ +use std::time::Duration; + +use gpui::{div, prelude::*, px, Animation, AnimationExt, Context, MouseButton, ScrollWheelEvent, Window}; + +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; + +use crate::app::AppView; +use crate::state::{leftover_sticky_folder, leftover_tree_row_height, leftover_visible_tree_rows, ContextMenu}; +use crate::ui::TreeDrag; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("file-tree-empty").into_any_element(); + }; + let profile = ws.profile_id.clone(); + let root = ws.tree.get("").cloned(); + let err = ws.tree_error.clone(); + + v_flex() + .id("file-tree") + .size_full() + .min_h_0() + .px(px(6.)) + .py_1() + .text_size(px(13.)) + .tab_index(0) + .on_key_down({ + let view = view.clone(); + move |ev: &gpui::KeyDownEvent, window, cx| { + view.update(cx, |app, cx| { + if app.tree_key(ev.keystroke.key.as_str(), ev.keystroke.modifiers.shift, window, cx) { + cx.notify(); + } + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::TreeBlank, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); + cx.notify(); + }); + } + }) + .on_drop({ + let view = view.clone(); + move |drag: &TreeDrag, _, cx| { + view.update(cx, |app, cx| { + app.drop_tree_paths(&[drag.path.clone()], None); + cx.notify(); + }); + } + }) + .when_some(err, |el, err| { + el.child(div().text_xs().text_color(theme.danger).opacity(0.8).child(err)) + }) + .when_some(sticky_folder(app), |el, crumb| { + el.child( + h_flex() + .id("tree-sticky-folder") + .w_full() + .px_1() + .py_0p5() + .mb_1() + .gap_1() + .rounded_md() + .bg(theme.muted) + .text_xs() + .font_semibold() + .child( + Icon::new(IconName::FolderOpen) + .w(px(12.)) + .text_color(gpui::rgb(0xc9a227)), + ) + .child(crumb), + ) + }) + .child(match root { + None => div() + .p_3() + .text_color(theme.muted_foreground) + .child(app.t("fileTreeLoading")) + .into_any_element(), + Some(root) if root.children.is_empty() => div() + .p_3() + .text_color(theme.muted_foreground) + .child(app.t("fileTreeEmptyDirectory")) + .into_any_element(), + Some(root) => v_flex() + .id("file-tree-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .on_scroll_wheel({ + let view = view.clone(); + move |ev: &ScrollWheelEvent, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + let delta = match ev.delta.pixel_delta(px(leftover_tree_row_height())) { + gpui::Point { y, .. } => f32::from(y), + }; + ws.tree_scroll_y = (ws.tree_scroll_y - delta).max(0.0); + cx.notify(); + } + }); + } + }) + .children( + root.children + .iter() + .map(|path| node_view(app, path, 0, &profile, window, cx)), + ) + .into_any_element(), + }) + .into_any_element() +} + +fn node_view( + app: &AppView, + path: &str, + depth: usize, + profile: &str, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(node) = app.data.current_ws().and_then(|w| w.tree.get(path)).cloned() else { + return div().into_any_element(); + }; + let status = app + .data + .current_ws() + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path || p.ends_with(path))) + .map(|(_, s)| s.clone()); + let indent = px(12. * depth as f32 + 4.); + let path_owned = path.to_string(); + let renaming = app.data.overlay.renaming_path.as_deref() == Some(path); + let selected = app.data.current_ws().is_some_and(|w| w.tree_selected.contains(path)); + + v_flex() + .child( + h_flex() + .id(crate::ui::eid(format!("tree-{path}"))) + .w_full() + .pl(indent) + .pr_1() + .py_0p5() + .rounded_md() + .gap_1() + .when(selected, |el| el.bg(theme.muted)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + let is_dir = node.is_dir; + move |ev, window, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.renaming_path.as_deref() == Some(path.as_str()) { + return; + } + let mods = ev.modifiers(); + app.click_tree_path(&path, is_dir, mods.platform || mods.control, mods.shift, window, cx); + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + let path = path_owned.clone(); + let is_dir = node.is_dir; + move |_, _, cx| { + if is_dir { + return; + } + view.update(cx, |app, _| { + app.data.overlay.drag_file = Some(path.clone()); + }); + } + }) + .on_drag( + TreeDrag { + path: path_owned.clone(), + }, + |info, _, _, cx| { + cx.new(|_| crate::ui::DragGhost { + label: info.path.clone(), + }) + }, + ) + .when(node.is_dir, |el| { + el.on_drop({ + let view = view.clone(); + let dest = path_owned.clone(); + move |drag: &TreeDrag, _, cx| { + view.update(cx, |app, cx| { + if drag.path != dest { + app.drop_tree_paths(&[drag.path.clone()], Some(&dest)); + cx.notify(); + } + }); + } + }) + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let path = path_owned.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::File { path: path.clone() }, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); + cx.notify(); + }); + } + }) + .child(crate::ui::file_icons::file_glyph(path, node.is_dir, node.expanded, 13.)) + .child(if renaming { + div() + .flex_1() + .min_w_0() + .child(Input::new(&app.inputs.rename)) + .into_any_element() + } else { + div() + .flex_1() + .when( + status.as_ref().is_some_and(|st| { + crate::app::git_status_kind(st) == crate::app::GitStatusKind::Deleted + }), + |el| el.line_through().text_color(theme.muted_foreground), + ) + .child(node.name.clone()) + .into_any_element() + }) + .when_some(status.filter(|_| !renaming), |el, st| { + let kind = crate::app::git_status_kind(&st); + el.child( + div() + .text_xs() + .text_color(match kind { + crate::app::GitStatusKind::Added | crate::app::GitStatusKind::Untracked => { + theme.success + } + crate::app::GitStatusKind::Deleted => theme.danger, + crate::app::GitStatusKind::Renamed => theme.warning, + crate::app::GitStatusKind::Ignored => theme.muted_foreground, + crate::app::GitStatusKind::Modified => theme.warning, + }) + .child(crate::app::file_status_badge(&st)), + ) + }), + ) + .when(node.is_dir && node.expanded, |el| { + el.child( + v_flex() + .id(crate::ui::eid(format!("tree-children-{path}"))) + .children( + node.children + .iter() + .map(|child| node_view(app, child, depth + 1, profile, window, cx)), + ) + .with_animation( + crate::ui::eid(format!("tree-open-{path}")), + Animation::new(Duration::from_millis(18)), + |this, delta| this.opacity(delta).ml(px(12.0 * (1.0 - delta))), + ), + ) + }) + .into_any_element() +} + +fn sticky_folder(app: &AppView) -> Option { + let ws = app.data.current_ws()?; + leftover_sticky_folder( + &leftover_visible_tree_rows(&ws.tree), + ws.tree_scroll_y, + leftover_tree_row_height(), + ) +} diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs new file mode 100644 index 00000000..b555c57b --- /dev/null +++ b/src-gpui/src/ui/file_viewer.rs @@ -0,0 +1,368 @@ +use std::path::PathBuf; + +use crate::ui::markdown::{self, MarkupTarget}; +use gpui::{div, img, prelude::*, px, rgb, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::text::TextView; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::backend; +use crate::state::UnifiedTab; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("file-viewer-none").into_any_element(); + }; + let Some(UnifiedTab::File { index }) = ws.active else { + return div().id("file-viewer-hidden").into_any_element(); + }; + let Some(file) = ws.files.get(index).cloned() else { + return div().id("file-viewer-missing").into_any_element(); + }; + + if let Some(err) = file.load_error.clone() { + return v_flex() + .id("file-viewer-error") + .size_full() + .items_center() + .justify_center() + .h(px(128.)) + .gap_2() + .child( + div() + .text_color(theme.muted_foreground) + .child(app.t("somethingWentWrong")), + ) + .child(div().text_xs().text_color(theme.muted_foreground).child(err)) + .into_any_element(); + } + + if file.preview { + return preview_pane(app, &file, cx).into_any_element(); + } + + if backend::is_markdown(&file.path) { + let preview = app.data.overlay.md_preview; + let draft = file.draft.clone(); + return v_flex() + .id("markdown-viewer") + .size_full() + .font(markdown::editor_font(app.data.prefs.font_family.clone())) + .text_size(px(app.data.prefs.font_size)) + .child(markdown::toolbar(app, MarkupTarget::File, window, cx)) + .child(if preview { + div() + .id("markdown-preview") + .flex_1() + .min_h_0() + .p_3() + .child(TextView::markdown("md-preview", draft, window, cx)) + .into_any_element() + } else { + div() + .id("markdown-editor") + .flex_1() + .min_h_0() + .p_2() + .child(Input::new(&app.inputs.file_editor)) + .into_any_element() + }) + .into_any_element(); + } + + let q = app.inputs.file_search.read(cx).value().to_string(); + let hits = crate::app::search_match_offsets(&file.draft, &q); + let hit_count = hits.len(); + let hit_ix = if hit_count == 0 { + 0 + } else { + app.data.overlay.file_search_ix % hit_count + 1 + }; + v_flex() + .id("text-viewer") + .size_full() + .font(markdown::editor_font(app.data.prefs.font_family.clone())) + .text_size(px(app.data.prefs.font_size)) + .when(app.data.overlay.file_search_open, |el| { + el.child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .border_b_1() + .border_color(theme.border) + .child(div().flex_1().child(Input::new(&app.inputs.file_search))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if q.is_empty() { + app.t("fileViewerFindInFile") + } else if hit_count == 0 { + app.t("terminalSearchNoResults") + } else { + format!("{hit_ix}/{hit_count}") + }), + ) + .child( + Button::new("file-find-prev") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip(app.t("fileViewerPreviousMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, false); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("file-find-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip(app.t("fileViewerNextMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, true); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("file-find-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("fileViewerCloseFileSearch")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.file_search_open = false; + cx.notify(); + }); + } + }), + ), + ) + }) + .child( + div() + .id("text-editor") + .flex_1() + .min_h_0() + .py(px(12.)) + .child(Input::new(&app.inputs.file_editor)), + ) + .into_any_element() +} + +pub fn leftover_preview_label(kind: &str) -> &'static str { + if kind == "office-pdf" { + "Office Preview" + } else { + "Preview" + } +} + +pub fn leftover_archive_counts(entries: &[(String, String)]) -> (usize, usize) { + let files = entries.iter().filter(|(_, kind)| kind == "file").count(); + (files, entries.len().saturating_sub(files)) +} + +fn leftover_preview_header(theme: &gpui_component::Theme, title: &str, right: String) -> impl IntoElement { + h_flex() + .w_full() + .min_h(px(36.)) + .px_3() + .gap_3() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted) + .justify_between() + .child(div().min_w_0().text_sm().font_medium().child(title.to_string())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .whitespace_nowrap() + .child(right), + ) +} + +fn preview_pane(_app: &AppView, file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + if file.preview_kind == "archive" && !file.archive_entries.is_empty() { + let (files, folders) = leftover_archive_counts(&file.archive_entries); + return v_flex() + .id("archive-preview") + .size_full() + .overflow_hidden() + .child(leftover_preview_header( + &theme, + &file.title, + format!("{files} files / {folders} folders"), + )) + .child( + v_flex() + .flex_1() + .min_h_0() + .min_w_0() + .overflow_hidden() + .px(px(6.)) + .py_1() + .children(file.archive_entries.iter().map(|(path, kind)| { + let is_dir = kind == "dir"; + let depth = path.matches('/').count(); + h_flex() + .gap_1() + .pl(px(4. + 12. * depth as f32)) + .child(crate::ui::file_icons::file_glyph(path, is_dir, false, 13.)) + .child(div().text_size(px(13.)).child(path.clone())) + })), + ) + .into_any_element(); + } + v_flex() + .id("binary-preview") + .size_full() + .overflow_hidden() + .child(leftover_preview_header( + &theme, + &file.title, + leftover_preview_label(&file.preview_kind).to_string(), + )) + .child(preview_body(file, cx)) + .into_any_element() +} + +fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + if file.preview_kind == "image" || backend::is_image(&file.path) { + let src = if file.preview_path.is_empty() { + file.path.clone() + } else { + file.preview_path.clone() + }; + return div() + .id("image-preview") + .size_full() + .relative() + .child(checkerboard()) + .child( + div().absolute().inset_0().flex().items_center().justify_center().child( + img(PathBuf::from(src)) + .id("image-preview-img") + .max_w_full() + .max_h_full(), + ), + ) + .into_any_element(); + } + if backend::is_document_preview(&file.preview_kind, &file.path) { + let path = if file.preview_path.is_empty() { + file.path.clone() + } else { + file.preview_path.clone() + }; + if let Some(png) = backend::rasterize_pdf_preview(&path) { + return div() + .id("document-preview") + .size_full() + .bg(rgb(0xffffff)) + .flex() + .items_center() + .justify_center() + .child(img(png).id("pdf-preview-img").max_w_full().max_h_full()) + .into_any_element(); + } + } + v_flex() + .flex_1() + .items_center() + .justify_center() + .child( + div() + .max_w(px(512.)) + .px_6() + .text_sm() + .text_color(theme.muted_foreground) + .child(if file.binary_note.is_empty() { + "Preview unavailable".to_string() + } else { + file.binary_note.clone() + }), + ) + .into_any_element() +} + +pub fn line_number_count(text: &str) -> usize { + if text.is_empty() { + 1 + } else { + text.lines().count().max(1) + } +} + +fn checkerboard() -> impl IntoElement { + let cell = px(16.); + let mark = gpui::hsla(0., 0., 0.5, 0.08); + v_flex() + .id("image-checkerboard") + .absolute() + .inset_0() + .overflow_hidden() + .children((0..24).map(move |row| { + h_flex() + .children((0..40).map(move |col| div().w(cell).h(cell).when((row + col) % 2 == 0, |el| el.bg(mark)))) + })) +} + +#[cfg(test)] +mod tests { + use super::{leftover_archive_counts, leftover_preview_label, line_number_count}; + + #[test] + fn line_number_count_empty_is_one() { + assert_eq!(line_number_count(""), 1); + } + + #[test] + fn line_number_count_counts_lines() { + assert_eq!(line_number_count("a"), 1); + assert_eq!(line_number_count("a\nb"), 2); + assert_eq!(line_number_count("a\nb\nc"), 3); + } + + #[test] + fn leftover_preview_label_is_office_only_for_office_pdf() { + assert_eq!(leftover_preview_label("office-pdf"), "Office Preview"); + assert_eq!(leftover_preview_label("pdf"), "Preview"); + assert_eq!(leftover_preview_label("image"), "Preview"); + } + + #[test] + fn leftover_archive_counts_match_file_preview_pane() { + assert_eq!( + leftover_archive_counts(&[ + ("a.txt".into(), "file".into()), + ("src/".into(), "dir".into()), + ("src/b.rs".into(), "file".into()), + ]), + (2, 1) + ); + } +} diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs new file mode 100644 index 00000000..91122f5a --- /dev/null +++ b/src-gpui/src/ui/git.rs @@ -0,0 +1,1338 @@ +use gpui::{div, img, prelude::*, px, Context, MouseButton, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::checkbox::Checkbox; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; + +use crate::app::{extract_file_hunk, file_status_badge, AppView}; +use crate::backend; +use crate::diff::{self, DiffLineKind}; +use crate::state::{ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab}; +use crate::timefmt; + +pub fn render_panel(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let Some(ws) = app.data.current_ws() else { + return div().id("git-panel-empty").into_any_element(); + }; + let files = ws.git_files.clone(); + let included = ws.git_included.clone(); + let ahead = ws.git_ahead; + + v_flex() + .id("git-panel") + .size_full() + .child( + v_flex() + .flex_1() + .min_h_0() + .child(leftover_changes_header(app, &files, included.len(), true, cx)) + .child(if files.is_empty() { + div() + .flex() + .flex_1() + .w_full() + .items_center() + .justify_center() + .p(px(24.)) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("noChangesDetected")), + ) + .into_any_element() + } else { + div() + .id("git-changes-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child(v_flex().children(files.iter().map(|(path, status)| { + file_row(app, path, status, included.contains(path), true, window, cx) + }))) + .into_any_element() + }), + ) + .child(commit_composer(app, included.len(), files.len(), ahead, cx)) + .into_any_element() +} + +pub fn file_display_parts(file_name: &str) -> (String, Option) { + match file_name.rfind('/') { + Some(idx) => (file_name[idx + 1..].to_string(), Some(file_name[..idx].to_string())), + None => (file_name.to_string(), None), + } +} + +pub fn leftover_step_index(current: usize, delta: i32, count: usize) -> usize { + if count == 0 { + return current; + } + let next = current as i32 + delta; + next.clamp(0, count as i32 - 1) as usize +} + +pub fn leftover_step_commit_index(current: Option, delta: i32, count: usize) -> Option { + if count == 0 { + return current; + } + Some(match current { + None => 0, + Some(index) => leftover_step_index(index, delta, count), + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverIncludeState { + Empty, + None, + Partial, + All, +} + +pub fn leftover_include_state(total: usize, included: usize) -> LeftoverIncludeState { + if total == 0 { + LeftoverIncludeState::Empty + } else if included == 0 { + LeftoverIncludeState::None + } else if included == total { + LeftoverIncludeState::All + } else { + LeftoverIncludeState::Partial + } +} + +pub fn leftover_composer_shows_push(total_files: usize) -> bool { + total_files == 0 +} + +pub fn leftover_short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +pub fn leftover_change_badge(status: &str) -> (&'static str, &'static str) { + match file_status_badge(status) { + "A" => ("A", "green"), + "D" => ("D", "red"), + "R" => ("R", "amber"), + "M" => ("M", "blue"), + other => (other, "muted"), + } +} + +fn leftover_changes_header( + app: &AppView, + files: &[(String, String)], + included: usize, + show_maximize: bool, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let state = leftover_include_state(files.len(), included); + let names: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + h_flex() + .id("git-changes-header") + .w_full() + .px_3() + .py(px(10.)) + .gap_2() + .bg(theme.background) + .border_b_1() + .border_color(theme.border) + .child(leftover_include_checkbox( + "git-all", + state, + names, + app.t("gitCommitIncludeAll"), + view.clone(), + )) + .child( + div() + .flex_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "changedFiles", + &[("count", &files.len().to_string())], + )), + ) + .when(show_maximize, |el| { + el.child( + Button::new("open-diff") + .ghost() + .xsmall() + .icon(IconName::Maximize) + .tooltip(app.t("gitOpenDiffView")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_git_diff(); + cx.notify(); + }); + } + }), + ) + }) +} + +fn leftover_include_checkbox( + id: &'static str, + state: LeftoverIncludeState, + names: Vec, + tip: String, + view: gpui::Entity, +) -> impl IntoElement { + let include_all = { + let view = view.clone(); + let names = names.clone(); + move |cx: &mut gpui::App| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included = names.iter().cloned().collect(); + } + cx.notify(); + }); + } + }; + let include_none = { + let view = view.clone(); + move |cx: &mut gpui::App| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included.clear(); + } + cx.notify(); + }); + } + }; + if state == LeftoverIncludeState::Partial { + return h_flex() + .id(id) + .w(px(16.)) + .h(px(16.)) + .items_center() + .justify_center() + .rounded(px(4.)) + .border_1() + .border_color(gpui::hsla(0., 0., 0.55, 0.45)) + .tooltip(crate::ui::tip(tip)) + .on_click(move |_, _, cx| include_all(cx)) + .child(Icon::new(IconName::Dash).w(px(12.))) + .into_any_element(); + } + let _ = tip; + Checkbox::new(id) + .checked(state == LeftoverIncludeState::All) + .on_click(move |checked, _, cx| { + if *checked { + include_all(cx); + } else { + include_none(cx); + } + }) + .into_any_element() +} + +fn leftover_badge_color(kind: &str) -> gpui::Hsla { + match kind { + "green" => gpui::hsla(142. / 360., 0.70, 0.45, 1.), + "red" => gpui::hsla(0., 0.84, 0.60, 1.), + "blue" => gpui::hsla(217. / 360., 0.91, 0.60, 1.), + "amber" => gpui::hsla(38. / 360., 0.92, 0.50, 1.), + _ => gpui::hsla(0., 0., 0.55, 1.), + } +} + +fn leftover_status_badge(status: &str) -> impl IntoElement { + let (letter, kind) = leftover_change_badge(status); + let color = leftover_badge_color(kind); + h_flex() + .flex_none() + .w(px(16.)) + .h(px(16.)) + .rounded(px(2.)) + .border_1() + .border_color(color.opacity(0.3)) + .bg(color.opacity(0.1)) + .justify_center() + .child(div().font_family("monospace").text_xs().text_color(color).child(letter)) +} + +fn leftover_path_cell(path: &str, active: bool, muted: gpui::Hsla) -> impl IntoElement { + let (basename, parent) = file_display_parts(path); + h_flex() + .min_w_0() + .flex_1() + .gap_2() + .overflow_hidden() + .child( + div() + .min_w_0() + .flex_1() + .text_sm() + .overflow_hidden() + .when(active, |el| el.font_medium()) + .child(basename), + ) + .when_some(parent, |el, dir| { + el.child( + div() + .min_w(px(16.)) + .overflow_hidden() + .text_xs() + .text_color(muted) + .child(dir), + ) + }) +} + +fn file_row( + app: &AppView, + path: &str, + status: &str, + checked: bool, + compact: bool, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let path_owned = path.to_string(); + let status_owned = status.to_string(); + let active = app.data.overlay.git_diff_file.as_deref() == Some(path); + h_flex() + .id(crate::ui::eid(format!("git-file-{path}"))) + .w_full() + .min_w_0() + .items_start() + .px_3() + .py_2() + .gap_2() + .overflow_hidden() + .when(active, |el| el.bg(theme.muted)) + .when(!checked, |el| el.opacity(0.7)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + let status = status_owned.clone(); + move |ev, window, cx| { + view.update(cx, |app, cx| { + app.select_diff_file(&path); + if ev.click_count() >= 2 { + if compact { + app.open_git_diff(); + } else if let Some(pid) = app.data.current_profile.clone() { + if crate::app::git_status_kind(&status) != crate::app::GitStatusKind::Deleted { + app.open_file(&pid, &path, window, cx); + } + app.data.overlay.git_diff_open = false; + } + } + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let path = path_owned.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.select_diff_file(&path); + app.data.overlay.context_menu = Some(( + ContextMenu::GitFile { path: path.clone() }, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); + cx.notify(); + }); + } + }) + .child( + Checkbox::new(crate::ui::eid(format!("chk-{path}"))) + .checked(checked) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + move |val, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if *val { + ws.git_included.insert(path.clone()); + } else { + ws.git_included.remove(&path); + } + } + cx.notify(); + }); + } + }), + ) + .child(leftover_path_cell(path, active, theme.muted_foreground)) + .child(leftover_status_badge(status)) +} + +fn commit_composer( + app: &AppView, + included: usize, + total: usize, + ahead: u32, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let summary_empty = app.inputs.commit_summary.read(cx).value().trim().is_empty(); + let no_files = leftover_composer_shows_push(total); + let can_commit = !no_files && included > 0 && !summary_empty; + v_flex() + .id("commit-composer") + .w_full() + .border_t_1() + .border_color(theme.border) + .px(px(10.)) + .py_2() + .gap_2() + .child( + div() + .text_xs() + .font_medium() + .text_color(theme.muted_foreground) + .child(app.t("gitCommitSectionTitle").to_ascii_uppercase()), + ) + .child(div().text_xs().child(app.t("gitCommitSummary"))) + .child(Input::new(&app.inputs.commit_summary).disabled(no_files)) + .child(div().text_xs().child(app.t("gitCommitBody"))) + .child(Input::new(&app.inputs.commit_body).disabled(no_files)) + .child(h_flex().w_full().justify_end().child(if no_files { + Button::new("push") + .small() + .disabled(ahead == 0) + .icon(IconName::ArrowUp) + .label(if ahead > 0 { + format!("{ahead} {}", app.t("gitPushButton")) + } else { + app.t("gitPushButton") + }) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.push_current(); + cx.notify(); + }); + } + }) + .into_any_element() + } else { + Button::new("commit") + .primary() + .small() + .label(app.t("gitCommitButton")) + .disabled(!can_commit) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.commit_selected(window, cx); + cx.notify(); + }); + } + }) + .into_any_element() + })) +} + +pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.git_diff_open { + return div().id("git-diff-closed").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + let branch = app + .data + .current_ws() + .map(|w| { + if w.branch.is_empty() { + "main".into() + } else { + w.branch.clone() + } + }) + .unwrap_or_else(|| "main".into()); + let tab = app.data.overlay.git_diff_tab; + let mode = app.data.overlay.git_diff_mode; + let files = app.data.current_ws().map(|w| w.git_files.clone()).unwrap_or_default(); + let selected = app.data.overlay.git_diff_file.clone(); + let diff = selected + .as_ref() + .map(|p| extract_file_hunk(&app.data.overlay.git_diff_text, p)) + .unwrap_or_else(|| app.data.overlay.git_diff_text.clone()); + let large = diff.lines().count() >= 400 + && selected + .as_ref() + .map(|p| !app.data.overlay.git_large_revealed.contains(p)) + .unwrap_or(false); + + div() + .id("git-diff-overlay") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.35)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_open = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("git-diff-dialog") + .w(px(1408.)) + .h(px(896.)) + .bg(theme.background) + .rounded_xl() + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .w_full() + .px_4() + .py_2() + .border_b_1() + .border_color(theme.border) + .justify_between() + .child( + h_flex() + .min_w_0() + .flex_1() + .gap(px(6.)) + .child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + .child(div().min_w_0().flex_1().text_sm().overflow_hidden().child(branch)), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("gitDiffPreviewMode")), + ) + .child( + Button::new("unified") + .xsmall() + .selected(mode == DiffPreviewMode::Unified) + .label(app.t("gitDiffPreviewModeUnified")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_mode = DiffPreviewMode::Unified; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("split") + .xsmall() + .selected(mode == DiffPreviewMode::Split) + .label(app.t("gitDiffPreviewModeSplit")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_mode = DiffPreviewMode::Split; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("close-diff") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("Close".to_string()) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_open = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + h_flex() + .flex_1() + .min_h_0() + .child( + v_flex() + .w(px(360.)) + .h_full() + .border_r_1() + .border_color(theme.border) + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(tab_btn( + "changes", + app.t("changes"), + tab == GitDiffTab::Changes, + GitDiffTab::Changes, + &view, + )) + .child(tab_btn( + "history", + app.t("history"), + tab == GitDiffTab::History, + GitDiffTab::History, + &view, + )), + ) + .child(if tab == GitDiffTab::Changes { + v_flex() + .flex_1() + .min_h_0() + .child(if files.is_empty() { + div() + .flex() + .flex_1() + .w_full() + .items_center() + .justify_center() + .p_8() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noChangesDetected")), + ) + .into_any_element() + } else { + v_flex() + .flex_1() + .children(files.iter().map(|(p, s)| { + file_row( + app, + p, + s, + app.data + .current_ws() + .map(|w| w.git_included.contains(p)) + .unwrap_or(false), + false, + window, + cx, + ) + })) + .into_any_element() + }) + .child(commit_composer( + app, + app.data.current_ws().map(|w| w.git_included.len()).unwrap_or(0), + files.len(), + app.data.current_ws().map(|w| w.git_ahead).unwrap_or(0), + cx, + )) + .into_any_element() + } else { + history_pane(app, cx).into_any_element() + }), + ) + .child(v_flex().flex_1().min_w_0().h_full().p_3().child( + if selected.is_none() && tab == GitDiffTab::Changes { + div() + .text_color(theme.muted_foreground) + .child(app.t("selectFileToView")) + .into_any_element() + } else if large { + v_flex() + .gap_2() + .child(div().font_semibold().child(app.t("gitDiffLargeGuardrailTitle"))) + .child(div().text_sm().child(app.t("gitDiffLargeGuardrailDescription"))) + .child( + Button::new("reveal-large") + .label(app.t("gitDiffLargeGuardrailReveal")) + .on_click({ + let view = view.clone(); + let path = selected.clone().unwrap_or_default(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_large_revealed.insert(path.clone()); + cx.notify(); + }); + } + }), + ) + .into_any_element() + } else if selected.as_ref().is_some_and(|p| backend::is_image(p)) { + image_preview(app, selected.as_deref().unwrap_or_default(), &diff).into_any_element() + } else { + v_flex() + .gap_2() + .children(diff::rename_paths(&diff).map(|(old, new)| { + v_flex() + .gap_1() + .child(div().text_xs().text_color(theme.muted_foreground).child(format!( + "{label}: {old}", + label = app.t("gitDiffRenamePreviousPath") + ))) + .child(div().text_xs().text_color(theme.muted_foreground).child(format!( + "{label}: {new}", + label = app.t("gitDiffRenameCurrentPath") + ))) + })) + .child(diff_stats(&diff)) + .child(diff_view( + &diff, + mode, + theme.muted_foreground, + selected.clone().unwrap_or_default(), + app.data.overlay.review_selection.clone(), + &view, + )) + .child(review_composer(app, cx)) + .into_any_element() + }, + )), + ) + .when(!app.data.overlay.review_comments.is_empty(), |el| { + el.child( + div().id("review-fab").absolute().bottom_4().right_4().child( + Button::new("open-review") + .primary() + .small() + .label(format!("Review Queue ({})", app.data.overlay.review_comments.len())) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::ReviewQueue); + cx.notify(); + }); + } + }), + ), + ) + }), + ) + .into_any_element() +} + +fn tab_btn( + id: &'static str, + label: String, + selected: bool, + tab: GitDiffTab, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(id) + .ghost() + .small() + .selected(selected) + .label(label) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.switch_git_tab(tab); + cx.notify(); + }); + }) +} + +fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + if let Some(hash) = app.data.overlay.git_selected_commit.clone() { + let commit = app + .data + .overlay + .git_commits + .iter() + .find(|c| c.hash == hash || c.full_hash == hash) + .cloned(); + let selected_file = app.data.overlay.git_diff_file.clone(); + return v_flex() + .id("commit-drill-in") + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .child( + Button::new("back-commits") + .ghost() + .xsmall() + .icon(IconName::ArrowLeft) + .tooltip(app.t("backToCommitList")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_selected_commit = None; + cx.notify(); + }); + } + }), + ) + .child( + v_flex() + .min_w_0() + .flex_1() + .child( + div() + .text_sm() + .font_medium() + .child(commit.as_ref().map(|c| c.message.clone()).unwrap_or_default()), + ) + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(commit.as_ref().map(|c| c.hash.clone()).unwrap_or(hash.clone())), + ), + ), + ) + .child(if app.data.overlay.git_commit_files.is_empty() { + div() + .p_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noFileChanges")) + .into_any_element() + } else { + v_flex() + .child( + div() + .px_3() + .py_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "changedFiles", + &[("count", &app.data.overlay.git_commit_files.len().to_string())], + )), + ) + .children(app.data.overlay.git_commit_files.iter().enumerate().map(|(ix, p)| { + let path = p.clone(); + let active = app.data.overlay.git_commit_file_index == ix + || selected_file.as_deref() == Some(p.as_str()); + h_flex() + .id(crate::ui::eid(format!("cfile-{p}"))) + .w_full() + .min_w_0() + .items_start() + .px_3() + .py_2() + .gap_2() + .overflow_hidden() + .when(active, |el| el.bg(theme.muted)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_commit_file_index = ix; + app.data.overlay.git_diff_file = Some(path.clone()); + cx.notify(); + }); + } + }) + .child(leftover_path_cell(p, active, theme.muted_foreground)) + .child(leftover_status_badge("M")) + })) + .into_any_element() + }) + .into_any_element(); + } + if app.data.overlay.git_commits.is_empty() { + return div() + .p_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noCommitsFound")) + .into_any_element(); + } + let now = timefmt::unix_now_secs(); + let selected_ix = app.data.overlay.git_commit_index; + v_flex() + .id("commit-list") + .children(app.data.overlay.git_commits.iter().enumerate().map(|(ix, c)| { + let hash = c.hash.clone(); + let active = selected_ix == Some(ix); + v_flex() + .id(crate::ui::eid(format!("commit-{hash}"))) + .px_3() + .py(px(6.)) + .gap(px(2.)) + .when(active, |el| el.bg(theme.muted)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let hash = hash.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.select_commit(&hash); + cx.notify(); + }); + } + }) + .child(div().text_sm().child(c.message.clone())) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(theme.muted_foreground) + .child(div().font_family("monospace").child(hash.clone())) + .child(div().min_w_0().flex_1().child(c.author.name.clone())) + .child(div().child(timefmt::format_relative_time(&c.date, now))), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .when(c.files_changed > 0, |el| { + el.child(div().text_color(theme.muted_foreground).child(format!( + "{} {}", + c.files_changed, + if c.files_changed == 1 { "file" } else { "files" } + ))) + }) + .when(c.insertions > 0, |el| { + el.child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("+{}", c.insertions)), + ) + }) + .when(c.deletions > 0, |el| { + el.child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("-{}", c.deletions)), + ) + }), + ) + })) + .into_any_element() +} + +fn diff_stats(diff: &str) -> impl IntoElement { + let adds = diff + .lines() + .filter(|l| l.starts_with('+') && !l.starts_with("+++")) + .count(); + let dels = diff + .lines() + .filter(|l| l.starts_with('-') && !l.starts_with("---")) + .count(); + h_flex() + .gap_2() + .text_xs() + .font_family("monospace") + .child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("+{adds}")), + ) + .child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("-{dels}")), + ) +} + +fn review_composer(app: &AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let Some(sel) = app.data.overlay.review_selection.clone() else { + return div().id("review-composer-idle").into_any_element(); + }; + let theme = cx.theme().clone(); + v_flex() + .id("review-composer") + .w(px(448.)) + .mt_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + .child( + h_flex() + .px_3() + .py_2() + .justify_between() + .border_b_1() + .border_color(theme.border) + .child( + div() + .text_xs() + .font_semibold() + .text_color(theme.muted_foreground) + .child(format!("Comment on {}", crate::review::format_review_range(sel.range))), + ) + .child( + Button::new("cancel-review") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("Cancel review comment".to_string()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cancel_review_comment(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .child(div().px_1().child(Input::new(&app.inputs.review_comment))) + .child( + h_flex() + .justify_end() + .px_3() + .py_2() + .border_t_1() + .border_color(theme.border) + .child( + Button::new("add-review") + .small() + .primary() + .label("Add to queue".to_string()) + .disabled(app.inputs.review_comment.read(cx).value().trim().is_empty()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.add_review_comment(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .into_any_element() +} + +fn diff_view( + diff: &str, + mode: DiffPreviewMode, + muted: gpui::Hsla, + file: String, + selection: Option, + view: &gpui::Entity, +) -> impl IntoElement { + if diff.trim().is_empty() { + return div().text_color(muted).child("").into_any_element(); + } + if mode == DiffPreviewMode::Split { + return split_diff_view(diff, muted, file, selection, view).into_any_element(); + } + let annotated = diff::annotate_unified(diff); + v_flex() + .id("diff-lines") + .gap_0() + .font_family("monospace") + .text_sm() + .children( + annotated + .into_iter() + .enumerate() + .map(|(ix, line)| unified_line(line, muted, file.clone(), selection.as_ref(), ix, view)), + ) + .into_any_element() +} + +fn unified_line( + line: diff::AnnotatedLine, + muted: gpui::Hsla, + file: String, + selection: Option<&crate::review::ReviewSelection>, + ix: usize, + view: &gpui::Entity, +) -> impl IntoElement { + let (mut bg, color) = line_colors(&line.raw, muted); + let target = match line.kind { + DiffLineKind::Add => line.new_no.map(|no| (crate::review::ReviewSide::Additions, no)), + DiffLineKind::Del => line.old_no.map(|no| (crate::review::ReviewSide::Deletions, no)), + _ => None, + }; + if let Some((side, no)) = target { + if selection.is_some_and(|s| s.file == file && crate::review::line_in_range(s.range, side, no)) { + bg = gpui::hsla(0.58, 0.55, 0.45, 0.22); + } + } + let view = view.clone(); + let text = line.raw; + div() + .id(crate::ui::eid(format!("udiff-{ix}"))) + .px_2() + .bg(bg) + .text_color(color) + .when_some(target, |el, (side, no)| { + el.on_click(move |ev, _, cx| { + let extend = ev.modifiers().shift; + view.update(cx, |app, cx| { + app.select_review_line(&file, side, no, extend); + cx.notify(); + }); + }) + }) + .child(text) +} + +fn line_colors(line: &str, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { + if line.starts_with('+') && !line.starts_with("+++") { + (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)) + } else if line.starts_with('-') && !line.starts_with("---") { + (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)) + } else { + (gpui::hsla(0., 0., 0., 0.), muted) + } +} + +fn kind_colors(kind: DiffLineKind, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { + match kind { + DiffLineKind::Add => (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)), + DiffLineKind::Del => (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)), + DiffLineKind::Header => (gpui::hsla(0., 0., 0., 0.04), muted), + DiffLineKind::Context => (gpui::hsla(0., 0., 0., 0.), muted), + } +} + +fn split_diff_view( + diff: &str, + muted: gpui::Hsla, + file: String, + selection: Option, + view: &gpui::Entity, +) -> impl IntoElement { + let annotated = diff::annotate_unified(diff); + v_flex() + .id("diff-split") + .gap_0() + .font_family("monospace") + .text_sm() + .children(diff::split_rows(diff).into_iter().enumerate().map(|(ix, row)| { + h_flex() + .id(crate::ui::eid(format!("split-row-{ix}"))) + .w_full() + .child(split_cell( + row.left, + muted, + ix, + "l", + file.clone(), + selection.as_ref(), + &annotated, + view, + )) + .child(div().w(px(1.)).h_full().bg(gpui::hsla(0., 0., 0.5, 0.2))) + .child(split_cell( + row.right, + muted, + ix, + "r", + file.clone(), + selection.as_ref(), + &annotated, + view, + )) + })) +} + +fn split_target( + kind: DiffLineKind, + text: &str, + annotated: &[diff::AnnotatedLine], +) -> Option<(crate::review::ReviewSide, u32)> { + let line = annotated.iter().find(|l| l.kind == kind && l.raw == text)?; + match kind { + DiffLineKind::Add => Some((crate::review::ReviewSide::Additions, line.new_no?)), + DiffLineKind::Del => Some((crate::review::ReviewSide::Deletions, line.old_no?)), + _ => None, + } +} + +fn split_cell( + cell: Option<(DiffLineKind, String)>, + muted: gpui::Hsla, + ix: usize, + side: &'static str, + file: String, + selection: Option<&crate::review::ReviewSelection>, + annotated: &[diff::AnnotatedLine], + view: &gpui::Entity, +) -> impl IntoElement { + let (kind, text) = match cell { + Some((kind, text)) => (Some(kind), text), + None => (None, String::new()), + }; + let (mut bg, color) = kind + .map(|k| kind_colors(k, muted)) + .unwrap_or((gpui::hsla(0., 0., 0., 0.), muted)); + let target = kind.and_then(|k| split_target(k, &text, annotated)); + if let Some((review_side, no)) = target { + if selection.is_some_and(|s| s.file == file && crate::review::line_in_range(s.range, review_side, no)) { + bg = gpui::hsla(0.58, 0.55, 0.45, 0.22); + } + } + let view = view.clone(); + let shown = if text.is_empty() { " ".to_string() } else { text }; + div() + .id(crate::ui::eid(format!("split-{side}-{ix}"))) + .flex_1() + .min_w_0() + .px_2() + .bg(bg) + .text_color(color) + .when_some(target, |el, (review_side, no)| { + el.on_click(move |ev, _, cx| { + let extend = ev.modifiers().shift; + view.update(cx, |app, cx| { + app.select_review_line(&file, review_side, no, extend); + cx.notify(); + }); + }) + }) + .child(shown) +} + +fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { + let worktree = app.data.current_ws().map(|w| w.worktree.clone()).unwrap_or_default(); + let after = std::path::PathBuf::from(&worktree).join(path); + let before_rel = diff::rename_paths(diff) + .map(|(old, _)| old) + .unwrap_or_else(|| path.to_string()); + let before = app.backend.cached_head_blob(&worktree, &before_rel); + h_flex() + .id("git-image-preview") + .w_full() + .gap_4() + .child( + v_flex() + .flex_1() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .child(app.t("gitDiffImagePreviewBefore")), + ) + .child(if let Some(before) = before { + img(before).w_full().max_h(px(480.)).into_any_element() + } else { + div() + .text_sm() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(app.t("gitDiffImagePreviewUnavailable")) + .into_any_element() + }), + ) + .child( + v_flex() + .flex_1() + .gap_1() + .child(div().text_xs().font_semibold().child(app.t("gitDiffImagePreviewAfter"))) + .child(if after.exists() { + img(after).w_full().max_h(px(480.)).into_any_element() + } else { + div() + .text_sm() + .child(app.t("gitDiffImagePreviewUnavailable")) + .into_any_element() + }), + ) +} + +#[cfg(test)] +mod tests { + use super::{ + file_display_parts, leftover_change_badge, leftover_composer_shows_push, leftover_include_state, + leftover_short_hash, leftover_step_commit_index, leftover_step_index, LeftoverIncludeState, + }; + + #[test] + fn leftover_file_display_parts_split_git_paths() { + assert_eq!( + file_display_parts("src/features/git/FileListItem.tsx"), + ("FileListItem.tsx".into(), Some("src/features/git".into())) + ); + assert_eq!(file_display_parts("README.md"), ("README.md".into(), None)); + } + + #[test] + fn leftover_step_index_clamps_like_git_diff_reducer() { + assert_eq!(leftover_step_index(2, 1, 10), 3); + assert_eq!(leftover_step_index(3, -1, 10), 2); + assert_eq!(leftover_step_index(0, -1, 5), 0); + assert_eq!(leftover_step_index(4, 1, 5), 4); + assert_eq!(leftover_step_index(0, 1, 1), 0); + assert_eq!(leftover_step_index(0, 1, 0), 0); + assert_eq!(leftover_step_index(2, 100, 5), 4); + } + + #[test] + fn leftover_step_commit_index_starts_at_first() { + assert_eq!(leftover_step_commit_index(None, 1, 5), Some(0)); + assert_eq!(leftover_step_commit_index(None, -1, 5), Some(0)); + assert_eq!(leftover_step_commit_index(None, 1, 0), None); + assert_eq!(leftover_step_commit_index(Some(1), 1, 5), Some(2)); + assert_eq!(leftover_step_commit_index(Some(4), 1, 5), Some(4)); + } + + #[test] + fn leftover_change_badge_matches_file_list_item() { + assert_eq!(leftover_change_badge("added"), ("A", "green")); + assert_eq!(leftover_change_badge("deleted"), ("D", "red")); + assert_eq!(leftover_change_badge("change"), ("M", "blue")); + assert_eq!(leftover_change_badge("renamed"), ("R", "amber")); + assert_eq!(leftover_change_badge("A"), ("A", "green")); + assert_eq!(leftover_change_badge("M"), ("M", "blue")); + } + + #[test] + fn leftover_composer_push_only_when_clean() { + assert!(leftover_composer_shows_push(0)); + assert!(!leftover_composer_shows_push(1)); + } + + #[test] + fn leftover_include_state_is_tri_state() { + assert_eq!(leftover_include_state(0, 0), LeftoverIncludeState::Empty); + assert_eq!(leftover_include_state(3, 0), LeftoverIncludeState::None); + assert_eq!(leftover_include_state(3, 2), LeftoverIncludeState::Partial); + assert_eq!(leftover_include_state(3, 3), LeftoverIncludeState::All); + } + + #[test] + fn leftover_commit_hash_is_seven_chars() { + assert_eq!(leftover_short_hash("abcdef1234567890"), "abcdef1"); + assert_eq!(leftover_short_hash("abc"), "abc"); + } +} diff --git a/src-gpui/src/ui/home.rs b/src-gpui/src/ui/home.rs new file mode 100644 index 00000000..54855326 --- /dev/null +++ b/src-gpui/src/ui/home.rs @@ -0,0 +1,60 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::{v_flex, ActiveTheme, Icon, IconName, StyledExt}; + +use crate::app::AppView; + +pub fn render(app: &AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let pad_left = if cfg!(target_os = "macos") && app.data.prefs.sidebar_collapsed { + px(84.) + } else { + px(20.) + }; + + v_flex() + .id("home-page") + .size_full() + .child( + div() + .id("home-header") + .h(px(52.)) + .w_full() + .px_5() + .pl(pad_left) + .flex() + .items_center() + .gap_2() + .border_b_1() + .border_color(theme.border) + .child( + Icon::new(IconName::Folder) + .text_color(theme.muted_foreground) + .w(px(16.)), + ) + .child(div().font_semibold().text_sm().child(app.t("home"))), + ) + .child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_3() + .child( + div() + .size(px(48.)) + .rounded_full() + .bg(theme.muted) + .flex() + .items_center() + .justify_center() + .child(crate::ui::leftover_folder_plus_mark(22., 10.)), + ) + .child(div().font_semibold().child(app.t("emptyProjectsTitle"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("emptyProjectsDesc")), + ), + ) +} diff --git a/src-gpui/src/ui/markdown.rs b/src-gpui/src/ui/markdown.rs new file mode 100644 index 00000000..55e2e82e --- /dev/null +++ b/src-gpui/src/ui/markdown.rs @@ -0,0 +1,1115 @@ +use gpui::{div, prelude::*, px, Context, Entity, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Selectable, Sizable}; + +use crate::app::AppView; +use crate::i18n; +use crate::state::{MdMenu, NotesStatus}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum MarkupTarget { + Notes, + File, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TableOp { + Insert, + AddRowAfter, + AddColAfter, + AddRowBefore, + AddColBefore, + DeleteRow, +} + +#[derive(Clone, Copy)] +pub struct SlashItem { + pub key: &'static str, + pub label_key: &'static str, + pub action: MdAction, +} + +#[derive(Clone, Copy)] +pub enum MdAction { + Wrap(&'static str, &'static str), + Block(&'static str), + Slash(&'static str, &'static str), + Insert(&'static str), + Table(TableOp), + ApplyLink, + RemoveLink, +} + +pub const DEFAULT_TABLE: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n"; + +pub fn slash_items() -> &'static [SlashItem] { + &[ + SlashItem { + key: "paragraph", + label_key: "notesFormatParagraph", + action: MdAction::Slash("", ""), + }, + SlashItem { + key: "h1", + label_key: "notesFormatHeading1", + action: MdAction::Slash("# ", ""), + }, + SlashItem { + key: "h2", + label_key: "notesFormatHeading2", + action: MdAction::Slash("## ", ""), + }, + SlashItem { + key: "h3", + label_key: "notesFormatHeading3", + action: MdAction::Slash("### ", ""), + }, + SlashItem { + key: "ul", + label_key: "notesFormatBulletList", + action: MdAction::Slash("- ", ""), + }, + SlashItem { + key: "ol", + label_key: "notesFormatOrderedList", + action: MdAction::Slash("1. ", ""), + }, + SlashItem { + key: "quote", + label_key: "notesFormatQuote", + action: MdAction::Slash("> ", ""), + }, + SlashItem { + key: "code", + label_key: "notesFormatCodeBlock", + action: MdAction::Slash("```\n", "\n```"), + }, + SlashItem { + key: "table", + label_key: "notesInsertTable", + action: MdAction::Slash(DEFAULT_TABLE, ""), + }, + SlashItem { + key: "hr", + label_key: "notesInsertDivider", + action: MdAction::Slash("---\n", ""), + }, + ] +} + +pub fn editor_font(family: impl Into) -> gpui::Font { + let mut font = gpui::font(family); + font.features = gpui::FontFeatures(std::sync::Arc::new(vec![("liga".into(), 1), ("calt".into(), 1)])); + font +} + +pub fn line_range(text: &str, caret: usize) -> (usize, usize) { + let caret = caret.min(text.len()); + let start = text[..caret].rfind('\n').map(|i| i + 1).unwrap_or(0); + let end = text[caret..].find('\n').map(|i| caret + i).unwrap_or(text.len()); + (start, end) +} + +pub fn slash_query(text: &str, caret: usize) -> Option { + let (start, end) = line_range(text, caret); + text[start..end].strip_prefix('/').map(|rest| rest.to_string()) +} + +pub fn apply_slash_at(text: &str, caret: usize, prefix: &str, suffix: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + if text[start..end].starts_with('/') { + let mut out = String::with_capacity(text.len() + prefix.len() + suffix.len()); + out.push_str(&text[..start]); + out.push_str(prefix); + out.push_str(suffix); + out.push_str(&text[end..]); + (out, start + prefix.len()) + } else { + crate::app::wrap_markup_text(text, caret, caret, prefix, suffix) + } +} + +pub fn strip_block_prefix(line: &str) -> &str { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("### ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("## ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("# ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("> ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("- ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("* ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("+ ") { + rest + } else if let Some((num, rest)) = trimmed.split_once(". ") { + if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) { + rest + } else { + trimmed + } + } else { + trimmed + } +} + +pub fn apply_block_prefix(text: &str, caret: usize, prefix: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + let line = &text[start..end]; + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let body = strip_block_prefix(line.trim_start()); + let mut out = String::with_capacity(text.len() + prefix.len()); + out.push_str(&text[..start]); + out.push_str(&indent); + out.push_str(prefix); + out.push_str(body); + out.push_str(&text[end..]); + (out, start + indent.len() + prefix.len()) +} + +pub fn insert_snippet(text: &str, caret: usize, snippet: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + let empty = text[start..end].trim().is_empty(); + let at = if empty { start } else { end }; + let mut out = String::new(); + out.push_str(&text[..at]); + if at > 0 && !text[..at].ends_with('\n') { + out.push('\n'); + } + out.push_str(snippet); + let caret = out.len(); + if empty { + out.push_str(&text[end..]); + } else { + if !snippet.ends_with('\n') && at < text.len() { + out.push('\n'); + } + out.push_str(&text[at..]); + } + let at = caret.min(out.len()); + (out, at) +} + +fn is_table_line(line: &str) -> bool { + let t = line.trim(); + t.starts_with('|') && t.ends_with('|') && t.len() > 1 +} + +fn is_separator_row(cells: &[String]) -> bool { + !cells.is_empty() + && cells.iter().all(|c| { + let t = c.trim(); + !t.is_empty() && t.chars().all(|ch| ch == '-' || ch == ':') + }) +} + +fn split_cells(line: &str) -> Vec { + let t = line.trim(); + let inner = t.strip_prefix('|').and_then(|s| s.strip_suffix('|')).unwrap_or(t); + inner.split('|').map(|c| c.trim().to_string()).collect() +} + +fn join_cells(cells: &[String]) -> String { + format!("| {} |", cells.join(" | ")) +} + +#[derive(Debug, Clone)] +struct TableSpan { + start_line: usize, + end_line: usize, + rows: Vec>, + row: usize, + col: usize, +} + +fn line_starts(text: &str) -> Vec { + let mut starts = vec![0]; + for (i, ch) in text.char_indices() { + if ch == '\n' { + starts.push(i + 1); + } + } + starts +} + +fn table_at(text: &str, caret: usize) -> Option { + let starts = line_starts(text); + let caret = caret.min(text.len()); + let line_ix = starts.iter().rposition(|&s| s <= caret).unwrap_or(0); + let lines: Vec<&str> = text.split('\n').collect(); + if line_ix >= lines.len() || !is_table_line(lines[line_ix]) { + return None; + } + let mut start = line_ix; + while start > 0 && is_table_line(lines[start - 1]) { + start -= 1; + } + let mut end = line_ix; + while end + 1 < lines.len() && is_table_line(lines[end + 1]) { + end += 1; + } + let rows: Vec> = lines[start..=end].iter().map(|l| split_cells(l)).collect(); + if rows.len() < 2 || !is_separator_row(&rows[1]) { + return None; + } + let col = { + let line = lines[line_ix]; + let local = caret.saturating_sub(starts[line_ix]); + let prefix = &line[..local.min(line.len())]; + prefix.chars().filter(|c| *c == '|').count().saturating_sub(1) + }; + Some(TableSpan { + start_line: start, + end_line: end, + row: line_ix - start, + col, + rows, + }) +} + +fn serialize_table(rows: &[Vec]) -> String { + rows.iter().map(|r| join_cells(r)).collect::>().join("\n") +} + +fn replace_lines(text: &str, start_line: usize, end_line: usize, next: &str) -> (String, usize) { + let starts = line_starts(text); + let from = starts.get(start_line).copied().unwrap_or(0); + let to = if end_line + 1 < starts.len() { + starts[end_line + 1].saturating_sub(1) + } else { + text.len() + }; + let mut out = String::new(); + out.push_str(&text[..from]); + out.push_str(next); + if to < text.len() { + if !next.ends_with('\n') && text[to..].starts_with('\n') { + out.push('\n'); + out.push_str(&text[to + 1..]); + } else { + out.push_str(&text[to..]); + } + } else if !next.ends_with('\n') && text.ends_with('\n') { + out.push('\n'); + } + (out, from) +} + +pub fn apply_table_op(text: &str, caret: usize, op: TableOp) -> (String, usize) { + if matches!(op, TableOp::Insert) { + let (start, end) = line_range(text, caret); + let empty = text[start..end].trim().is_empty(); + let insert_at = if empty { start } else { end }; + let prefix = if insert_at > 0 && !text[..insert_at].ends_with('\n') { + "\n" + } else { + "" + }; + let mut out = String::new(); + out.push_str(&text[..insert_at]); + out.push_str(prefix); + out.push_str(DEFAULT_TABLE); + if insert_at < text.len() && !DEFAULT_TABLE.ends_with('\n') { + out.push('\n'); + } + out.push_str(&text[if empty { end } else { insert_at }..]); + return (out, insert_at + prefix.len()); + } + + let Some(mut table) = table_at(text, caret) else { + return (text.to_string(), caret); + }; + let width = table.rows.first().map(|r| r.len()).unwrap_or(1).max(1); + for row in &mut table.rows { + while row.len() < width { + row.push(String::new()); + } + } + let col = table.col.min(width.saturating_sub(1)); + match op { + TableOp::Insert => {} + TableOp::AddRowAfter => { + let ix = if table.row <= 1 { + 2.min(table.rows.len()) + } else { + table.row + 1 + }; + table.rows.insert(ix.min(table.rows.len()), vec![String::new(); width]); + } + TableOp::AddRowBefore => { + let ix = if table.row <= 1 { 2 } else { table.row }; + table.rows.insert(ix.min(table.rows.len()), vec![String::new(); width]); + } + TableOp::AddColAfter => { + let at = col + 1; + for (i, row) in table.rows.iter_mut().enumerate() { + row.insert(at, if i == 1 { "---".into() } else { String::new() }); + } + } + TableOp::AddColBefore => { + for (i, row) in table.rows.iter_mut().enumerate() { + row.insert(col, if i == 1 { "---".into() } else { String::new() }); + } + } + TableOp::DeleteRow => { + if table.row <= 1 || table.rows.len() <= 3 { + return replace_lines(text, table.start_line, table.end_line, ""); + } + table.rows.remove(table.row); + } + } + if table.rows.len() >= 2 { + table.rows[1] = vec!["---".into(); table.rows[0].len().max(1)]; + } + replace_lines(text, table.start_line, table.end_line, &serialize_table(&table.rows)) +} + +fn link_span(text: &str, caret: usize) -> Option<(usize, usize, String, String)> { + let caret = caret.min(text.len()); + let window_start = caret.saturating_sub(200); + let window_end = (caret + 200).min(text.len()); + let slice = &text[window_start..window_end]; + let rel = caret - window_start; + let mut best = None; + for (i, _) in slice.match_indices('[') { + let Some(mid) = slice[i..].find("](") else { + continue; + }; + let Some(close) = slice[i + mid + 2..].find(')') else { + continue; + }; + let start = window_start + i; + let end = window_start + i + mid + 2 + close + 1; + if caret >= start && caret <= end { + let label = slice[i + 1..i + mid].to_string(); + let href = slice[i + mid + 2..i + mid + 2 + close].to_string(); + best = Some((start, end, label, href)); + if rel >= i { + break; + } + } + } + best +} + +pub fn link_href_at(text: &str, caret: usize) -> Option { + link_span(text, caret).map(|(_, _, _, href)| href) +} + +pub fn apply_link(text: &str, start: usize, end: usize, href: &str) -> (String, usize) { + let href = href.trim(); + if href.is_empty() { + return (text.to_string(), end); + } + if let Some((ls, le, label, _)) = link_span(text, start) { + let next = format!("[{label}]({href})"); + let mut out = String::new(); + out.push_str(&text[..ls]); + out.push_str(&next); + out.push_str(&text[le..]); + return (out, ls + next.len()); + } + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let label = if a == b { href } else { &text[a..b] }; + let next = format!("[{label}]({href})"); + let mut out = String::new(); + out.push_str(&text[..a]); + out.push_str(&next); + out.push_str(&text[b..]); + (out, a + next.len()) +} + +pub fn remove_link(text: &str, caret: usize) -> (String, usize) { + if let Some((start, end, label, _)) = link_span(text, caret) { + let mut out = String::new(); + out.push_str(&text[..start]); + out.push_str(&label); + out.push_str(&text[end..]); + (out, start + label.len()) + } else { + (text.to_string(), caret) + } +} + +pub const CODE_BLOCK_LANGS: &[&str] = &[ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "html", + "css", + "markdown", + "rust", + "python", + "go", + "sql", + "yaml", + "xml", + "c", + "cpp", + "shell", +]; + +pub fn fence_at(text: &str, caret: usize) -> Option<(usize, usize, String)> { + let starts = line_starts(text); + let caret = caret.min(text.len()); + let line_ix = starts.iter().rposition(|&s| s <= caret).unwrap_or(0); + let lines: Vec<&str> = text.split('\n').collect(); + let mut open = None; + for (i, line) in lines.iter().enumerate() { + if let Some(rest) = line.trim_start().strip_prefix("```") { + if open.is_none() { + open = Some((i, rest.trim().to_string())); + } else if i >= line_ix { + let (oi, lang) = open?; + if line_ix >= oi && line_ix <= i { + return Some((oi, i, lang)); + } + open = None; + } else { + open = None; + } + } + } + if let Some((oi, lang)) = open { + if line_ix >= oi { + return Some((oi, lines.len().saturating_sub(1), lang)); + } + } + None +} + +pub fn set_fence_language(text: &str, caret: usize, lang: &str) -> (String, usize) { + let Some((open_ix, _, _)) = fence_at(text, caret) else { + return (text.to_string(), caret); + }; + let starts = line_starts(text); + let from = starts[open_ix]; + let line_end = text[from..].find('\n').map(|i| from + i).unwrap_or(text.len()); + let indent = text[from..line_end] + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + let mut out = String::new(); + out.push_str(&text[..from]); + out.push_str(&indent); + out.push_str("```"); + out.push_str(lang); + out.push_str(&text[line_end..]); + (out, from + indent.len() + 3 + lang.len()) +} + +pub fn fence_body(text: &str, caret: usize) -> Option { + let (open_ix, close_ix, _) = fence_at(text, caret)?; + let lines: Vec<&str> = text.split('\n').collect(); + if close_ix <= open_ix + 1 { + return Some(String::new()); + } + Some(lines[open_ix + 1..close_ix].join("\n")) +} + +pub fn toolbar( + app: &mut AppView, + target: MarkupTarget, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let menu = app.data.overlay.md_menu; + let locale = app.data.locale; + let id_prefix = match target { + MarkupTarget::Notes => "notes", + MarkupTarget::File => "md", + }; + let status = match target { + MarkupTarget::Notes => app.data.current_ws().map(|w| w.notes_status).unwrap_or_default(), + MarkupTarget::File => { + if app + .data + .current_ws() + .and_then(|w| w.active_file()) + .is_some_and(|f| f.dirty()) + { + NotesStatus::Saving + } else { + NotesStatus::Saved + } + } + }; + let badge = match status { + NotesStatus::Saving => app.t("notesSaving"), + NotesStatus::Failed => app.t("notesSaveFailedShort"), + NotesStatus::Saved => app.t("notesSaved"), + }; + let input = match target { + MarkupTarget::Notes => &app.inputs.notes, + MarkupTarget::File => &app.inputs.file_editor, + }; + let draft = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + let fence = fence_at(&draft, caret); + let slash = slash_query(&draft, caret); + + v_flex() + .id(crate::ui::eid(format!("{id_prefix}-md-chrome"))) + .w_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_1() + .flex_wrap() + .border_b_1() + .border_color(theme.border) + .child(menu_btn( + &format!("{id_prefix}-cmd"), + IconName::Menu, + &app.t("notesCommandMenu"), + menu == Some(MdMenu::Command), + MdMenu::Command, + target, + &view, + )) + .child(sep(&theme)) + .child(mark_btn( + &format!("{id_prefix}-b"), + "B", + &format!("{} ⌘B", app.t("notesFormatBold")), + MdAction::Wrap("**", "**"), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-i"), + "I", + &format!("{} ⌘I", app.t("notesFormatItalic")), + MdAction::Wrap("*", "*"), + target, + &view, + )) + .child(icon_action( + &format!("{id_prefix}-code"), + IconName::Copy, + &app.t("notesFormatCode"), + MdAction::Wrap("`", "`"), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-s"), + "S", + &app.t("notesFormatStrike"), + MdAction::Wrap("~~", "~~"), + target, + &view, + )) + .child(menu_btn( + &format!("{id_prefix}-link"), + IconName::ExternalLink, + &app.t("notesFormatLink"), + menu == Some(MdMenu::Link), + MdMenu::Link, + target, + &view, + )) + .child(sep(&theme)) + .child(mark_btn( + &format!("{id_prefix}-ul"), + "•", + &app.t("notesFormatBulletList"), + MdAction::Block("- "), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-ol"), + "1.", + &app.t("notesFormatOrderedList"), + MdAction::Block("1. "), + target, + &view, + )) + .child(icon_action( + &format!("{id_prefix}-q"), + IconName::ChevronRight, + &app.t("notesFormatQuote"), + MdAction::Block("> "), + target, + &view, + )) + .child(sep(&theme)) + .child(menu_btn( + &format!("{id_prefix}-tbl"), + IconName::Frame, + &app.t("notesTableMenu"), + menu == Some(MdMenu::Table), + MdMenu::Table, + target, + &view, + )) + .when(target == MarkupTarget::File, |el| { + el.child( + Button::new(crate::ui::eid(format!("{id_prefix}-preview"))) + .ghost() + .xsmall() + .icon(if app.data.overlay.md_preview { + IconName::EyeOff + } else { + IconName::Eye + }) + .tooltip(if app.data.overlay.md_preview { + app.t("notesCodeBlockEdit") + } else { + app.t("notesCodeBlockHidePreview") + }) + .selected(app.data.overlay.md_preview) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.md_preview = !app.data.overlay.md_preview; + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-save"))) + .xsmall() + .primary() + .label(app.t("save")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.save_active_file(window, cx); + cx.notify(); + }); + } + }), + ) + }) + .child(div().flex_1().text_xs().text_color(theme.muted_foreground).child(badge)), + ) + .when(menu == Some(MdMenu::Command), |el| { + el.child(command_menu(id_prefix, target, locale, &view)) + }) + .when(menu == Some(MdMenu::Table), |el| { + el.child(table_menu(id_prefix, target, locale, &view)) + }) + .when(menu == Some(MdMenu::Link), |el| { + el.child(link_editor(app, id_prefix, target, &view)) + }) + .when(slash.is_some(), |el| { + el.child(slash_menu(id_prefix, target, &slash.unwrap_or_default(), locale, &view)) + }) + .when(fence.is_some(), |el| { + el.child(fence_bar(app, id_prefix, target, &draft, caret, window, cx)) + }) +} + +fn sep(theme: &gpui_component::Theme) -> impl IntoElement { + div().w(px(1.)).h(px(16.)).bg(theme.border) +} + +fn menu_btn( + id: &str, + icon: IconName, + tip: &str, + selected: bool, + menu: MdMenu, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .icon(icon) + .tooltip(tip.to_string()) + .selected(selected) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.toggle_md_menu(menu, target, window, cx); + cx.notify(); + }); + }) +} + +fn mark_btn( + id: &str, + label: &str, + tip: &str, + action: MdAction, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .label(label.to_string()) + .tooltip(tip.to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) +} + +fn icon_action( + id: &str, + icon: IconName, + tip: &str, + action: MdAction, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .icon(icon) + .tooltip(tip.to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) +} + +fn command_menu( + id_prefix: &str, + target: MarkupTarget, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let items: [(&str, &str, MdAction); 8] = [ + ("p", "notesFormatParagraph", MdAction::Block("")), + ("h1", "notesFormatHeading1", MdAction::Block("# ")), + ("h2", "notesFormatHeading2", MdAction::Block("## ")), + ("h3", "notesFormatHeading3", MdAction::Block("### ")), + ("q", "notesFormatQuote", MdAction::Block("> ")), + ("code", "notesFormatCodeBlock", MdAction::Wrap("```\n", "\n```")), + ("table", "notesInsertTable", MdAction::Table(TableOp::Insert)), + ("hr", "notesInsertDivider", MdAction::Insert("---\n")), + ]; + vertical_menu( + &format!("{id_prefix}-command-menu"), + &items.map(|(id, key, action)| (format!("{id_prefix}-cmd-{id}"), i18n::t(locale, key), action)), + target, + view, + ) +} + +fn table_menu( + id_prefix: &str, + target: MarkupTarget, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let items = [ + ( + format!("{id_prefix}-tbl-insert"), + i18n::t(locale, "notesInsertTable"), + MdAction::Table(TableOp::Insert), + ), + ( + format!("{id_prefix}-tbl-row-after"), + i18n::t(locale, "notesTableAddRow"), + MdAction::Table(TableOp::AddRowAfter), + ), + ( + format!("{id_prefix}-tbl-col-after"), + i18n::t(locale, "notesTableAddColumn"), + MdAction::Table(TableOp::AddColAfter), + ), + ( + format!("{id_prefix}-tbl-row-before"), + i18n::t(locale, "notesTableAddRowBefore"), + MdAction::Table(TableOp::AddRowBefore), + ), + ( + format!("{id_prefix}-tbl-col-before"), + i18n::t(locale, "notesTableAddColumnBefore"), + MdAction::Table(TableOp::AddColBefore), + ), + ( + format!("{id_prefix}-tbl-del"), + i18n::t(locale, "notesTableDeleteCells"), + MdAction::Table(TableOp::DeleteRow), + ), + ]; + vertical_menu(&format!("{id_prefix}-table-menu"), &items, target, view) +} + +fn vertical_menu( + id: &str, + items: &[(String, String, MdAction)], + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + v_flex() + .id(crate::ui::eid(id)) + .w(px(180.)) + .max_h(px(280.)) + .px_1() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .children(items.iter().map(|(id, label, action)| { + let view = view.clone(); + let action = *action; + Button::new(crate::ui::eid(id.clone())) + .ghost() + .xsmall() + .label(label.clone()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) + })) +} + +fn slash_menu( + id_prefix: &str, + target: MarkupTarget, + query: &str, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let q = query.to_ascii_lowercase(); + v_flex() + .id(crate::ui::eid(format!("{id_prefix}-slash"))) + .w(px(180.)) + .max_h(px(280.)) + .px_1() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .child( + div() + .px_2() + .text_xs() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(i18n::t(locale, "notesCommandMenu")), + ) + .children(slash_items().iter().filter_map(|item| { + let label = i18n::t(locale, item.label_key); + let hay = format!("{} {}", item.key, label.to_ascii_lowercase()); + if !q.is_empty() && !hay.contains(&q) { + return None; + } + let view = view.clone(); + let action = item.action; + Some( + Button::new(crate::ui::eid(format!("{id_prefix}-slash-{}", item.key))) + .ghost() + .xsmall() + .label(label) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }), + ) + })) +} + +fn link_editor(app: &AppView, id_prefix: &str, target: MarkupTarget, view: &gpui::Entity) -> impl IntoElement { + h_flex() + .id(crate::ui::eid(format!("{id_prefix}-link-editor"))) + .w_full() + .px_2() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .child(div().flex_1().min_w(px(160.)).child(Input::new(&app.inputs.md_link))) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-link-apply"))) + .ghost() + .xsmall() + .icon(IconName::Check) + .tooltip(app.t("notesApplyLink")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, MdAction::ApplyLink, window, cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-link-remove"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("notesRemoveLink")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, MdAction::RemoveLink, window, cx); + cx.notify(); + }); + } + }), + ) +} + +fn fence_bar( + app: &mut AppView, + id_prefix: &str, + target: MarkupTarget, + draft: &str, + caret: usize, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let current = fence_at(draft, caret).map(|(_, _, lang)| lang).unwrap_or_default(); + h_flex() + .id(crate::ui::eid(format!("{id_prefix}-fence"))) + .w_full() + .px_2() + .py_1() + .gap_1() + .flex_wrap() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .bg(gpui::rgb(0x282c34)) + .child( + div() + .text_xs() + .text_color(gpui::rgb(0xabb2bf)) + .child(if current.is_empty() { + app.t("notesCodeBlockSearchLanguage") + } else { + current.clone() + }), + ) + .children(CODE_BLOCK_LANGS.iter().map(|lang| { + let view = view.clone(); + let lang = *lang; + Button::new(crate::ui::eid(format!("{id_prefix}-lang-{lang}"))) + .ghost() + .xsmall() + .label(lang.to_string()) + .selected(lang == current) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.set_md_fence_language(target, lang, window, cx); + cx.notify(); + }); + }) + })) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-copy-fence"))) + .ghost() + .xsmall() + .icon(IconName::Copy) + .tooltip(app.t("notesCodeBlockCopy")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.copy_md_fence(target, cx); + cx.notify(); + }); + } + }), + ) +} + +pub fn md_input(app: &AppView, target: MarkupTarget) -> &Entity { + match target { + MarkupTarget::Notes => &app.inputs.notes, + MarkupTarget::File => &app.inputs.file_editor, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_block_prefix_replaces_heading() { + assert_eq!(apply_block_prefix("# hello", 3, "## "), ("## hello".into(), 3)); + assert_eq!(apply_block_prefix("- item", 2, ""), ("item".into(), 0)); + assert_eq!(apply_block_prefix("1. item", 3, "> "), ("> item".into(), 2)); + } + + #[test] + fn slash_query_reads_caret_line() { + assert_eq!(slash_query("/h1", 3).as_deref(), Some("h1")); + assert_eq!(slash_query("intro\n/tab", 10).as_deref(), Some("tab")); + assert!(slash_query("hello", 3).is_none()); + } + + #[test] + fn apply_slash_at_replaces_slash_line() { + assert_eq!(apply_slash_at("/h1", 3, "# ", ""), ("# ".into(), 2)); + assert_eq!( + apply_slash_at("intro\n/code", 10, "```\n", "\n```"), + ("intro\n```\n\n```".into(), 10) + ); + } + + #[test] + fn apply_table_op_inserts_and_edits() { + let (text, _) = apply_table_op("", 0, TableOp::Insert); + assert!(text.contains("| --- | --- | --- |")); + assert_eq!(text.lines().count(), 4); + + let src = "| A | B |\n| --- | --- |\n| x | y |"; + let (after, _) = apply_table_op(src, 30, TableOp::AddRowAfter); + assert_eq!(after.lines().count(), 4); + assert!(after.lines().last().unwrap().contains('|')); + + let (before, _) = apply_table_op(src, 30, TableOp::AddRowBefore); + assert_eq!(before.lines().count(), 4); + + let (cols, _) = apply_table_op(src, 3, TableOp::AddColAfter); + assert!(cols.lines().next().unwrap().matches('|').count() >= 4); + + let (left, _) = apply_table_op(src, 3, TableOp::AddColBefore); + assert!(left.lines().next().unwrap().starts_with("| | A")); + + let (gone, _) = apply_table_op(src, 30, TableOp::DeleteRow); + assert!(!gone.contains('x') || gone.trim().is_empty() || gone.lines().count() < 3); + } + + #[test] + fn apply_and_remove_link() { + let (linked, _) = apply_link("see here", 4, 8, "https://ex.test"); + assert_eq!(linked, "see [here](https://ex.test)"); + let (plain, _) = remove_link(&linked, 8); + assert_eq!(plain, "see here"); + } + + #[test] + fn fence_language_roundtrip() { + let src = "```\nfn main() {}\n```"; + assert_eq!(fence_at(src, 6).map(|(_, _, l)| l), Some(String::new())); + let (next, _) = set_fence_language(src, 6, "rust"); + assert_eq!(next, "```rust\nfn main() {}\n```"); + assert_eq!(fence_body(&next, 8).as_deref(), Some("fn main() {}")); + } +} diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs new file mode 100644 index 00000000..6beacd53 --- /dev/null +++ b/src-gpui/src/ui/mod.rs @@ -0,0 +1,86 @@ +use gpui::{div, prelude::*, px, AnyView, App, Context, SharedString, Window}; +use gpui_component::{Icon, IconName}; + +pub fn eid(s: impl Into) -> SharedString { + SharedString::from(s.into()) +} + +pub fn leftover_branch_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("⎇") +} + +pub fn leftover_pencil_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("✎") +} + +pub fn leftover_wrench_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("🔧") +} + +pub fn leftover_house_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("⌂") +} + +pub fn leftover_folder_plus_mark(folder: f32, plus: f32) -> gpui::Div { + div() + .relative() + .size(px(folder)) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .child(Icon::new(IconName::Folder).w(px(folder))) + .child( + div() + .absolute() + .bottom(px(-2.)) + .right(px(-3.)) + .size(px(plus)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Plus).w(px((plus * 0.75).max(8.)))), + ) +} + +pub fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static { + let text = text.into(); + move |window, cx| gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) +} + +pub struct DragGhost { + pub label: String, +} + +#[derive(Clone)] +pub struct TreeDrag { + pub path: String, +} + +#[derive(Clone)] +pub struct TopbarDrag { + pub id: String, +} + +impl gpui::Render for DragGhost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().px_2().py_1().rounded_md().opacity(0.45).child(self.label.clone()) + } +} + +pub mod debug; +pub mod dialogs; +pub mod file_icons; +pub mod file_tree; +pub mod file_viewer; +pub mod git; +pub mod home; +pub mod markdown; +pub mod notes; +pub mod palette; +pub mod settings; +pub mod shell; +pub mod sidebar; +pub mod terminal; +pub mod workspace; diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs new file mode 100644 index 00000000..f00120f4 --- /dev/null +++ b/src-gpui/src/ui/notes.rs @@ -0,0 +1,21 @@ +use gpui::{div, prelude::*, Context, Window}; +use gpui_component::input::Input; +use gpui_component::v_flex; + +use crate::app::AppView; +use crate::ui::markdown::{self, MarkupTarget}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("notes-panel") + .size_full() + .child(markdown::toolbar(app, MarkupTarget::Notes, window, cx)) + .child( + div() + .id("notes-editor") + .flex_1() + .min_h_0() + .p_2() + .child(Input::new(&app.inputs.notes)), + ) +} diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs new file mode 100644 index 00000000..e70c40ea --- /dev/null +++ b/src-gpui/src/ui/palette.rs @@ -0,0 +1,157 @@ +use gpui::{div, prelude::*, px, Context, KeyDownEvent, Window}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; + +use crate::app::AppView; + +pub fn parent_label(relative_path: &str, root_label: &str) -> String { + match relative_path.rfind('/') { + Some(ix) if ix > 0 => relative_path[..ix].to_string(), + _ => root_label.to_string(), + } +} + +pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.palette_open { + return div().id("palette-closed").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + let q = app.inputs.palette.read(cx).value().to_string(); + let results = app.data.overlay.palette_results.clone(); + let selected = app.data.overlay.palette_index; + let error = app.data.overlay.palette_error.clone(); + + div() + .id("palette-mask") + .absolute() + .inset_0() + .bg(gpui::hsla(0., 0., 0., 0.4)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.palette_open = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("command-palette") + .absolute() + .top(px(72.)) + .left_0() + .right_0() + .mx_auto() + .w(px(640.)) + .max_h(px(520.)) + .rounded_lg() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, window, cx| { + view.update(cx, |app, cx| { + if app.handle_overlay_key( + ev.keystroke.key.as_str(), + ev.keystroke.modifiers.shift, + window, + cx, + ) { + cx.notify(); + } + }); + } + }) + .child( + div() + .px_4() + .py_3() + .border_b_1() + .border_color(theme.border) + .child(Input::new(&app.inputs.palette)), + ) + .child(if let Some(error) = error.filter(|_| results.is_empty()) { + div() + .flex() + .justify_center() + .px_4() + .py_8() + .child(div().text_sm().text_color(theme.danger).child(error)) + .into_any_element() + } else if results.is_empty() { + div() + .flex() + .justify_center() + .px_4() + .py_8() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(if q.trim().is_empty() { + app.t("commandPaletteEmpty") + } else { + app.t("commandPaletteNoResults") + }), + ) + .into_any_element() + } else { + v_flex() + .max_h(px(420.)) + .p_1() + .children(results.iter().enumerate().map(|(ix, r)| { + h_flex() + .id(crate::ui::eid(format!("pal-{ix}"))) + .min_w_0() + .px_3() + .py_2() + .gap_2() + .rounded_md() + .when(ix == selected, |el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.data.overlay.palette_index = ix; + app.open_palette_selection(window, cx); + cx.notify(); + }); + } + }) + .child(crate::ui::file_icons::file_glyph(&r.name, false, false, 16.)) + .child( + v_flex() + .min_w_0() + .flex_1() + .child(div().text_sm().overflow_hidden().child(r.name.clone())) + .child( + div() + .text_xs() + .overflow_hidden() + .text_color(theme.muted_foreground) + .child(parent_label(&r.relative_path, &app.t("commandPaletteRoot"))), + ), + ) + })) + .into_any_element() + }), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_label_uses_directory_or_root() { + assert_eq!(parent_label("src/app.rs", "Project root"), "src"); + assert_eq!(parent_label("README.md", "Project root"), "Project root"); + assert_eq!(parent_label("src/ui/palette.rs", "根目录"), "src/ui"); + } +} diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs new file mode 100644 index 00000000..0a91a5d0 --- /dev/null +++ b/src-gpui/src/ui/settings.rs @@ -0,0 +1,1812 @@ +#![cfg_attr(rustfmt, rustfmt_skip)] + +use gpui::{ + div, img, point, prelude::*, px, size, App, Bounds, Context, Image, ImageFormat, SharedString, + Window, WindowBounds, WindowOptions, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::input::Input; +use gpui_component::switch::Switch; +use gpui_component::checkbox::Checkbox; +use gpui_component::{ + h_flex, v_flex, ActiveTheme, Icon, IconName, Root, Sizable, StyledExt, ThemeMode, +}; +use gpui_component::tab::Tab; + +use crate::app::AppView; +use crate::i18n::Locale; +use crate::prefs::{term_theme_by_name, RadiusPref, TERM_THEMES, ThemePref}; +use crate::state::{ + leftover_command_preview, leftover_normalize_global_template, leftover_template_draft_can_save, + leftover_template_draft_width, DialogKind, SettingsTab, +}; + +pub fn open_update_page(app: &mut AppView, window: &mut Window, cx: &mut Context) { + open_settings_at(app, window, cx, Some(SettingsTab::About)); +} + +pub fn open_settings_window(app: &mut AppView, window: &mut Window, cx: &mut Context) { + open_settings_at(app, window, cx, None); +} + +fn open_settings_at( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, + tab: Option, +) { + if let Some(existing) = app.settings_view.as_ref().and_then(|w| w.upgrade()) { + if let Some(tab) = tab { + existing.update(cx, |this, cx| { + this.tab = tab; + cx.notify(); + }); + } + if let Some(handle) = app.settings_window.clone() { + if handle.update(cx, |_, win, _| win.activate_window()).is_ok() { + app.data.overlay.settings_open = true; + app.data.overlay.settings_tab = tab.unwrap_or(app.data.overlay.settings_tab); + return; + } + } + } + let backend = app.backend.clone(); + let prefs = app.data.prefs.clone(); + let locale = app.data.locale; + let main = cx.entity().downgrade(); + let bounds = Bounds::centered(None, size(px(880.), px(640.)), cx); + if let Ok(handle) = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(600.), px(420.))), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("Settings")), + appears_transparent: false, + traffic_light_position: Some(point(px(12.), px(12.))), + }), + ..Default::default() + }, + move |window, cx| { + let initial = tab.unwrap_or(SettingsTab::General); + let view = cx.new(|cx| { + let mut settings = + SettingsView::new(backend.clone(), prefs.clone(), locale, main.clone(), window, cx); + settings.tab = initial; + if let Some(main) = settings.main.upgrade() { + let weak = cx.entity().downgrade(); + main.update(cx, |app, _| { + app.settings_view = Some(weak); + }); + } + settings + }); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) { + app.settings_window = Some(handle); + app.data.overlay.settings_open = true; + } + let _ = window; +} + +pub struct SettingsView { + backend: crate::backend::Backend, + prefs: crate::prefs::Prefs, + locale: Locale, + tab: SettingsTab, + main: gpui::WeakEntity, + custom_shell: gpui::Entity, + worktree: gpui::Entity, + template_name: gpui::Entity, + template_shell: gpui::Entity, + template_cwd: gpui::Entity, + template_cmds: gpui::Entity, + update_status: String, + latest_version: Option, + released_at: Option, + latest_url: String, + fonts: Vec, + sounds: Vec, + editing_template_id: Option, + template_dialog_open: bool, + preview_theme: Option, + open_select: Option, +} + +impl SettingsView { + fn new( + backend: crate::backend::Backend, + prefs: crate::prefs::Prefs, + locale: Locale, + main: gpui::WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + fn inp( + window: &mut Window, + cx: &mut Context, + ph: &str, + val: &str, + multi: bool, + ) -> gpui::Entity { + let ph = ph.to_string(); + let val = val.to_string(); + cx.new(|cx| { + let mut s = gpui_component::input::InputState::new(window, cx).placeholder(ph); + if multi { + s = s.multi_line(true); + } + s + }) + } + let custom_shell = inp(window, cx, &crate::i18n::t(locale, "customShellPlaceholder"), &prefs.custom_shell, false); + let worktree = inp(window, cx, &crate::i18n::t(locale, "defaultWorktreeDirPlaceholder"), &prefs.worktree_dir, false); + custom_shell.update(cx, |s, cx| { + s.set_placeholder(crate::i18n::t(locale, "customShellPlaceholder"), window, cx); + s.set_value(prefs.custom_shell.clone(), window, cx); + }); + worktree.update(cx, |s, cx| { + s.set_value(prefs.worktree_dir.clone(), window, cx); + }); + Self { + backend, + prefs, + locale, + tab: SettingsTab::General, + main, + custom_shell, + worktree, + template_name: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateNamePlaceholder"), "", false), + template_shell: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateShellPlaceholder"), "", false), + template_cwd: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateCwdPlaceholder"), "", false), + template_cmds: inp(window, cx, &crate::i18n::t(locale, "scriptPlaceholder"), "", true), + update_status: crate::i18n::t(locale, "updateIdleDescription"), + latest_version: None, + released_at: None, + latest_url: crate::updater::releases_page().to_string(), + fonts: crate::platform::list_mono_fonts(), + sounds: crate::platform::list_system_sounds(), + editing_template_id: None, + template_dialog_open: false, + preview_theme: None, + open_select: None, + } + } + + fn t(&self, key: &str) -> String { + crate::i18n::t(self.locale, key) + } + + fn persist(&mut self, cx: &mut Context) { + let typed = self.custom_shell.read(cx).value().to_string(); + let listed = crate::platform::list_shells(); + if self.prefs.custom_shell.is_empty() || !listed.iter().any(|s| s == &self.prefs.custom_shell) { + self.prefs.custom_shell = typed; + } + self.prefs.worktree_dir = self.worktree.read(cx).value().to_string(); + self.prefs.language = self.locale; + self.prefs.save(&self.backend.app_data_dir); + let scale = self.prefs.radius.scale(); + let theme = gpui_component::Theme::global_mut(cx); + theme.radius = px(6.0 * scale); + theme.radius_lg = px(8.0 * scale); + if let Some(main) = self.main.upgrade() { + let prefs = self.prefs.clone(); + let locale = self.locale; + main.update(cx, |app, cx| { + app.data.prefs = prefs; + app.data.locale = locale; + if !app.data.prefs.debug_mode { + if app.data.overlay.dialog == Some(DialogKind::DebugLog) { + app.data.overlay.dialog = None; + } + app.data.overlay.debug_open = false; + } + app.persist_prefs(); + cx.notify(); + }); + } + } +} + +impl gpui::Render for SettingsView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let tab = self.tab; + v_flex() + .id("settings-window") + .size_full() + .relative() + .bg(theme.background) + .child( + h_flex() + .id("settings-tabs") + .w_full() + .px_5() + .pt_5() + .gap_2() + .overflow_x_scroll() + .child(set_tab("g", IconName::Settings, self.t("general"), tab == SettingsTab::General, SettingsTab::General, cx)) + .child(set_tab("t", IconName::SquareTerminal, self.t("terminal"), tab == SettingsTab::Terminal, SettingsTab::Terminal, cx)) + .child(set_tab("tp", IconName::LayoutDashboard, self.t("templates"), tab == SettingsTab::Templates, SettingsTab::Templates, cx)) + .child(set_tab("n", IconName::Bell, self.t("notification"), tab == SettingsTab::Notification, SettingsTab::Notification, cx)) + .child(set_tab("tb", IconName::PanelBottom, self.t("topbar"), tab == SettingsTab::TopBar, SettingsTab::TopBar, cx)) + .child(set_tab("a", IconName::Info, self.t("about"), tab == SettingsTab::About, SettingsTab::About, cx)), + ) + .child( + div() + .id("settings-body") + .flex_1() + .min_h_0() + .p_5() + .overflow_y_scroll() + .child(match self.tab { + SettingsTab::General => self.general(window, cx).into_any_element(), + SettingsTab::Terminal => self.terminal(window, cx).into_any_element(), + SettingsTab::Templates => self.templates(window, cx).into_any_element(), + SettingsTab::Notification => self.notification(cx).into_any_element(), + SettingsTab::TopBar => self.topbar(cx).into_any_element(), + SettingsTab::About => self.about(cx).into_any_element(), + }), + ) + .child(self.leftover_template_draft_overlay(window, cx)) + } +} + +impl SettingsView { + fn general(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .id("settings-general") + .max_w(px(448.)) + .gap_6() + .child(field_label(&self.t("language"))) + .child(leftover_select( + "language", + match self.locale { + Locale::En => "English", + Locale::Zh => "中文", + }, + self.open_select.as_deref() == Some("language"), + false, + vec![ + ("en".into(), "English".into()), + ("zh".into(), "中文".into()), + ], + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.locale = if value == "zh" { Locale::Zh } else { Locale::En }; + this.persist(cx); + }, + )) + .child(field_label(&self.t("theme"))) + .child(leftover_select( + "theme", + &match self.prefs.theme { + ThemePref::System => self.t("themeSystem"), + ThemePref::Light => self.t("themeLight"), + ThemePref::Dark => self.t("themeDark"), + }, + self.open_select.as_deref() == Some("theme"), + false, + vec![ + ("system".into(), self.t("themeSystem")), + ("light".into(), self.t("themeLight")), + ("dark".into(), self.t("themeDark")), + ], + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.theme = match value.as_str() { + "light" => ThemePref::Light, + "dark" => ThemePref::Dark, + _ => ThemePref::System, + }; + let mode = match this.prefs.theme { + ThemePref::Dark => ThemeMode::Dark, + ThemePref::Light => ThemeMode::Light, + ThemePref::System => cx.window_appearance().into(), + }; + gpui_component::Theme::change(mode, None, cx); + this.persist(cx); + }, + )) + .child(field_label(&self.t("borderRadius"))) + .child( + h_flex().gap_2().children(RadiusPref::all().into_iter().map(|r| { + let selected = self.prefs.radius == r; + choice(r.label_key(), &self.t(r.label_key()), selected, { + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.radius = r; + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ) + .child(field_label(&self.t("defaultWorktreeDir"))) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("defaultWorktreeDirDesc")), + ) + .child( + h_flex() + .gap_2() + .child(div().flex_1().min_w_0().child(Input::new(&self.worktree))) + .child( + Button::new("pick-worktree") + .small() + .icon(IconName::Folder) + .label(self.t("chooseFolder")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + if let Some(folder) = crate::backend::pick_folder() { + view.update(cx, |this, cx| { + this.worktree.update(cx, |s, cx| { + s.set_value(folder, window, cx); + }); + this.persist(cx); + cx.notify(); + }); + } + } + }), + ) + .child( + Button::new("clear-worktree") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(self.t("clearDefaultWorktreeDir")) + .disabled(self.worktree.read(cx).value().is_empty()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.worktree.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ) + .child(switch_row( + "debug-mode", + self.t("debugMode"), + self.t("debugModeDescription"), + self.prefs.debug_mode, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.debug_mode = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(switch_row( + "perf", + self.t("performanceProfile"), + self.t("performanceProfileDescription"), + self.prefs.performance_profile, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.performance_profile = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(switch_row( + "avatars", + self.t("showProjectAvatars"), + self.t("showProjectAvatarsDescription"), + self.prefs.show_avatars, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.show_avatars = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + } + + fn preview_theme_name(&self, cx: &Context) -> String { + if let Some(name) = &self.preview_theme { + return name.clone(); + } + if !self.prefs.sync_terminal_theme && cx.theme().mode == ThemeMode::Light { + self.prefs.terminal_theme_light.clone() + } else { + self.prefs.terminal_theme_dark.clone() + } + } + + fn terminal(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = term_theme_by_name(&self.preview_theme_name(cx)); + let shells = crate::platform::list_shells(); + let default_shell = crate::backend::default_shell(); + let custom_selected = self.prefs.custom_shell.is_empty() || !shells.iter().any(|s| s == &self.prefs.custom_shell); + let fonts = if self.prefs.show_all_fonts { + crate::platform::visible_font_families(true) + } else { + self.fonts.clone() + }; + let dark_label = if self.prefs.sync_terminal_theme { + self.t("terminalTheme") + } else { + self.t("terminalThemeDark") + }; + h_flex() + .id("settings-terminal") + .size_full() + .gap_8() + .child( + v_flex() + .max_w(px(448.)) + .gap_4() + .child(theme_label_row(&dark_label, "preview-dark", self.t("preview"), { + let view = view.clone(); + let name = self.prefs.terminal_theme_dark.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.preview_theme = Some(name.clone()); + cx.notify(); + }); + } + })) + .child(leftover_select( + "term-theme-dark", + &self.prefs.terminal_theme_dark, + self.open_select.as_deref() == Some("term-theme-dark"), + false, + TERM_THEMES.iter().map(|t| (t.name.to_string(), t.name.to_string())).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_theme_dark = value.clone(); + if this.prefs.sync_terminal_theme { + this.prefs.terminal_theme_light = value; + } + this.preview_theme = None; + this.persist(cx); + }, + )) + .when(!self.prefs.sync_terminal_theme, |el| { + el.child(theme_label_row(&self.t("terminalThemeLight"), "preview-light", self.t("preview"), { + let view = view.clone(); + let name = self.prefs.terminal_theme_light.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.preview_theme = Some(name.clone()); + cx.notify(); + }); + } + })) + .child(leftover_select( + "term-theme-light", + &self.prefs.terminal_theme_light, + self.open_select.as_deref() == Some("term-theme-light"), + false, + TERM_THEMES.iter().map(|t| (t.name.to_string(), t.name.to_string())).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_theme_light = value; + this.preview_theme = None; + this.persist(cx); + }, + )) + }) + .child(leftover_check_row( + "sync-term", + self.t("syncTerminalTheme"), + self.prefs.sync_terminal_theme, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.sync_terminal_theme = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(field_label(&self.t("defaultShell"))) + .child(leftover_select( + "shell", + &if custom_selected { + self.t("customShell") + } else if self.prefs.custom_shell == default_shell { + format!("{} ({})", self.prefs.custom_shell, self.t("defaultOption")) + } else { + self.prefs.custom_shell.clone() + }, + self.open_select.as_deref() == Some("shell"), + false, + { + let mut options: Vec<(String, String)> = shells + .into_iter() + .map(|shell| { + let label = if shell == default_shell { + format!("{shell} ({})", self.t("defaultOption")) + } else { + shell.clone() + }; + (shell, label) + }) + .collect(); + options.push(("__custom__".into(), self.t("customShell"))); + options + }, + view.clone(), + cx.theme().clone(), + |this, value, cx| { + if value == "__custom__" { + if crate::platform::list_shells().iter().any(|s| s == &this.prefs.custom_shell) { + this.prefs.custom_shell.clear(); + } + } else { + this.prefs.custom_shell = value; + } + this.persist(cx); + }, + )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("defaultShellDescription")), + ) + .when(custom_selected, |el| el.child(Input::new(&self.custom_shell))) + .child(field_label(&self.t("terminalFont"))) + .when(fonts.is_empty(), |el| { + el.child(leftover_select( + "font", + &self.t("fontPickerUnavailable"), + false, + true, + vec![("".into(), self.t("fontPickerUnavailable"))], + view.clone(), + cx.theme().clone(), + |_, _, _| {}, + )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("fontPickerUnavailableDescription")), + ) + }) + .when(!fonts.is_empty(), |el| { + el.child(leftover_select( + "font", + &self.prefs.font_family, + self.open_select.as_deref() == Some("font"), + false, + fonts.into_iter().map(|family| (family.clone(), family)).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.font_family = value; + this.persist(cx); + }, + )) + }) + .child(leftover_check_row( + "show-all-fonts", + self.t("showAllFonts"), + self.prefs.show_all_fonts, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.show_all_fonts = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(field_label(&self.t("fontSize"))) + .child( + h_flex() + .gap_2() + .child( + Button::new("fs-") + .xsmall() + .label("-") + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.font_size = (this.prefs.font_size - 1.0).max(10.0); + this.persist(cx); + cx.notify(); + }); + } + }), + ) + .child(div().child(format!("{}", self.prefs.font_size as i32))) + .child( + Button::new("fs+") + .xsmall() + .label("+") + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.font_size = (this.prefs.font_size + 1.0).min(20.0); + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + v_flex() + .id("terminal-preview") + .flex_1() + .h(px(220.)) + .rounded_lg() + .px(px(16.)) + .py(px(12.)) + .gap_0() + .bg(gpui::rgb(theme.bg)) + .text_color(gpui::rgb(theme.fg)) + .font_family(self.prefs.font_family.clone()) + .text_size(px(self.prefs.font_size)) + .border_1() + .border_color(cx.theme().border) + .child(preview_line(0x3fb950, "$", theme.fg, "whoami")) + .child(div().child("2code")) + .child(preview_line(0x3fb950, "$", theme.fg, "ls")) + .child(div().child("src-gpui docs messages")) + .child(preview_line(0x3fb950, "$", theme.fg, "echo \"Hello, 2code!\"")) + .child(div().child("Hello, 2code!")) + .child( + h_flex() + .gap_1() + .child(div().text_color(gpui::rgb(0x3fb950)).child("$")) + .child( + div() + .w(px(8.)) + .h(px(self.prefs.font_size)) + .bg(gpui::rgb(theme.cursor)), + ), + ), + ) + } + + fn templates(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _ = window; + let view = cx.entity(); + let theme = cx.theme().clone(); + v_flex() + .id("settings-templates") + .max_w(px(672.)) + .gap_4() + .child( + h_flex() + .items_start() + .justify_between() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_semibold().child(self.t("globalTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(self.t("globalTerminalTemplatesDescription")), + ), + ) + .child( + Button::new("add-tpl") + .outline() + .small() + .label(self.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.open_template_draft(None, window, cx); + }); + } + }), + ), + ) + .child(if self.prefs.templates.is_empty() { + div() + .rounded_lg() + .border_1() + .border_color(theme.border) + .px_4() + .py_3() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(self.t("noTerminalTemplates")), + ) + .into_any_element() + } else { + v_flex() + .gap_2() + .children(self.prefs.templates.iter().cloned().map(|t| { + let preview = leftover_command_preview(&t.commands.join("\n")); + h_flex() + .id(crate::ui::eid(format!("tpl-{}", t.id))) + .items_center() + .justify_between() + .gap_4() + .rounded_lg() + .border_1() + .border_color(theme.border) + .px_4() + .py_3() + .child( + v_flex() + .min_w_0() + .gap_1() + .child(div().font_medium().child(t.name.clone())) + .when(!preview.is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) + .child(preview), + ) + }), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new(crate::ui::eid(format!("tpl-edit-{}", t.id))) + .ghost() + .xsmall() + .tooltip(self.t("editTerminalTemplate")) + .child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) + .on_click({ + let view = view.clone(); + let t = t.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.open_template_draft(Some(&t), window, cx); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("del-{}", t.id))) + .ghost() + .xsmall() + .tooltip(self.t("deleteTerminalTemplate")) + .child(Icon::new(IconName::Delete).w(px(14.)).text_color(theme.danger)) + .on_click({ + let view = view.clone(); + let id = t.id.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.templates.retain(|x| x.id != id); + if this.editing_template_id.as_deref() == Some(id.as_str()) { + this.close_template_draft(); + } + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ) + })) + .into_any_element() + }) + } + + fn leftover_template_draft_overlay( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + if !self.template_dialog_open { + return div().id("no-tpl-draft").into_any_element(); + } + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = self.editing_template_id.is_some(); + let name = self.template_name.read(cx).value().to_string(); + let can_save = leftover_template_draft_can_save(&name); + div() + .id("tpl-draft-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.close_template_draft(); + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("tpl-draft-panel") + .w(px(leftover_template_draft_width())) + .p_4() + .gap_4() + .rounded_xl() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))) + .child( + div().font_semibold().child(if editing { + self.t("editTerminalTemplate") + } else { + self.t("addTerminalTemplate") + }), + ), + ) + .child( + v_flex() + .gap_4() + .child( + v_flex() + .gap_1() + .child(field_label(&self.t("terminalTemplateName"))) + .child(Input::new(&self.template_name)), + ) + .child( + v_flex() + .gap_1() + .child(field_label(&self.t("terminalTemplateCommands"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(self.t("terminalTemplateCommandsDescription")), + ) + .child(Input::new(&self.template_cmds)), + ), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Button::new("tpl-cancel") + .outline() + .small() + .label(self.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.close_template_draft(); + cx.notify(); + }); + } + }), + ) + .when(editing, |el| { + el.child( + Button::new("tpl-delete") + .danger() + .small() + .label(self.t("delete")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if let Some(id) = this.editing_template_id.clone() { + this.prefs.templates.retain(|x| x.id != id); + this.persist(cx); + } + this.close_template_draft(); + cx.notify(); + }); + } + }), + ) + }), + ) + .child( + Button::new("tpl-save") + .primary() + .small() + .label(self.t("save")) + .disabled(!can_save) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.save_template_draft(cx); + }); + } + }), + ), + ), + ) + .into_any_element() + } + + fn open_template_draft( + &mut self, + template: Option<&crate::prefs::TerminalTemplatePref>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(t) = template { + self.editing_template_id = Some(t.id.clone()); + self.template_name.update(cx, |s, cx| { + s.set_value(t.name.clone(), window, cx); + }); + self.template_cmds.update(cx, |s, cx| { + s.set_value(t.commands.join("\n"), window, cx); + }); + } else { + self.editing_template_id = None; + self.template_name.update(cx, |s, cx| s.set_value("", window, cx)); + self.template_cmds.update(cx, |s, cx| s.set_value("", window, cx)); + } + self.template_dialog_open = true; + cx.notify(); + } + + fn close_template_draft(&mut self) { + self.template_dialog_open = false; + self.editing_template_id = None; + } + + fn save_template_draft(&mut self, cx: &mut Context) { + let name = self.template_name.read(cx).value().to_string(); + let commands_text = self.template_cmds.read(cx).value().to_string(); + let Some((name, commands)) = leftover_normalize_global_template(&name, &commands_text) else { + return; + }; + crate::prefs::upsert_template( + &mut self.prefs.templates, + self.editing_template_id.as_deref(), + name, + String::new(), + String::new(), + commands, + ); + self.close_template_draft(); + self.persist(cx); + cx.notify(); + } + + fn notification(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .gap_4() + .max_w(px(448.)) + .child(switch_row( + "notif", + self.t("notificationEnabled"), + self.t("notificationEnabledDescription"), + self.prefs.notifications, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.notifications = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .child(div().flex_1().child(field_label(&self.t("notificationSound")))) + .child( + Button::new("preview-sound") + .ghost() + .xsmall() + .icon(IconName::Bell) + .disabled(!self.prefs.notifications || self.prefs.notification_sound.is_empty() || self.sounds.is_empty()) + .on_click({ + let sound = self.prefs.notification_sound.clone(); + move |_, _, _| { + let _ = crate::platform::play_system_sound(&sound); + } + }), + ), + ) + .child(if self.sounds.is_empty() { + v_flex() + .gap_1() + .child(leftover_select( + "sound", + &self.t("soundPickerUnavailable"), + false, + true, + vec![("".into(), self.t("soundPickerUnavailable"))], + view.clone(), + cx.theme().clone(), + |_, _, _| {}, + )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("soundPickerUnavailableDescription")), + ) + .into_any_element() + } else { + leftover_select( + "sound", + if self.prefs.notification_sound.is_empty() { + self.t("notificationSoundNone") + } else { + self.prefs.notification_sound.clone() + }, + self.open_select.as_deref() == Some("sound"), + !self.prefs.notifications, + std::iter::once(("".into(), self.t("notificationSoundNone"))) + .chain(self.sounds.iter().cloned().map(|name| (name.clone(), name))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.notification_sound = value; + this.persist(cx); + }, + ) + .into_any_element() + }) + } + + fn topbar(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let all = ["github-desktop", "editor", "terminal", "pr-status"]; + v_flex() + .gap_4() + .child(div().font_semibold().child(self.t("topbarPreview"))) + .child( + v_flex().gap_1().children(self.prefs.topbar_controls.iter().cloned().map(|id| { + let view = view.clone(); + h_flex() + .id(crate::ui::eid(format!("tb-order-{id}"))) + .gap_2() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("⠿"), + ) + .on_drag( + crate::ui::TopbarDrag { id: id.clone() }, + |info, _, _, cx| { + cx.new(|_| crate::ui::DragGhost { + label: info.id.clone(), + }) + }, + ) + .on_drop({ + let view = view.clone(); + let target = id.clone(); + move |drag: &crate::ui::TopbarDrag, _, cx| { + view.update(cx, |this, cx| { + if let Some(to) = this.prefs.topbar_controls.iter().position(|x| x == &target) { + crate::prefs::move_topbar_control(&mut this.prefs.topbar_controls, &drag.id, to); + this.persist(cx); + cx.notify(); + } + }); + } + }) + .child(div().flex_1().child(match id.as_str() { + "github-desktop" => self.t("topbarGithubDesktop"), + "editor" => self.t("topbarEditor"), + "terminal" => self.t("topbarTerminal"), + "pr-status" => self.t("topbarPrStatus"), + _ => id.clone(), + })) + })), + ) + .when(self.prefs.topbar_controls.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("topbarNoControls")), + ) + }) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDragHint"))) + .child( + v_flex() + .id("tb-available-drop") + .gap_2() + .w_full() + .min_h(px(40.)) + .rounded_md() + .on_drop({ + let view = view.clone(); + move |drag: &crate::ui::TopbarDrag, _, cx| { + view.update(cx, |this, cx| { + this.prefs.topbar_controls.retain(|x| x != &drag.id); + this.persist(cx); + cx.notify(); + }); + } + }) + .child(div().font_semibold().child(self.t("topbarAvailable"))), + ) + .when(all.iter().all(|id| self.prefs.topbar_controls.iter().any(|x| x == *id)), |el| { + el.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("topbarAllControlsActive")), + ) + }) + .children(all.into_iter().map(|id| { + let on = self.prefs.topbar_controls.iter().any(|x| x == id); + switch_row( + id, + match id { + "github-desktop" => self.t("topbarGithubDesktop"), + "editor" => self.t("topbarEditor"), + "terminal" => self.t("topbarTerminal"), + _ => self.t("topbarPrStatus"), + }, + String::new(), + on, + { + let view = view.clone(); + let id = id.to_string(); + move |val, cx| { + view.update(cx, |this, cx| { + if val { + if !this.prefs.topbar_controls.contains(&id) { + this.prefs.topbar_controls.push(id.clone()); + } + } else { + this.prefs.topbar_controls.retain(|x| x != &id); + } + this.persist(cx); + cx.notify(); + }); + } + }, + ) + })) + .child(field_label(&self.t("topbarEditorApp"))) + .child(if crate::platform::installed_editors().is_empty() { + div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() + } else { + let editors = crate::platform::installed_editors(); + let current = crate::state::leftover_configured_app( + &self.prefs.editor_app, + crate::state::LEFTOVER_EDITOR_APP_IDS, + &editors.iter().map(|app| app.id).collect::>(), + ) + .unwrap_or(editors[0].id); + leftover_select( + "editor-app", + &self.t(crate::state::leftover_launch_app_i18n(current)), + self.open_select.as_deref() == Some("editor-app"), + false, + editors + .iter() + .map(|app| (app.id.to_string(), self.t(crate::state::leftover_launch_app_i18n(app.id)))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.editor_app = value; + this.persist(cx); + }, + ) + .into_any_element() + }) + .child(field_label(&self.t("topbarTerminalApp"))) + .child(if crate::platform::installed_terminals().is_empty() { + div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() + } else { + let terminals = crate::platform::installed_terminals(); + let current = crate::state::leftover_configured_app( + &self.prefs.terminal_app, + crate::state::LEFTOVER_TERMINAL_APP_IDS, + &terminals.iter().map(|app| app.id).collect::>(), + ) + .unwrap_or(terminals[0].id); + leftover_select( + "terminal-app", + &self.t(crate::state::leftover_launch_app_i18n(current)), + self.open_select.as_deref() == Some("terminal-app"), + false, + terminals + .iter() + .map(|app| (app.id.to_string(), self.t(crate::state::leftover_launch_app_i18n(app.id)))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_app = value; + this.persist(cx); + }, + ) + .into_any_element() + }) + .child( + Button::new("reset-topbar") + .label(self.t("topbarResetDefaults")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.topbar_controls = crate::prefs::default_topbar_controls(); + this.persist(cx); + cx.notify(); + }); + } + }), + ) + } + + fn about(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let badge = crate::state::leftover_update_badge( + self.latest_version.is_some(), + self.update_status == self.t("updateNotAvailableDescription"), + ); + v_flex() + .gap_8() + .max_w(px(crate::state::leftover_about_max_width())) + .child( + h_flex() + .gap_5() + .items_center() + .child( + img(std::sync::Arc::new(Image::from_bytes( + ImageFormat::Png, + include_bytes!("../../assets/app-icon.png").to_vec(), + ))) + .id("about-app-icon") + .size(px(80.)) + .rounded_lg(), + ) + .child( + v_flex() + .gap(px(6.)) + .min_w_0() + .child( + h_flex() + .gap(px(10.)) + .items_center() + .child(div().text_xl().font_semibold().child("2code")) + .child( + Button::new("ver") + .xsmall() + .label(crate::i18n::tf( + self.locale, + "currentVersion", + &[("version", env!("CARGO_PKG_VERSION"))], + )) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + env!("CARGO_PKG_VERSION").into(), + )); + view.update(cx, |this, cx| { + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.data.push_toast( + crate::state::ToastKind::Success, + app.t("aboutVersionCopied"), + "", + ); + cx.notify(); + }); + } + }); + } + }), + ), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(self.t("aboutAppDescription")), + ), + ), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("repo") + .outline() + .small() + .label(self.t("repository")) + .on_click(|_, _, _| { + let _ = open::that("https://github.com/AkaraChen/2code"); + }), + ) + .child( + Button::new("rel") + .outline() + .small() + .label(self.t("releases")) + .on_click(|_, _, _| { + let _ = open::that("https://github.com/AkaraChen/2code/releases"); + }), + ), + ) + .child( + v_flex() + .id("about-update-card") + .w_full() + .overflow_hidden() + .rounded_xl() + .border_1() + .border_color(theme.border) + .child( + h_flex() + .min_h(px(44.)) + .w_full() + .px_4() + .py_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted.opacity(0.4)) + .child(div().text_sm().font_medium().child(self.t("update"))) + .when(badge != crate::state::LeftoverUpdateBadge::Hidden, |el| { + el.child( + div() + .px_2() + .py(px(2.)) + .rounded_full() + .when(badge == crate::state::LeftoverUpdateBadge::Available, |el| { + el.bg(theme.primary).text_color(theme.primary_foreground) + }) + .when(badge == crate::state::LeftoverUpdateBadge::NotAvailable, |el| { + el.border_1().border_color(theme.border).text_color(theme.muted_foreground) + }) + .child(div().text_xs().child(if badge == crate::state::LeftoverUpdateBadge::Available { + if let Some(ver) = &self.latest_version { + crate::i18n::tf( + self.locale, + "updateAvailableTitle", + &[("version", ver)], + ) + } else { + self.t("updateAvailableTitle") + } + } else { + self.t("updateNotAvailableTitle") + })), + ) + }), + ) + .child( + v_flex() + .gap_4() + .p_4() + .child(switch_row( + "beta", + self.t("acceptBetaUpdates"), + self.t("acceptBetaUpdatesDescription"), + self.prefs.accept_beta, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.accept_beta = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child( + v_flex() + .gap_1() + .child( + div() + .text_sm() + .when(self.latest_version.is_none(), |el| { + el.text_color(theme.muted_foreground) + }) + .child(self.update_status.clone()), + ) + .when_some(self.released_at.clone(), |el, date| { + el.child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + self.locale, + "updateReleasedAt", + &[("date", &crate::updater::format_release_date_display(&date, self.locale))], + )), + ) + }), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("check-upd") + .outline() + .small() + .label(self.t("checkForUpdates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + let result = + crate::updater::check_for_update(this.prefs.accept_beta); + match &result { + Ok(info) if info.available => { + this.latest_version = Some(info.latest_version.clone()); + this.released_at = info.released_at.clone(); + this.latest_url = info.html_url.clone(); + this.update_status = crate::i18n::tf( + this.locale, + "updateAvailableDescription", + &[ + ("currentVersion", env!("CARGO_PKG_VERSION")), + ("version", &info.latest_version), + ], + ); + } + Ok(_) => { + this.latest_version = None; + this.released_at = None; + this.update_status = + this.t("updateNotAvailableDescription"); + } + Err(err) => { + this.update_status = format!( + "{}: {err}", + this.t("updateCheckFailedTitle") + ); + } + } + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.apply_update_result(result, false); + cx.notify(); + }); + } + cx.notify(); + }); + } + }), + ) + .when(self.latest_version.is_some(), |el| { + el.child( + Button::new("open-upd") + .small() + .label(if let Some(ver) = &self.latest_version { + crate::i18n::tf( + self.locale, + "installUpdate", + &[("version", ver)], + ) + } else { + self.t("openUpdatePage") + }) + .on_click({ + let view = view.clone(); + let url = self.latest_url.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + match crate::updater::download_and_install(this.prefs.accept_beta) { + Ok(path) => { + this.update_status = path; + cx.quit(); + } + Err(err) => { + this.update_status = format!( + "{}: {err}", + this.t("updateInstallFailedTitle") + ); + let _ = open::that(&url); + } + } + cx.notify(); + }); + } + }), + ) + }), + ), + ), + ) + .child(div().text_sm().font_medium().child(self.t("contributors"))) + .child( + h_flex() + .gap_3() + .items_center() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .child( + img("https://github.com/AkaraChen.png?size=96") + .id("about-maintainer-avatar") + .size(px(36.)) + .rounded_full(), + ) + .child( + v_flex() + .child(div().text_sm().font_medium().child("AkaraChen")) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(self.t("primaryContributorDescription")), + ), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::state::leftover_about_copyright( + crate::timefmt::leftover_utc_year(crate::timefmt::unix_now_secs()), + )), + ) + } +} + +fn set_tab( + _id: &'static str, + icon: IconName, + label: String, + selected: bool, + tab: SettingsTab, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + Tab::new() + .icon(icon) + .label(label) + .selected(selected) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.tab = tab; + this.open_select = None; + this.template_dialog_open = false; + cx.notify(); + }); + }) +} + +fn field_label(text: &str) -> impl IntoElement { + div().font_medium().text_sm().child(text.to_string()) +} + +fn theme_label_row( + label: &str, + preview_id: &'static str, + preview_label: String, + on_preview: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .child(field_label(label)) + .child( + Button::new(preview_id) + .ghost() + .xsmall() + .icon(IconName::Eye) + .tooltip(preview_label) + .on_click(move |_, _, cx| on_preview(cx)), + ) +} + +fn leftover_select( + id: &str, + current: impl Into, + open: bool, + disabled: bool, + options: Vec<(String, String)>, + view: gpui::Entity, + theme: gpui_component::Theme, + on_pick: impl Fn(&mut SettingsView, String, &mut Context) + Clone + 'static, +) -> impl IntoElement { + let current = current.into(); + let selected_label = current.clone(); + let height = crate::state::leftover_native_select_height(); + div() + .id(crate::ui::eid(format!("sel-{id}"))) + .relative() + .w_full() + .child( + h_flex() + .id(crate::ui::eid(format!("sel-btn-{id}"))) + .h(px(height)) + .w_full() + .px_3() + .gap_2() + .items_center() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .when(disabled, |el| el.opacity(0.5)) + .on_click({ + let view = view.clone(); + let id = id.to_string(); + move |_, _, cx| { + if disabled { + return; + } + view.update(cx, |this, cx| { + this.open_select = if this.open_select.as_deref() == Some(id.as_str()) { + None + } else { + Some(id.clone()) + }; + cx.notify(); + }); + } + }) + .child(div().flex_1().min_w_0().text_sm().child(current)) + .child(Icon::new(IconName::ChevronDown).w(px(14.))), + ) + .when(open && !disabled, |el| { + el.child( + div() + .id(crate::ui::eid(format!("sel-menu-{id}"))) + .absolute() + .top(px(height + 4.)) + .left_0() + .right_0() + .max_h(px(240.)) + .overflow_y_scroll() + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .children(options.into_iter().map(|(value, label)| { + let selected = label == selected_label; + div() + .id(crate::ui::eid(format!("sel-{id}-{value}"))) + .w_full() + .px_2() + .py(px(6.)) + .rounded_md() + .text_sm() + .when(selected, |row| row.bg(theme.muted)) + .hover(|row| row.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let on_pick = on_pick.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + on_pick(this, value.clone(), cx); + this.open_select = None; + cx.notify(); + }); + } + }) + .child(label) + })), + ) + }) +} + +fn leftover_check_row( + id: &'static str, + label: String, + checked: bool, + on: impl Fn(bool, &mut App) + 'static, +) -> impl IntoElement { + h_flex() + .gap_2() + .items_center() + .child(Checkbox::new(id).checked(checked).on_click(move |val, _, cx| on(*val, cx))) + .child(div().text_sm().child(label)) +} + +fn choice( + id: impl Into, + label: &str, + selected: bool, + on: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + Button::new(id.into()) + .small() + .selected(selected) + .label(label.to_string()) + .on_click(move |_, _, cx| on(cx)) +} + +fn switch_row( + id: &'static str, + title: String, + desc: String, + checked: bool, + on: impl Fn(bool, &mut App) + 'static, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .gap_4() + .child( + v_flex() + .child(div().font_medium().text_sm().child(title)) + .when(!desc.is_empty(), |el| { + el.child(div().text_xs().child(desc)) + }), + ) + .child(Switch::new(id).checked(checked).on_click(move |val, _, cx| on(*val, cx))) +} + +fn preview_line(prompt_color: u32, prompt: &str, fg: u32, cmd: &str) -> impl IntoElement { + h_flex() + .gap_1() + .child(div().text_color(gpui::rgb(prompt_color)).child(prompt.to_string())) + .child(div().text_color(gpui::rgb(fg)).child(cmd.to_string())) +} diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs new file mode 100644 index 00000000..ede45590 --- /dev/null +++ b/src-gpui/src/ui/shell.rs @@ -0,0 +1,258 @@ +use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, MouseButton, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::state::{Route, ToastAction, ToastKind}; +use crate::ui::{debug, dialogs, palette, settings, sidebar, workspace}; + +pub fn leftover_toast_icon(kind: ToastKind) -> IconName { + match kind { + ToastKind::Success => IconName::CircleCheck, + ToastKind::Info => IconName::Info, + ToastKind::Warning => IconName::TriangleAlert, + ToastKind::Error => IconName::CircleX, + } +} + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + div() + .flex() + .size_full() + .bg(theme.background) + .text_color(theme.foreground) + .on_mouse_move({ + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + let x = f32::from(ev.position.x); + let mut changed = false; + if let Some((start_x, start_w)) = app.data.overlay.sidebar_drag { + app.data.prefs.sidebar_width = (start_w + x - start_x).clamp(220.0, 420.0); + changed = true; + } + if let Some((start_x, start_w)) = app.data.overlay.profile_sidebar_drag { + app.data.prefs.profile_sidebar_width = (start_w + x - start_x).clamp(180.0, 560.0); + changed = true; + } + if changed { + cx.notify(); + } + }); + } + }) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, window, cx| { + view.update(cx, |app, cx| { + if let Some(profile) = app.data.overlay.sidebar_resize_focus { + if app.nudge_sidebar(profile, ev.keystroke.key.as_str()) { + cx.notify(); + return; + } + } + if app.handle_overlay_key(ev.keystroke.key.as_str(), ev.keystroke.modifiers.shift, window, cx) { + cx.notify(); + } + }); + } + }) + .on_mouse_up(MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.sidebar_drag.take().is_some() + || app.data.overlay.profile_sidebar_drag.take().is_some() + { + app.persist_prefs(); + cx.notify(); + } + }); + } + }) + .child(sidebar::render(app, window, cx)) + .child( + div() + .id("main-column") + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .bg(theme.background) + .child(match app.data.route { + Route::Home => home_or_empty(app, window, cx), + Route::Workspace => workspace::render(app, window, cx).into_any_element(), + }) + .when(cfg!(target_os = "windows"), |el| el.child(window_controls(window))), + ) + .child(dialogs::render(app, window, cx)) + .child(palette::render(app, window, cx)) + .child(crate::ui::git::render_diff_dialog(app, window, cx)) + .child(debug::render_fab(app, window, cx)) + .child(toasts(app, cx)) +} + +fn home_or_empty(app: &mut AppView, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { + crate::ui::home::render(app, window, cx).into_any_element() +} + +fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + div() + .id("toasts") + .absolute() + .bottom_4() + .right_4() + .flex() + .flex_col() + .gap_2() + .children(app.data.toasts.iter().rev().take(4).map(|toast| { + let action = toast.action; + let icon = leftover_toast_icon(toast.kind); + h_flex() + .id(crate::ui::eid(format!("toast-{}", toast.id))) + .w(px(356.)) + .gap_2() + .items_start() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .on_click({ + let view = view.clone(); + let id = toast.id; + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.toasts.retain(|t| t.id != id); + cx.notify(); + }); + } + }) + .child(Icon::new(icon).w(px(16.)).h(px(16.))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_sm().font_semibold().child(toast.title.clone())) + .when(!toast.body.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(toast.body.clone()), + ) + }) + .when(action == Some(ToastAction::OpenAbout), |el| { + el.child( + Button::new(crate::ui::eid(format!("toast-act-{}", toast.id))) + .xsmall() + .label(app.t("openUpdatePage")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_update_page(app, window, cx); + }); + } + }), + ) + }), + ) + })) +} + +fn window_controls(window: &Window) -> impl IntoElement { + let maximized = window.is_maximized(); + div() + .id("win-controls") + .absolute() + .top_0() + .right_0() + .h(px(28.)) + .flex() + .child(win_btn("min", "–", "Minimize", false, |window| { + window.minimize_window() + })) + .child(win_btn( + "max", + if maximized { "❐" } else { "□" }, + if maximized { "Restore" } else { "Maximize" }, + false, + |window| window.zoom_window(), + )) + .child(win_btn("close", "×", "Close", true, |window| window.remove_window())) +} + +fn win_btn( + id: &'static str, + label: &'static str, + tip: &'static str, + close: bool, + on: impl Fn(&mut Window) + 'static, +) -> impl IntoElement { + div() + .id(id) + .h(px(28.)) + .w(px(36.)) + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(gpui::hsla(0., 0., 0.45, 1.)) + .tooltip(crate::ui::tip(tip)) + .on_click(move |_, window, _| on(window)) + .hover(|el| { + if close { + el.bg(rgb(0xc42b1c)).text_color(gpui::white()) + } else { + el.bg(gpui::hsla(0., 0., 0.97, 1.)) + } + }) + .active(|el| { + if close { + el.bg(rgb(0xb32717)).text_color(gpui::white()) + } else { + el.bg(gpui::hsla(0., 0., 0.97, 1.)) + } + }) + .child(label) +} + +#[cfg(test)] +mod tests { + use super::leftover_toast_icon; + use crate::state::ToastKind; + use gpui_component::IconName; + + fn icon_path(name: IconName) -> String { + gpui_component::IconNamed::path(name).to_string() + } + + #[test] + fn leftover_toast_icons_follow_sonner() { + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Success)), + icon_path(IconName::CircleCheck) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Info)), + icon_path(IconName::Info) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Warning)), + icon_path(IconName::TriangleAlert) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Error)), + icon_path(IconName::CircleX) + ); + } +} diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs new file mode 100644 index 00000000..40331de1 --- /dev/null +++ b/src-gpui/src/ui/sidebar.rs @@ -0,0 +1,982 @@ +use std::time::Duration; + +use gpui::{div, img, prelude::*, px, Animation, AnimationExt, Context, KeyDownEvent, MouseButton, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; + +use crate::app::AppView; +use crate::backend; +use crate::state::{ + leftover_profile_sublist_open, leftover_project_row_active, AgentStatus, ContextMenu, DialogKind, Route, + SidebarNavItem, +}; +use crate::ui::{leftover_branch_glyph, settings}; + +#[derive(Clone)] +struct SidebarDrag { + id: String, + name: String, +} + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if app.data.prefs.sidebar_collapsed { + return div().id("app-sidebar-collapsed").into_any_element(); + } + + let theme = cx.theme().clone(); + let width = app.data.prefs.sidebar_width.clamp(220.0, 420.0); + let view = cx.entity(); + let has_projects = !app.data.projects.is_empty(); + let pad_top = if cfg!(target_os = "macos") { px(32.) } else { px(8.) }; + + div() + .id("app-sidebar-wrap") + .relative() + .h_full() + .child(onboarding_popover(app, width, cx)) + .child( + v_flex() + .id("app-sidebar") + .w(px(width)) + .h_full() + .bg(theme.sidebar) + .border_r_1() + .border_color(theme.border) + .tab_index(0) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + view.update(cx, |app, cx| { + if app.handle_sidebar_key(ev.keystroke.key.as_str()) { + cx.notify(); + } + }); + } + }) + .child( + h_flex() + .id("sidebar-header") + .w_full() + .pt(pad_top) + .px_3() + .pb_2() + .justify_between() + .child(div().font_semibold().text_sm().child("2Code")) + .child( + Button::new("collapse-sidebar") + .ghost() + .xsmall() + .icon(IconName::PanelLeftClose) + .tooltip(app.t("collapseSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = true; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ), + ) + .child( + v_flex() + .id("sidebar-content") + .flex_1() + .min_h_0() + .px_2() + .py_1() + .gap_1() + .overflow_hidden() + .when(!has_projects, |el| { + el.child(leftover_home_row( + app.t("home"), + app.data.route == Route::Home + || app.data.overlay.sidebar_nav == Some(SidebarNavItem::Home), + { + let view = view.clone(); + move |cx| { + view.update(cx, |app, cx| { + app.data.route = Route::Home; + cx.notify(); + }); + } + }, + cx, + )) + }) + .when_some(app.data.sidebar_error.clone(), |el, err| { + el.child( + v_flex() + .p_4() + .gap_2() + .child( + div() + .text_color(theme.danger) + .font_semibold() + .child(app.t("somethingWentWrong")), + ) + .child(div().text_xs().child(err)) + .child( + Button::new("sidebar-retry") + .xsmall() + .label(app.t("tryAgain")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.reload_projects(); + cx.notify(); + }); + } + }), + ), + ) + }) + .child(project_sections(app, window, cx)), + ) + .child( + h_flex() + .id("sidebar-footer") + .w_full() + .px_2() + .py_2() + .border_t_1() + .border_color(theme.border) + .child( + Button::new("open-settings") + .ghost() + .small() + .icon(IconName::Settings) + .label(app.t("settings")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_settings_window(app, window, cx); + }); + } + }), + ), + ), + ) + .child(resize_handle( + "app-sidebar-resize", + view, + false, + app.data.overlay.sidebar_resize_focus == Some(false), + "Resize sidebar".to_string(), + )) + .into_any_element() +} + +fn drop_zone( + id: impl Into, + label: String, + border: gpui::Hsla, + muted: gpui::Hsla, + on_drop: impl Fn(&SidebarDrag, &mut gpui::App) + 'static, +) -> impl IntoElement { + div() + .id(id.into()) + .mt_2() + .px_2() + .py_3() + .rounded_md() + .border_1() + .border_dashed() + .border_color(border) + .text_xs() + .text_color(muted) + .child(label) + .on_drop(move |drag: &SidebarDrag, _, cx| on_drop(drag, cx)) +} + +pub(crate) fn resize_handle( + id: &'static str, + view: gpui::Entity, + profile: bool, + focused: bool, + label: String, +) -> impl IntoElement { + div() + .id(id) + .absolute() + .top_0() + .right_0() + .w(px(8.)) + .h_full() + .tab_index(0) + .cursor(gpui::CursorStyle::ResizeColumn) + .tooltip(crate::ui::tip(label)) + .when(focused, |el| el.bg(gpui::hsla(0., 0., 0.5, 0.3))) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + let start = f32::from(ev.position.x); + app.data.overlay.sidebar_resize_focus = Some(profile); + if profile { + app.data.overlay.profile_sidebar_drag = Some((start, app.data.prefs.profile_sidebar_width)); + } else { + app.data.overlay.sidebar_drag = Some((start, app.data.prefs.sidebar_width)); + } + cx.notify(); + }); + } + }) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + view.update(cx, |app, cx| { + if app.nudge_sidebar(profile, ev.keystroke.key.as_str()) { + cx.notify(); + } + }); + } + }) +} + +fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let pinned: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.pinned_at.is_some()) + .cloned() + .collect(); + let ungrouped: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.pinned_at.is_none() && p.group_id.is_none()) + .cloned() + .collect(); + let groups = app.data.groups.clone(); + + v_flex() + .w_full() + .gap_2() + .when(!pinned.is_empty() || app.data.overlay.sort_mode, |el| { + el.child(section_label(&app.t("pinnedProjects"))) + .child( + v_flex() + .gap_1() + .children(pinned.into_iter().map(|p| project_row(app, &p, cx))), + ) + .when(app.data.overlay.sort_mode, |el| { + el.child(drop_zone( + "sidebar-drop-pin", + app.t("dropHereToPin"), + theme.border, + theme.muted_foreground, + { + let view = view.clone(); + move |drag: &SidebarDrag, cx| { + view.update(cx, |app, cx| { + app.set_project_pinned(&drag.id, true); + cx.notify(); + }); + } + }, + )) + }) + }) + .child( + h_flex() + .w_full() + .justify_between() + .px_1() + .child(section_label(&app.t("sidebarProjectsSection"))) + .child( + h_flex() + .gap_1() + .child( + Button::new("sort-mode") + .ghost() + .xsmall() + .icon(if app.data.overlay.sort_mode { + IconName::Check + } else { + IconName::Replace + }) + .tooltip(if app.data.overlay.sort_mode { + app.t("doneEditingProjectOrder") + } else { + app.t("editProjectOrder") + }) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.sort_mode = !app.data.overlay.sort_mode; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("add-project-button") + .ghost() + .xsmall() + .icon(IconName::Plus) + .tooltip(app.t("newProject")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProject); + app.data.overlay.dialog_folder = None; + app.data.overlay.dialog_error = None; + app.data.overlay.onboarding = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + v_flex() + .gap_1() + .children(groups.into_iter().map(|group| { + let collapsed = app.data.prefs.collapsed_groups.contains(&group.id); + let members: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.group_id.as_deref() == Some(group.id.as_str()) && p.pinned_at.is_none()) + .cloned() + .collect(); + let count = members.len(); + let gid = group.id.clone(); + v_flex() + .id(crate::ui::eid(format!("group-{}", group.id))) + .child( + h_flex() + .id(crate::ui::eid(format!("group-h-{}", group.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let gid = gid.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.sort_mode { + return; + } + if let Some(ix) = + app.data.prefs.collapsed_groups.iter().position(|x| x == &gid) + { + app.data.prefs.collapsed_groups.remove(ix); + } else { + app.data.prefs.collapsed_groups.push(gid.clone()); + } + app.persist_prefs(); + cx.notify(); + }); + } + }) + .when(app.data.overlay.sort_mode, |el| { + el.on_drop({ + let view = view.clone(); + let gid = gid.clone(); + move |drag: &SidebarDrag, _, cx| { + view.update(cx, |app, cx| { + app.assign_project_to_group(&drag.id, Some(gid.clone())); + cx.notify(); + }); + } + }) + }) + .when(app.data.overlay.sort_mode, |el| { + el.child(div().text_xs().text_color(theme.muted_foreground).child("⠿")) + }) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .w(px(12.)), + ) + .child( + Button::new(crate::ui::eid(format!("group-toggle-{}", group.id))) + .ghost() + .small() + .label(group.name.clone()) + .tooltip(crate::i18n::tf( + app.data.locale, + "toggleProjectGroup", + &[("name", &group.name)], + )) + .on_click({ + let view = view.clone(); + let gid = gid.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.sort_mode { + return; + } + if let Some(ix) = + app.data.prefs.collapsed_groups.iter().position(|x| x == &gid) + { + app.data.prefs.collapsed_groups.remove(ix); + } else { + app.data.prefs.collapsed_groups.push(gid.clone()); + } + app.persist_prefs(); + cx.notify(); + }); + } + }), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(count.to_string()), + ), + ) + .when(app.data.overlay.sort_mode, |el| { + el.child(drop_zone( + crate::ui::eid(format!("sidebar-drop-group-{}", group.id)), + app.t("dropProjectIntoFolder"), + theme.border, + theme.muted_foreground, + { + let view = view.clone(); + let gid = gid.clone(); + move |drag: &SidebarDrag, cx| { + view.update(cx, |app, cx| { + app.assign_project_to_group(&drag.id, Some(gid.clone())); + cx.notify(); + }); + } + }, + )) + }) + .when(!collapsed, |el| { + el.child( + v_flex() + .pl_3() + .children(members.into_iter().map(|p| project_row(app, &p, cx))), + ) + }) + })) + .children(ungrouped.into_iter().map(|p| project_row(app, &p, cx))), + ) + .when(app.data.overlay.sort_mode, |el| { + el.child(drop_zone( + "sidebar-drop-unpin", + app.t("dropHereToUnpinOrMoveOut"), + theme.border, + theme.muted_foreground, + { + let view = view.clone(); + move |drag: &SidebarDrag, cx| { + view.update(cx, |app, cx| { + app.drop_sidebar_project(&drag.id, None, true); + cx.notify(); + }); + } + }, + )) + }) +} + +fn onboarding_popover(app: &AppView, sidebar_width: f32, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.onboarding || has_any_projects(app) { + return div().id("onboarding-hidden").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + v_flex() + .id("onboarding-tour") + .absolute() + .top(px(leftover_onboarding_top(cfg!(target_os = "macos")))) + .left(px(sidebar_width + 8.)) + .w(px(260.)) + .p_3() + .gap_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .shadow_lg() + .child( + h_flex() + .justify_between() + .child(div().font_semibold().text_sm().child(app.t("onboardingTourTitle"))) + .child( + Button::new("onboarding-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.onboarding = false; + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("onboardingTourDesc")), + ) + .into_any_element() +} + +pub fn leftover_onboarding_top(macos_overlay: bool) -> f32 { + let chrome = if macos_overlay { 52. } else { 28. }; + chrome + 36. + 32. +} + +fn has_any_projects(app: &AppView) -> bool { + !app.data.projects.is_empty() +} + +fn section_label(text: &str) -> impl IntoElement { + div().px_2().pt_2().text_xs().font_medium().child(text.to_string()) +} + +fn project_row( + app: &AppView, + project: &model::project::ProjectWithProfiles, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let nav_focus = app.data.overlay.sidebar_nav.as_ref() == Some(&SidebarNavItem::Project(project.id.clone())); + let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).cloned().collect(); + let has_extras = !extras.is_empty(); + let collapsed = app.data.overlay.collapsed_projects.contains(&project.id); + let expanded = leftover_profile_sublist_open(has_extras, collapsed); + let default_profile = project.profiles.iter().find(|p| p.is_default).map(|p| p.id.clone()); + let default_current = default_profile + .as_deref() + .is_some_and(|id| app.data.current_profile.as_deref() == Some(id)); + let row_active = leftover_project_row_active(has_extras, default_current); + let agent = app.agent_for_project(&project.id); + let letter = project + .name + .chars() + .next() + .map(|c| c.to_ascii_uppercase()) + .unwrap_or('?'); + let id = project.id.clone(); + + v_flex() + .id(crate::ui::eid(format!("proj-{}", project.id))) + .child( + h_flex() + .id(crate::ui::eid(format!("proj-row-{}", project.id))) + .w_full() + .px_2() + .py_1() + .rounded_md() + .gap_2() + .when(row_active, |el| el.bg(theme.sidebar_accent)) + .when(nav_focus && !row_active, |el| el.border_1().border_color(theme.border)) + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + let default_profile = default_profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.sort_mode { + return; + } + app.data.overlay.sidebar_nav = Some(SidebarNavItem::Project(id.clone())); + if let Some(pid) = default_profile.clone() { + app.open_profile(&id, &pid); + } + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, _| { + if app.data.overlay.sort_mode { + app.data.overlay.drag_project = Some(id.clone()); + } + }); + } + }) + .on_mouse_up(MouseButton::Left, { + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(dragged) = app.data.overlay.drag_project.take() { + if dragged != id { + app.drop_sidebar_project(&dragged, Some(&id), false); + cx.notify(); + } + } + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let id = id.clone(); + move |ev, window, cx| { + view.update(cx, |app, cx| { + app.open_project_menu(id.clone(), ev.position.x.into(), ev.position.y.into(), window, cx); + cx.notify(); + }); + } + }) + .when(app.data.overlay.sort_mode, |el| { + el.cursor(gpui::CursorStyle::OpenHand) + .opacity(0.95) + .child(div().text_xs().text_color(theme.muted_foreground).child("⠿")) + .on_drag( + SidebarDrag { + id: id.clone(), + name: project.name.clone(), + }, + |info, _, _, cx| { + cx.new(|_| crate::ui::DragGhost { + label: info.name.clone(), + }) + }, + ) + .on_drop({ + let view = view.clone(); + let target = id.clone(); + move |drag: &SidebarDrag, _, cx| { + view.update(cx, |app, cx| { + if drag.id != target { + app.drop_sidebar_project(&drag.id, Some(&target), false); + cx.notify(); + } + }); + } + }) + }) + .when(app.data.prefs.show_avatars, |el| { + el.child( + div() + .size(px(16.)) + .rounded_md() + .bg(theme.sidebar_accent) + .flex() + .items_center() + .justify_center() + .overflow_hidden() + .text_xs() + .child(if let Some(url) = app.data.avatars.get(&project.id).cloned() { + img(url).size(px(16.)).into_any_element() + } else { + div().child(letter.to_string()).into_any_element() + }), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_medium() + .whitespace_nowrap() + .overflow_hidden() + .child(project.name.clone()), + ) + .when(app.data.overlay.sort_mode, |el| { + let pinned = project.pinned_at.is_some(); + el.child( + Button::new(crate::ui::eid(format!("up-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.move_sidebar_project(&id, -1); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("down-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.move_sidebar_project(&id, 1); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("pin-{}", project.id))) + .ghost() + .xsmall() + .icon(if pinned { IconName::Star } else { IconName::StarOff }) + .tooltip(if pinned { + app.t("unpinProject") + } else { + app.t("pinProject") + }) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.set_project_pinned(&id, !pinned); + cx.notify(); + }); + } + }), + ) + }) + .when(!app.data.overlay.sort_mode && !has_extras, |el| { + el.child(agent_dot(agent)).child( + Button::new(crate::ui::eid(format!("add-prof-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::Plus) + .tooltip(app.t("createProfile")) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProfile); + app.data.overlay.dialog_project = Some(id.clone()); + cx.notify(); + }); + } + }), + ) + }) + .when(!app.data.overlay.sort_mode && has_extras, |el| { + el.child( + Button::new(crate::ui::eid(format!("expand-prof-{}", project.id))) + .ghost() + .xsmall() + .icon(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .tooltip(crate::i18n::tf( + app.data.locale, + "toggleProjectGroup", + &[("name", &project.name)], + )) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.collapsed_projects.contains(&id) { + app.data.overlay.collapsed_projects.remove(&id); + } else { + app.data.overlay.collapsed_projects.insert(id.clone()); + } + cx.notify(); + }); + } + }), + ) + }), + ) + .when(expanded, |el| { + el.child( + v_flex() + .pl_6() + .gap_0() + .when(project.profiles.is_empty(), |el| { + el.child( + div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("noProfiles")), + ) + }) + .children(project.profiles.iter().map(|profile| { + let pid = profile.id.clone(); + let proj = project.id.clone(); + let selected = app.data.current_profile.as_deref() == Some(profile.id.as_str()); + let nav_focus = app.data.overlay.sidebar_nav.as_ref() + == Some(&SidebarNavItem::Profile { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + }); + let label = if profile.is_default { + app.t("defaultProfile") + } else { + profile.branch_name.clone() + }; + h_flex() + .id(crate::ui::eid(format!("prof-{}", profile.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .when(selected, |el| el.bg(theme.sidebar_accent)) + .when(nav_focus && !selected, |el| el.border_1().border_color(theme.border)) + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let pid = pid.clone(); + let proj = proj.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.sidebar_nav = Some(SidebarNavItem::Profile { + project_id: proj.clone(), + profile_id: pid.clone(), + }); + app.open_profile(&proj, &pid); + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let pid = pid.clone(); + let proj = proj.clone(); + let is_default = profile.is_default; + move |ev, _, cx| { + if is_default { + return; + } + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::Profile { + id: pid.clone(), + project_id: proj.clone(), + }, + ev.position.x.into(), + ev.position.y.into(), + )); + cx.notify(); + }); + } + }) + .child(if profile.is_default { + Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element() + } else { + leftover_branch_glyph(theme.muted_foreground).into_any_element() + }) + .child(div().flex_1().text_sm().child(label)) + .child(agent_dot(app.agent_for_profile(&profile.id))) + })) + .child( + h_flex() + .id(crate::ui::eid(format!("new-prof-{}", project.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProfile); + app.data.overlay.dialog_project = Some(id.clone()); + cx.notify(); + }); + } + }) + .child(Icon::new(IconName::Plus).w(px(12.))) + .child(div().text_sm().child(app.t("createProfile"))), + ), + ) + }) +} + +fn leftover_home_row( + label: String, + selected: bool, + on_click: impl Fn(&mut gpui::App) + 'static, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + div() + .id("home-row") + .px_2() + .py_1() + .rounded_md() + .when(selected, |el| el.bg(gpui::hsla(0., 0., 0.5, 0.08))) + .on_click(move |_, _, cx| on_click(cx)) + .child( + h_flex() + .gap_2() + .child(crate::ui::leftover_house_glyph(theme.muted_foreground)) + .child(div().text_sm().child(label)), + ) +} + +pub fn agent_dot(status: AgentStatus) -> impl IntoElement { + if status == AgentStatus::Idle { + return div().id("agent-dot-idle").into_any_element(); + } + let (color, pulse) = match status { + AgentStatus::Waiting => (gpui::rgb(0xfacc15), false), + AgentStatus::Running => (gpui::rgb(0x34d399), true), + AgentStatus::Completed => (gpui::rgb(0x22c55e), false), + AgentStatus::Idle => (gpui::rgb(0x000000), false), + }; + let dot = div().size(px(8.)).rounded_full().bg(color); + if pulse { + dot.with_animation( + "agent-dot-running", + Animation::new(Duration::from_millis(1400)).repeat(), + |this, delta| { + let t = (delta * std::f32::consts::PI).sin().abs(); + this.opacity(1.0 - 0.28 * t) + }, + ) + .into_any_element() + } else { + dot.into_any_element() + } +} + +#[allow(dead_code)] +fn _file_name(path: &str) -> String { + backend::file_name(path) +} + +#[cfg(test)] +mod tests { + use super::leftover_onboarding_top; + + #[test] + fn leftover_onboarding_anchors_right_of_add_project() { + assert_eq!(leftover_onboarding_top(true), 120.0); + assert_eq!(leftover_onboarding_top(false), 96.0); + } +} diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs new file mode 100644 index 00000000..15d9d84f --- /dev/null +++ b/src-gpui/src/ui/terminal.rs @@ -0,0 +1,679 @@ +use gpui::{ + div, img, prelude::*, px, relative, rgb, Context, CursorStyle, Image, ImageFormat, KeyDownEvent, MouseButton, + TextRun, Window, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::prefs::TermTheme; + +pub fn render( + app: &mut AppView, + profile_id: &str, + index: usize, + interactive: bool, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = app.current_term_theme(); + let view = cx.entity(); + let Some(term) = app.data.workspaces.get(profile_id).and_then(|w| w.terminals.get(index)) else { + return div().id("term-missing").into_any_element(); + }; + let search_open = term.search_open; + let search_query = if search_open { + app.inputs.term_search.read(cx).value().to_string() + } else { + String::new() + }; + let hits = term.search_hits(&search_query); + let hit_ix = term.search_ix; + let id = term.id.clone(); + let clickables = crate::detector::clickable_tokens(&term.screen_text()); + let link_hits = crate::detector::clickable_hits(&term.screen_text()); + let osc_bar = crate::detector::parse_osc_progress(&term.osc_progress()); + let images = term.images.clone(); + let font_size = app.data.prefs.font_size; + let (cell_w, cell_h) = measure_pty_cell(window, app.data.prefs.font_family.clone(), font_size); + let grid = render_grid( + term, + theme, + &search_query, + hit_ix, + interactive, + &view, + &link_hits, + cell_w, + cell_h, + ); + + div() + .id(crate::ui::eid(format!("pty-{id}"))) + .size_full() + .relative() + .bg(rgb(theme.bg)) + .text_color(rgb(theme.fg)) + .font(crate::ui::markdown::editor_font(app.data.prefs.font_family.clone())) + .text_size(px(app.data.prefs.font_size)) + .when(!interactive, |el| el.invisible().absolute().inset_0()) + .when(interactive, |el| { + el.on_mouse_up(MouseButton::Left, { + let view = view.clone(); + move |ev, window, cx| { + let skip = ev.modifiers.platform || ev.modifiers.control; + view.update(cx, |app, cx| { + if app.data.overlay.drag_file.is_some() { + app.drop_file_on_terminal(); + } + let copied = app + .data + .current_ws_mut() + .and_then(|w| w.active_terminal_mut()) + .is_some_and(|term| term.finish_selection()); + if copied { + app.copy_term_selection(cx); + } else if let Some((row, col)) = app + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .and_then(|t| t.click_cell) + { + let screen = app + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.screen_text()) + .unwrap_or_default(); + if let Some(hit) = crate::detector::clickable_hits(&screen) + .into_iter() + .find(|h| h.contains(row, col)) + { + app.open_clickable(&hit.token, skip, window, cx); + } + } + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.click_cell = None; + } + cx.notify(); + }); + } + }) + }) + .child({ + let body = div() + .id(crate::ui::eid(format!("pty-body-{id}"))) + .size_full() + .p_2() + .font(crate::ui::markdown::editor_font(app.data.prefs.font_family.clone())) + .overflow_hidden() + .relative() + .child(grid) + .children(images.into_iter().enumerate().filter_map(|(i, image)| { + let kind = crate::detector::image_format(&image.bytes)?; + let format = match kind { + "jpeg" => ImageFormat::Jpeg, + "gif" => ImageFormat::Gif, + "webp" => ImageFormat::Webp, + _ => ImageFormat::Png, + }; + let top = 8.0 + image.row as f32 * cell_h; + let left = 8.0 + image.col as f32 * cell_w; + Some( + div() + .id(crate::ui::eid(format!("pty-img-{id}-{i}"))) + .absolute() + .top(px(top)) + .left(px(left)) + .child( + img(std::sync::Arc::new(Image::from_bytes(format, image.bytes))) + .id(crate::ui::eid(format!("pty-img-src-{id}-{i}"))) + .max_w(px(480.)) + .max_h(px(280.)), + ), + ) + })); + if interactive { + body.on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, window, cx| { + if ev.keystroke.modifiers.control && ev.keystroke.key == "f" + || ev.keystroke.modifiers.platform && ev.keystroke.key == "f" + { + view.update(cx, |app, cx| { + app.open_find(window, cx); + cx.notify(); + }); + return; + } + if (ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control) + && ev.keystroke.key == "v" + && !ev.keystroke.modifiers.alt + { + view.update(cx, |app, cx| { + app.paste_to_pty(cx); + }); + return; + } + if ev.keystroke.key == "c" + && !ev.keystroke.modifiers.alt + && !ev.keystroke.modifiers.shift + && (ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control) + { + view.update(cx, |app, cx| { + if ev.keystroke.modifiers.platform { + app.copy_term_selection(cx); + } else { + app.copy_term_or_interrupt(cx); + } + }); + return; + } + if ev.keystroke.key == "l" + && ev.keystroke.modifiers.control + && !ev.keystroke.modifiers.platform + && !ev.keystroke.modifiers.alt + && !ev.keystroke.modifiers.shift + { + view.update(cx, |app, cx| { + app.clear_active_terminal(); + cx.notify(); + }); + return; + } + let handled = view.update(cx, |app, cx| { + let handled = app.handle_overlay_key( + ev.keystroke.key.as_str(), + ev.keystroke.modifiers.shift, + window, + cx, + ); + if handled { + cx.notify(); + } + handled + }); + if handled { + return; + } + if let Some(bytes) = key_to_bytes(ev) { + view.update(cx, |app, _| { + app.write_to_active_pty(&bytes); + }); + } + } + }) + .into_any_element() + } else { + body.into_any_element() + } + }) + .when_some(osc_bar.filter(|(state, _)| *state != 0), |el, (state, value)| { + el.child( + div() + .id("pty-osc-progress") + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(3.)) + .bg(rgb(0x21262d)) + .child( + div() + .h_full() + .bg(rgb(match state { + 2 => 0xda3633, + 3 => 0x8b949e, + _ => 0x1f6feb, + })) + .w(relative(if state == 3 { + 1.0 + } else { + (value as f32 / 100.0).clamp(0.02, 1.0) + })), + ), + ) + }) + .when(search_open && interactive, |el| { + el.child( + h_flex() + .id("term-search") + .absolute() + .top(px(12.)) + .right(px(12.)) + .h(px(28.)) + .px_2() + .gap_1() + .rounded_md() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .child(div().w(px(224.)).child(Input::new(&app.inputs.term_search))) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if search_query.is_empty() { + String::new() + } else if hits.is_empty() { + app.t("terminalSearchNoResults") + } else { + format!("{}/{}", hit_ix + 1, hits.len()) + }), + ) + .child( + Button::new("term-search-prev") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip(app.t("terminalSearchPrevious")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.cycle_term_search(cx, false); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("term-search-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip(app.t("terminalSearchNext")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.cycle_term_search(cx, true); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("term-search-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("terminalSearchClose")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = + app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) + { + term.search_open = false; + } + cx.notify(); + }); + } + }), + ), + ) + }) + .when(interactive && !clickables.is_empty(), |el| { + el.child( + h_flex() + .id(crate::ui::eid(format!("pty-links-{id}"))) + .absolute() + .bottom(px(8.)) + .left(px(8.)) + .right(px(8.)) + .gap_1() + .flex_wrap() + .children(clickables.into_iter().map(|token| { + let label = match &token { + crate::detector::Clickable::Url(u) => u.clone(), + crate::detector::Clickable::Path(p) => p.clone(), + }; + let view = view.clone(); + Button::new(crate::ui::eid(format!("pty-link-{label}"))) + .xsmall() + .label(label.clone()) + .tooltip(match &token { + crate::detector::Clickable::Path(_) => app.t("terminalFilePathTooltip"), + crate::detector::Clickable::Url(_) => app.t("terminalOpenLink"), + }) + .on_click(move |ev, window, cx| { + let token = token.clone(); + let skip = ev.modifiers().platform || ev.modifiers().control; + view.update(cx, |app, cx| { + app.open_clickable(&token, skip, window, cx); + cx.notify(); + }); + }) + })), + ) + }) + .into_any_element() +} + +fn render_grid( + term: &crate::state::TermSession, + theme: &TermTheme, + query: &str, + hit_ix: usize, + interactive: bool, + view: &gpui::Entity, + link_hits: &[crate::detector::ClickHit], + cell_w: f32, + cell_h: f32, +) -> impl IntoElement { + let screen = term.parser.screen(); + let (rows, cols) = screen.size(); + let hits = term.search_hits(query); + let active = hits.get(hit_ix).copied(); + let query_len = query.len(); + + v_flex().id("pty-grid").children((0..rows).map(|row| { + let mut cells = Vec::with_capacity(cols as usize); + for col in 0..cols { + let cell = screen.cell(row, col); + let (ch, wide, continuation) = cell + .map(|c| { + let text = c.contents(); + let ch = if text.is_empty() { + " ".to_string() + } else { + text.to_string() + }; + (ch, c.is_wide(), c.is_wide_continuation()) + }) + .unwrap_or_else(|| (" ".into(), false, false)); + let (mut fg, mut bg) = cell + .map(|c| (map_color(c.fgcolor(), theme.fg), map_color(c.bgcolor(), theme.bg))) + .unwrap_or((theme.fg, theme.bg)); + if cell.map(|c| c.inverse()).unwrap_or(false) { + std::mem::swap(&mut fg, &mut bg); + } + if cell.map(|c| c.bold()).unwrap_or(false) { + fg = brighten(fg); + } + let highlight = hits + .iter() + .any(|&(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len); + let selected = term.cell_selected(row, col as usize); + let linked = link_hits.iter().any(|h| h.contains(row, col as usize)); + if highlight { + bg = if active == Some((row, col as usize, query_len)) + || active.is_some_and(|(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len) + { + 0x1f6feb + } else { + 0x5f4b16 + }; + fg = 0xffffff; + } else if selected { + bg = 0x264f78; + fg = 0xffffff; + } + cells.push((ch, wide, continuation, (fg, bg, highlight, selected, linked))); + } + let spans = merge_row_spans(&cells); + h_flex() + .h(px(cell_h)) + .children(spans.into_iter().map(|(text, span_cols, start_col, style)| { + let (fg, bg, _, _, linked) = style; + let end_col = start_col + span_cols as usize; + let view = view.clone(); + let span = div() + .w(px(cell_w * span_cols as f32)) + .min_w(px(cell_w * span_cols as f32)) + .h(px(cell_h)) + .overflow_hidden() + .bg(rgb(bg)) + .text_color(rgb(fg)) + .whitespace_nowrap() + .when(linked, |el| el.underline().cursor(CursorStyle::PointingHand)) + .child(text); + if !interactive { + return span.into_any_element(); + } + span.on_mouse_down(MouseButton::Left, { + let view = view.clone(); + move |ev, _, cx| { + let extend = ev.modifiers.shift; + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.begin_selection(row, start_col, extend); + term.click_cell = Some((row, start_col)); + } + cx.notify(); + }); + } + }) + .on_mouse_move({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if term.selecting { + term.extend_selection(row, end_col); + cx.notify(); + } + } + }); + } + }) + .into_any_element() + })) + })) +} + +pub fn fallback_pty_cell(font_size: f32) -> (f32, f32) { + let font = font_size.max(10.0); + (font * 0.6, font * 1.35) +} + +pub fn measure_pty_cell(window: &Window, font_family: impl Into, font_size: f32) -> (f32, f32) { + let font_size = font_size.max(10.0); + let (fallback_w, fallback_h) = fallback_pty_cell(font_size); + let run = TextRun { + len: 1, + font: crate::ui::markdown::editor_font(font_family), + color: rgb(0xffffff).into(), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window.text_system().layout_line("W", px(font_size), &[run], None); + let width = f32::from(line.width); + let height = f32::from(line.ascent) + f32::from(line.descent); + ( + if width > 0.0 { width } else { fallback_w }, + if height > 0.0 { height } else { fallback_h }, + ) +} + +pub fn pty_grid_size( + viewport_w: f32, + viewport_h: f32, + cell_w: f32, + cell_h: f32, + sidebar_collapsed: bool, + sidebar_width: f32, + profile_sidebar_open: bool, + profile_sidebar_width: f32, +) -> (u16, u16) { + let mut chrome = 48.0; + if !sidebar_collapsed { + chrome += sidebar_width; + } + if profile_sidebar_open { + chrome += profile_sidebar_width; + } + let cols = ((viewport_w - chrome - 24.0) / cell_w.max(1.0)).floor() as i32; + let rows = ((viewport_h - 96.0) / cell_h.max(1.0)).floor() as i32; + (rows.clamp(10, 120) as u16, cols.clamp(40, 300) as u16) +} + +pub fn cell_display_cols(wide: bool, continuation: bool) -> Option { + if continuation { + None + } else if wide { + Some(2) + } else { + Some(1) + } +} + +pub fn merge_row_spans(cells: &[(String, bool, bool, S)]) -> Vec<(String, u16, usize, S)> { + let mut spans: Vec<(String, u16, usize, S)> = Vec::new(); + for (col, (text, wide, continuation, style)) in cells.iter().enumerate() { + let Some(cols) = cell_display_cols(*wide, *continuation) else { + continue; + }; + let ch = if text.is_empty() { " ".to_string() } else { text.clone() }; + if let Some((last_text, last_cols, _, last_style)) = spans.last_mut() { + if *last_style == *style { + last_text.push_str(&ch); + *last_cols += cols; + continue; + } + } + spans.push((ch, cols, col, *style)); + } + spans +} + +fn map_color(color: vt100::Color, fallback: u32) -> u32 { + match color { + vt100::Color::Default => fallback, + vt100::Color::Idx(idx) => ansi_color(idx), + vt100::Color::Rgb(r, g, b) => ((r as u32) << 16) | ((g as u32) << 8) | b as u32, + } +} + +fn ansi_color(idx: u8) -> u32 { + const BASIC: [u32; 16] = [ + 0x0d1117, 0xff7b72, 0x3fb950, 0xd29922, 0x58a6ff, 0xbc8cff, 0x39c5cf, 0xc9d1d9, 0x6e7681, 0xffa198, 0x56d364, + 0xe3b341, 0x79c0ff, 0xd2a8ff, 0x56d4dd, 0xffffff, + ]; + if (idx as usize) < 16 { + return BASIC[idx as usize]; + } + if idx >= 232 { + let v = 8 + (idx as u32 - 232) * 10; + return (v << 16) | (v << 8) | v; + } + let n = idx - 16; + let r = n / 36; + let g = (n / 6) % 6; + let b = n % 6; + let step = |c: u8| if c == 0 { 0 } else { 55 + 40 * c as u32 }; + (step(r) << 16) | (step(g) << 8) | step(b) +} + +fn brighten(color: u32) -> u32 { + let r = ((color >> 16) & 0xff).saturating_add(28).min(255); + let g = ((color >> 8) & 0xff).saturating_add(28).min(255); + let b = (color & 0xff).saturating_add(28).min(255); + (r << 16) | (g << 8) | b +} + +fn key_to_bytes(ev: &KeyDownEvent) -> Option> { + let key = ev.keystroke.key.as_str(); + let mods = &ev.keystroke.modifiers; + if mods.shift && !mods.control && !mods.platform && !mods.alt && key == "enter" { + return Some(b"\n".to_vec()); + } + if mods.platform && !mods.control && !mods.alt && !mods.shift { + match key { + "left" => return Some(b"\x1b[H".to_vec()), + "right" => return Some(b"\x1b[F".to_vec()), + _ => {} + } + } + if mods.alt && !mods.control && !mods.platform && !mods.shift { + match key { + "left" => return Some(b"\x1bb".to_vec()), + "right" => return Some(b"\x1bf".to_vec()), + _ => {} + } + } + if ev.keystroke.modifiers.control { + return match key { + "c" => Some(vec![0x03]), + "d" => Some(vec![0x04]), + "l" => Some(vec![0x0c]), + "u" => Some(vec![0x15]), + "k" => Some(vec![0x0b]), + "a" => Some(vec![0x01]), + "e" => Some(vec![0x05]), + "w" => Some(vec![0x17]), + _ => None, + }; + } + match key { + "enter" => Some(b"\r".to_vec()), + "backspace" => Some(vec![0x7f]), + "tab" => Some(b"\t".to_vec()), + "escape" => Some(b"\x1b".to_vec()), + "up" => Some(b"\x1b[A".to_vec()), + "down" => Some(b"\x1b[B".to_vec()), + "right" => Some(b"\x1b[C".to_vec()), + "left" => Some(b"\x1b[D".to_vec()), + "home" => Some(b"\x1b[H".to_vec()), + "end" => Some(b"\x1b[F".to_vec()), + "pageup" => Some(b"\x1b[5~".to_vec()), + "pagedown" => Some(b"\x1b[6~".to_vec()), + "delete" => Some(b"\x1b[3~".to_vec()), + other if other.len() == 1 => Some(other.as_bytes().to_vec()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{cell_display_cols, fallback_pty_cell, merge_row_spans, pty_grid_size}; + + #[test] + fn fallback_pty_cell_matches_xterm_advance_ratio() { + assert_eq!(fallback_pty_cell(10.0), (6.0, 13.5)); + assert_eq!(fallback_pty_cell(20.0), (12.0, 27.0)); + } + + #[test] + fn pty_grid_size_uses_measured_cell_and_chrome() { + assert_eq!(pty_grid_size(800.0, 600.0, 8.0, 16.0, true, 0.0, false, 0.0), (31, 91)); + assert_eq!( + pty_grid_size(800.0, 600.0, 8.0, 16.0, false, 250.0, true, 208.0), + (31, 40) + ); + } + + #[test] + fn cell_display_cols_skips_wide_continuation() { + assert_eq!(cell_display_cols(false, false), Some(1)); + assert_eq!(cell_display_cols(true, false), Some(2)); + assert_eq!(cell_display_cols(false, true), None); + assert_eq!(cell_display_cols(true, true), None); + } + + #[test] + fn merge_row_spans_skips_continuation_and_spans_wide_glyphs() { + let cells = [ + ("a".into(), false, false, 1), + ("你".into(), true, false, 1), + ("".into(), false, true, 1), + ("b".into(), false, false, 1), + ]; + assert_eq!(merge_row_spans(&cells), vec![("a你b".into(), 4, 0, 1)]); + + let split = [ + ("你".into(), true, false, 1), + ("".into(), false, true, 1), + ("x".into(), false, false, 2), + ]; + assert_eq!( + merge_row_spans(&split), + vec![("你".into(), 2, 0, 1), ("x".into(), 1, 2, 2)] + ); + } +} diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs new file mode 100644 index 00000000..69e2bbcd --- /dev/null +++ b/src-gpui/src/ui/workspace.rs @@ -0,0 +1,1023 @@ +use gpui::{div, prelude::*, px, rgb, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::tab::Tab; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; + +use crate::app::AppView; +use crate::state::{ + leftover_configured_app, leftover_launch_app_i18n, leftover_new_terminal_split, leftover_pr_label, + leftover_pr_visible, leftover_template_rows, leftover_topbar_app_tooltip, AgentKind, DialogKind, + LeftoverTemplateRow, SidebarMode, UnifiedTab, LEFTOVER_EDITOR_APP_IDS, LEFTOVER_TERMINAL_APP_IDS, +}; +use crate::ui::{file_tree, file_viewer, git, leftover_branch_glyph, notes, sidebar, terminal, tip}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("profile-workspace") + .size_full() + .min_w_0() + .min_h_0() + .child(topbar(app, window, cx)) + .child( + h_flex() + .id("workspace-body") + .flex_1() + .min_w_0() + .min_h_0() + .child(profile_sidebar(app, window, cx)) + .child(main_column(app, window, cx)), + ) +} + +fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("empty-topbar").into_any_element(); + }; + let mode = ws.sidebar_mode; + let open = ws.sidebar_open; + let stats = ws.git_stats.clone(); + let name = ws.project_name.clone(); + let branch = if ws.branch.is_empty() { + "main".to_string() + } else { + ws.branch.clone() + }; + let worktree = ws.worktree.clone(); + let pr = ws.pr.clone(); + let pr_error = ws.pr_error.clone(); + let collapsed = app.data.prefs.sidebar_collapsed; + let pad_left = if cfg!(target_os = "macos") && collapsed { + px(84.) + } else { + px(16.) + }; + let pad_right = px(leftover_topbar_pad_right(cfg!(target_os = "windows"))); + let controls = app.data.prefs.topbar_controls.clone(); + + div() + .id("project-topbar") + .w_full() + .min_h(px(44.)) + .pt(px(4.)) + .pb(px(6.)) + .pl(pad_left) + .pr(pad_right) + .border_b_1() + .border_color(theme.border) + .flex() + .items_end() + .justify_between() + .relative() + .child( + h_flex() + .id("topbar-title") + .absolute() + .left_0() + .right_0() + .bottom(px(6.)) + .px(px(128.)) + .min_w_0() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .id("topbar-project-name") + .min_w_0() + .font_semibold() + .truncate() + .tooltip(tip(worktree.clone())) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| { + let _ = app + .backend + .reveal_path(app.data.current_profile.as_deref().unwrap_or(""), None); + }); + } + }) + .child(name), + ) + .child( + h_flex() + .id("topbar-branch") + .min_w_0() + .gap_1() + .items_center() + .text_color(theme.muted_foreground) + .hover(|el| el.text_color(theme.foreground)) + .tooltip(tip(app.t("switchBranchTitle"))) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws() { + app.data.overlay.branches = + app.backend.list_branches(&ws.worktree).unwrap_or_default(); + } + app.data.overlay.dialog = Some(DialogKind::SwitchBranch); + cx.notify(); + }); + } + }) + .child(leftover_branch_glyph(theme.muted_foreground)) + .child(div().min_w_0().truncate().child(branch)), + ), + ) + .child( + h_flex() + .gap_2() + .when(collapsed, |el| { + el.child( + Button::new("expand-app-sidebar") + .ghost() + .small() + .icon(IconName::PanelLeftOpen) + .tooltip(app.t("expandSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = false; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ) + }) + .child(mode_switch(app, mode, open, stats.insertions, stats.deletions, cx)), + ) + .child( + h_flex() + .gap_1() + .children( + controls + .into_iter() + .map(|id| topbar_control(app, &id, pr.as_ref(), pr_error.as_deref(), cx)), + ) + .child( + Button::new("project-settings") + .small() + .icon(IconName::Settings) + .tooltip(app.t("projectSettings")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::ProjectSettings); + app.data.overlay.dialog_project = app.data.current_project.clone(); + if let Some(pid) = app.data.current_project.clone() { + if let Ok(cfg) = app.backend.project_config(&pid) { + app.inputs.worktree.update(cx, |s, cx| { + s.set_value(cfg.worktree_dir.clone().unwrap_or_default(), window, cx); + }); + app.inputs.init_script.update(cx, |s, cx| { + s.set_value(cfg.init_script.join("\n"), window, cx); + }); + app.inputs.setup_script.update(cx, |s, cx| { + s.set_value(cfg.setup_script.join("\n"), window, cx); + }); + app.inputs.teardown_script.update(cx, |s, cx| { + s.set_value(cfg.teardown_script.join("\n"), window, cx); + }); + } + } + cx.notify(); + }); + } + }), + ), + ) + .into_any_element() +} + +pub fn leftover_show_git_diff_stats(insertions: u32, deletions: u32) -> bool { + insertions != 0 || deletions != 0 +} + +pub fn leftover_topbar_pad_right(windows: bool) -> f32 { + if windows { 118.0 } else { 20.0 } +} + +fn mode_switch( + app: &AppView, + mode: SidebarMode, + open: bool, + ins: u32, + del: u32, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let git_selected = open && mode == SidebarMode::Git; + h_flex() + .id("sidebar-mode-switch") + .h(px(28.)) + .rounded_md() + .bg(theme.muted) + .p(px(2.)) + .gap_1() + .child(mode_btn( + "files", + IconName::Folder, + app.t("sidebarFilesTab"), + open && mode == SidebarMode::Files, + SidebarMode::Files, + view.clone(), + )) + .child( + h_flex() + .id("git-mode-btn") + .h(px(24.)) + .px_2() + .gap_1() + .items_center() + .rounded_md() + .when(git_selected, |el| el.bg(theme.background)) + .tooltip(tip(app.t("sidebarGitTab"))) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if ws.sidebar_open && ws.sidebar_mode == SidebarMode::Git { + ws.sidebar_open = false; + } else { + ws.sidebar_mode = SidebarMode::Git; + ws.sidebar_open = true; + } + app.data.prefs.profile_sidebar_open = ws.sidebar_open; + app.persist_prefs(); + } + cx.notify(); + }); + } + }) + .child(leftover_branch_glyph(if git_selected { + theme.foreground + } else { + theme.muted_foreground + })) + .when(leftover_show_git_diff_stats(ins, del), |el| { + el.child(div().text_xs().text_color(rgb(0x22c55e)).child(format!("+{ins}"))) + .child(div().text_xs().text_color(rgb(0xef4444)).child(format!("-{del}"))) + }), + ) + .child(mode_btn( + "notes", + IconName::BookOpen, + app.t("notes"), + open && mode == SidebarMode::Notes, + SidebarMode::Notes, + view, + )) +} + +fn mode_btn( + id: &'static str, + icon: IconName, + tip: String, + selected: bool, + mode: SidebarMode, + view: gpui::Entity, +) -> impl IntoElement { + Button::new(id) + .ghost() + .xsmall() + .icon(icon) + .selected(selected) + .tooltip(tip) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if ws.sidebar_open && ws.sidebar_mode == mode { + ws.sidebar_open = false; + } else { + ws.sidebar_mode = mode; + ws.sidebar_open = true; + } + app.data.prefs.profile_sidebar_open = ws.sidebar_open; + app.persist_prefs(); + } + cx.notify(); + }); + }) +} + +fn topbar_control( + app: &AppView, + id: &str, + pr: Option<&model::project::GitPullRequestStatus>, + pr_error: Option<&str>, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + match id { + "github-desktop" => Button::new("tb-gh") + .small() + .icon(IconName::GitHub) + .tooltip(app.t("topbarGithubDesktop")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("github-desktop")); + } + }) + .into_any_element(), + "editor" => { + let installed: Vec<_> = crate::platform::installed_editors(); + let ids: Vec<&str> = installed.iter().map(|app| app.id).collect(); + let Some(app_id) = leftover_configured_app(&app.data.prefs.editor_app, LEFTOVER_EDITOR_APP_IDS, &ids) + else { + return div().into_any_element(); + }; + let tip = leftover_topbar_app_tooltip(&app.t("topbarEditor"), &app.t(leftover_launch_app_i18n(app_id))); + Button::new("tb-ed") + .small() + .icon(IconName::ALargeSmall) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("editor")); + } + }) + .into_any_element() + } + "terminal" => { + let installed: Vec<_> = crate::platform::installed_terminals(); + let ids: Vec<&str> = installed.iter().map(|app| app.id).collect(); + let Some(app_id) = leftover_configured_app(&app.data.prefs.terminal_app, LEFTOVER_TERMINAL_APP_IDS, &ids) + else { + return div().into_any_element(); + }; + let tip = leftover_topbar_app_tooltip(&app.t("topbarTerminal"), &app.t(leftover_launch_app_i18n(app_id))); + Button::new("tb-term") + .small() + .icon(IconName::SquareTerminal) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("terminal")); + } + }) + .into_any_element() + } + "pr-status" => { + if !leftover_pr_visible(pr.is_some()) { + let _ = pr_error; + return div().into_any_element(); + } + let Some(p) = pr else { + return div().into_any_element(); + }; + let state = if p.is_draft { + app.t("topbarPrDraft") + } else { + match p.state.as_str() { + "MERGED" => app.t("topbarPrMerged"), + "CLOSED" => app.t("topbarPrClosed"), + _ => app.t("topbarPrOpen"), + } + }; + let label = leftover_pr_label(p.number, &state); + let tip = crate::i18n::tf( + app.data.locale, + "topbarPrTooltip", + &[ + ("number", &p.number.to_string()), + ("title", &p.title), + ("state", &state), + ], + ); + Button::new("tb-pr") + .small() + .icon(IconName::GitHub) + .label(label) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_pr()); + } + }) + .into_any_element() + } + _ => div().into_any_element(), + } +} + +fn profile_sidebar(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(ws) = app.data.current_ws() else { + return div().id("no-profile-sidebar").into_any_element(); + }; + let open = ws.sidebar_open; + let stored_width = app.data.prefs.profile_sidebar_width.clamp(180.0, 560.0); + let width = if open { stored_width } else { 0.0 }; + let mode = ws.sidebar_mode; + let theme = cx.theme().clone(); + + let view = cx.entity(); + div() + .id("profile-sidebar-wrap") + .relative() + .h_full() + .w(px(width)) + .overflow_hidden() + .when(!open, |el| el.invisible()) + .child( + div() + .id("profile-sidebar") + .w(px(stored_width)) + .h_full() + .border_r_1() + .border_color(theme.border) + .min_w_0() + .child( + // Keep all three mounted across Files/Git/Notes and while the sidebar is closed. + v_flex() + .size_full() + .child(visible(mode == SidebarMode::Files, file_tree::render(app, window, cx))) + .child(visible(mode == SidebarMode::Git, git::render_panel(app, window, cx))) + .child(visible(mode == SidebarMode::Notes, notes::render(app, window, cx))), + ), + ) + .when(open, |el| { + el.child(sidebar::resize_handle( + "profile-sidebar-resize", + view, + true, + app.data.overlay.sidebar_resize_focus == Some(true), + app.t("profileSidebarResizeSeparator"), + )) + }) + .into_any_element() +} + +fn visible(show: bool, child: impl IntoElement) -> impl IntoElement { + div() + .size_full() + .when(!show, |el| el.h(px(0.)).overflow_hidden().invisible()) + .when(show, |el| el.flex_1()) + .child(child) +} + +fn main_column(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(ws) = app.data.current_ws() else { + return persistent_terminals(app, window, cx).into_any_element(); + }; + if !ws.has_tabs() { + return v_flex() + .id("workspace-empty") + .flex_1() + .min_h_0() + .relative() + .child(empty_cta(app, cx)) + .child(persistent_terminals(app, window, cx)) + .into_any_element(); + } + v_flex() + .id("workspace-main") + .flex_1() + .min_w_0() + .min_h_0() + .child(tab_bar(app, cx)) + .child(tab_bodies(app, window, cx)) + .into_any_element() +} + +fn empty_cta(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + v_flex() + .id("empty-terminals") + .flex_1() + .items_center() + .justify_center() + .gap_4() + .child( + div() + .size(px(32.)) + .rounded_lg() + .bg(theme.muted) + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))), + ) + .child(div().text_sm().font_medium().child(app.t("noTerminalsOpen"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noTerminalsOpenDescription")), + ) + .child(new_terminal_control(app, view.clone(), true)) +} + +fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("no-tabs").into_any_element(); + }; + let active = ws.active; + + h_flex() + .id("unified-tabs") + .w_full() + .border_b_1() + .border_color(theme.border) + .overflow_x_scroll() + .children(ws.terminals.iter().enumerate().map(|(ix, term)| { + let selected = matches!(active, Some(UnifiedTab::Terminal { index }) if index == ix); + let id = term.id.clone(); + let profile = term.profile_id.clone(); + let show_completed = term.agent == crate::state::AgentStatus::Completed && !term.completed_hidden; + let show_live = matches!( + term.agent, + crate::state::AgentStatus::Running | crate::state::AgentStatus::Waiting + ); + Tab::new() + .label(term.title.clone()) + .prefix(terminal_tab_icon(&term.title, term.agent_kind)) + .max_w(px(224.)) + .selected(selected) + .suffix( + h_flex() + .gap_1() + .when(show_live, |el| el.child(sidebar::agent_dot(term.agent))) + .when(show_completed, |el| { + let view = view.clone(); + let id = id.clone(); + el.child( + div() + .id(crate::ui::eid(format!("done-{id}"))) + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(0x22c55e)) + .cursor(gpui::CursorStyle::PointingHand) + .tooltip(crate::ui::tip("Dismiss completion notification")) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app + .data + .current_ws_mut() + .and_then(|w| w.terminals.iter_mut().find(|t| t.id == id)) + { + term.completed_hidden = true; + } + cx.notify(); + }); + } + }), + ) + }) + .child( + Button::new(crate::ui::eid(format!("close-term-{id}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(format!("Close {title}", title = term.title)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + let profile = profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_terminal(&profile, &id); + cx.notify(); + }); + } + }), + ), + ) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.active = Some(UnifiedTab::Terminal { index: ix }); + } + cx.notify(); + }); + } + }) + .on_drop({ + let view = view.clone(); + let id = id.clone(); + move |drag: &crate::ui::TreeDrag, _, cx| { + view.update(cx, |app, cx| { + app.write_path_to_pty(&id, &drag.path); + cx.notify(); + }); + } + }) + })) + .children(ws.files.iter().enumerate().map(|(ix, file)| { + let selected = matches!(active, Some(UnifiedTab::File { index }) if index == ix); + let path = file.path.clone(); + let profile = ws.profile_id.clone(); + Tab::new() + .label(file.title.clone()) + .prefix(crate::ui::file_icons::file_glyph(&file.path, false, false, 14.)) + .max_w(px(224.)) + .selected(selected) + .suffix( + h_flex() + .gap_1() + .when(file.dirty(), |el| { + el.child(div().size(px(8.)).rounded_full().bg(theme.muted_foreground)) + }) + .child( + Button::new(crate::ui::eid(format!("close-file-{path}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(format!("Close {title}", title = file.title)) + .on_click({ + let view = view.clone(); + let path = path.clone(); + let profile = profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.request_close_file(&profile, &path); + cx.notify(); + }); + } + }), + ), + ) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.active = Some(UnifiedTab::File { index: ix }); + } + if let Some(file) = app.data.current_ws().and_then(|w| w.files.get(ix)) { + if !file.preview { + let draft = file.draft.clone(); + let path = file.path.clone(); + app.bind_file_editor(&path, &draft, window, cx); + } + } + cx.notify(); + }); + } + }) + })) + .child(leftover_new_terminal_tab(app, view, cx)) + .into_any_element() +} + +fn leftover_new_terminal_tab( + app: &AppView, + view: gpui::Entity, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let hover = app.data.overlay.new_terminal_hover; + let project: Vec<(String, String)> = app + .data + .current_ws() + .map(|w| { + w.config + .terminal_templates + .iter() + .map(|t| (t.name.clone(), t.cwd.clone())) + .collect() + }) + .unwrap_or_default(); + let global: Vec = app.data.prefs.templates.iter().map(|t| t.name.clone()).collect(); + let rows = leftover_template_rows(&project, &global, true); + div() + .id("new-term-tab") + .relative() + .ml_2() + .max_w(px(224.)) + .on_hover({ + let view = view.clone(); + move |hovered, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.new_terminal_hover != *hovered { + app.data.overlay.new_terminal_hover = *hovered; + cx.notify(); + } + }); + } + }) + .child( + Button::new("new-term") + .ghost() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }), + ) + .when(hover, |el| { + el.child(leftover_template_dropdown(app, &rows, view.clone(), theme.clone())) + }) +} + +fn leftover_template_dropdown( + app: &AppView, + rows: &[LeftoverTemplateRow], + view: gpui::Entity, + theme: gpui_component::Theme, +) -> impl IntoElement { + let border = theme.border; + let popover = theme.popover; + let muted = theme.muted; + let muted_fg = theme.muted_foreground; + v_flex() + .id("new-term-hover-menu") + .absolute() + .top(px(36.)) + .left_0() + .min_w(px(224.)) + .p_1() + .gap_1() + .rounded_lg() + .border_1() + .border_color(border) + .bg(popover) + .shadow_md() + .children(rows.iter().cloned().map(|row| { + match row { + LeftoverTemplateRow::EmptyTitle => div() + .px_2() + .py_1() + .text_sm() + .text_color(muted_fg) + .child(app.t("noTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::EmptyHint => div() + .px_2() + .py_1() + .text_xs() + .text_color(muted_fg) + .child(app.t("noTemplatesDropdownHint")) + .into_any_element(), + LeftoverTemplateRow::ProjectHeader => div() + .px_2() + .py_1() + .text_xs() + .font_medium() + .text_color(muted_fg) + .child(app.t("projectTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::GlobalHeader => div() + .px_2() + .py_1() + .text_xs() + .font_medium() + .text_color(muted_fg) + .child(app.t("globalTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::Project { index, name, cwd } => div() + .id(crate::ui::eid(format!("hover-pt-{index}"))) + .px_2() + .py_2() + .rounded_md() + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(t) = app + .data + .current_ws() + .and_then(|w| w.config.terminal_templates.get(index).cloned()) + { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .gap(px(2.)) + .child(div().text_sm().child(name)) + .when(!cwd.is_empty(), |el| { + el.child(div().text_xs().text_color(muted_fg).child(cwd)) + }), + ) + .into_any_element(), + LeftoverTemplateRow::Global { index, name } => div() + .id(crate::ui::eid(format!("hover-gt-{index}"))) + .px_2() + .py_2() + .rounded_md() + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(t) = app.data.prefs.templates.get(index).cloned() { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }) + .child(div().text_sm().child(name)) + .into_any_element(), + } + })) +} + +fn new_terminal_control(app: &AppView, view: gpui::Entity, empty_cta: bool) -> impl IntoElement { + let has_templates = !app.data.prefs.templates.is_empty() + || app + .data + .current_ws() + .map(|w| !w.config.terminal_templates.is_empty()) + .unwrap_or(false); + if leftover_new_terminal_split(has_templates, empty_cta) { + h_flex() + .gap_0() + .child( + Button::new("new-term") + .primary() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("new-term-split") + .primary() + .small() + .icon(IconName::ChevronDown) + .tooltip("Choose template".to_string()) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = + Some((crate::state::ContextMenu::NewTerminal, 200.0, 80.0)); + cx.notify(); + }); + } + }), + ) + .into_any_element() + } else { + Button::new("new-term") + .primary() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + cx.notify(); + }); + }) + .into_any_element() + } +} + +fn terminal_tab_icon(title: &str, detected: AgentKind) -> impl IntoElement { + match AgentKind::tab_icon_kind(title, detected) { + AgentKind::Unknown => Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element(), + kind => agent_kind_mark(kind).into_any_element(), + } +} + +fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { + let (label, color) = match kind { + AgentKind::Claude => ("C", gpui::rgb(0xd97757)), + AgentKind::Codex => ("X", gpui::rgb(0x6b7280)), + AgentKind::Gemini => ("G", gpui::rgb(0x4285f4)), + AgentKind::Cursor => ("R", gpui::rgb(0xf59e0b)), + AgentKind::Copilot => ("P", gpui::rgb(0x818cf8)), + AgentKind::Amp => ("A", gpui::rgb(0x22c55e)), + AgentKind::Cline => ("L", gpui::rgb(0x38bdf8)), + AgentKind::OpenCode => ("O", gpui::rgb(0x94a3b8)), + AgentKind::Grok => ("K", gpui::rgb(0xe5e7eb)), + AgentKind::Kimi => ("M", gpui::rgb(0xf472b6)), + AgentKind::Devin => ("D", gpui::rgb(0x14b8a6)), + AgentKind::Droid => ("F", gpui::rgb(0xa855f7)), + AgentKind::Hermes => ("H", gpui::rgb(0xf97316)), + AgentKind::Kilo => ("I", gpui::rgb(0x84cc16)), + AgentKind::Kiro => ("W", gpui::rgb(0x06b6d4)), + AgentKind::Pi => ("π", gpui::rgb(0x64748b)), + AgentKind::Qoder => ("Q", gpui::rgb(0x6366f1)), + AgentKind::Agy => ("Y", gpui::rgb(0xec4899)), + AgentKind::OpenClaw => ("C", gpui::rgb(0x334155)), + AgentKind::Other => ("•", gpui::rgb(0x9ca3af)), + AgentKind::Unknown => return Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element(), + }; + div() + .size(px(14.)) + .rounded_sm() + .bg(color) + .text_color(gpui::white()) + .flex() + .items_center() + .justify_center() + .text_xs() + .child(label) + .into_any_element() +} + +fn tab_bodies(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let file_active = app + .data + .current_ws() + .is_some_and(|ws| matches!(ws.active, Some(UnifiedTab::File { .. }))); + + div() + .id("tab-bodies") + .flex_1() + .min_h_0() + .min_w_0() + .relative() + .child( + div() + .id("file-viewer-slot") + .size_full() + .when(!file_active, |el| el.invisible().h(px(0.))) + .child(file_viewer::render(app, window, cx)), + ) + .child(persistent_terminals(app, window, cx)) + .into_any_element() +} + +fn persistent_terminals(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let current = app.data.current_profile.clone(); + let mut slots = app + .data + .workspaces + .iter() + .flat_map(|(profile_id, ws)| { + ws.terminals.iter().enumerate().map(|(index, term)| { + let visible = current.as_deref() == Some(profile_id.as_str()) + && matches!(ws.active, Some(UnifiedTab::Terminal { index: active }) if active == index); + (profile_id.clone(), index, term.id.clone(), visible) + }) + }) + .collect::>(); + slots.sort_by(|a, b| a.2.cmp(&b.2)); + + div() + .id("pty-layer") + .size_full() + .relative() + .children(slots.into_iter().map(|(profile_id, index, id, visible)| { + div() + .id(crate::ui::eid(format!("term-slot-{id}"))) + .size_full() + .child(terminal::render(app, &profile_id, index, visible, window, cx)) + })) +} + +#[cfg(test)] +mod tests { + use super::{leftover_show_git_diff_stats, leftover_topbar_pad_right}; + + #[test] + fn leftover_git_stats_hide_clean_trees() { + assert!(!leftover_show_git_diff_stats(0, 0)); + assert!(leftover_show_git_diff_stats(1, 0)); + assert!(leftover_show_git_diff_stats(0, 4)); + assert!(leftover_show_git_diff_stats(2, 3)); + } + + #[test] + fn leftover_topbar_pad_right_matches_inventory() { + assert_eq!(leftover_topbar_pad_right(true), 118.0); + assert_eq!(leftover_topbar_pad_right(false), 20.0); + } +} diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs new file mode 100644 index 00000000..f7da1986 --- /dev/null +++ b/src-gpui/src/updater.rs @@ -0,0 +1,383 @@ +use std::collections::HashMap; +use std::io::Write; + +use serde::Deserialize; + +const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/AkaraChen/2code/releases?per_page=20"; +const GITHUB_RELEASES_PAGE: &str = "https://github.com/AkaraChen/2code/releases"; +const GITHUB_RELEASE_DOWNLOAD_BASE: &str = "https://github.com/AkaraChen/2code/releases/download"; + +#[derive(Debug, Clone)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub available: bool, + pub prerelease: bool, + pub html_url: String, + pub released_at: Option, +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + html_url: String, + #[serde(default)] + published_at: Option, + #[serde(default)] + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct LatestJson { + #[serde(default)] + platforms: HashMap, +} + +#[derive(Debug, Deserialize)] +struct LatestPlatform { + url: String, +} + +pub fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +pub fn releases_page() -> &'static str { + GITHUB_RELEASES_PAGE +} + +pub fn check_for_update(accept_beta: bool) -> Result { + let mut request = ureq::get(GITHUB_RELEASES_API) + .set("accept", "application/vnd.github+json") + .set("user-agent", "2code-gpui"); + + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + + let releases: Vec = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + + let release = pick_release(&releases, accept_beta).ok_or_else(|| "no matching GitHub release".to_string())?; + let latest = release.tag_name.trim_start_matches('v').to_string(); + let current = current_version().to_string(); + Ok(UpdateInfo { + available: is_newer(&latest, ¤t), + latest_version: latest, + current_version: current, + prerelease: release.prerelease, + html_url: if release.html_url.is_empty() { + GITHUB_RELEASES_PAGE.to_string() + } else { + release.html_url.clone() + }, + released_at: release.published_at.clone(), + }) +} + +pub fn format_release_date_display(raw: &str, locale: crate::i18n::Locale) -> String { + crate::timefmt::format_release_date_display(raw, locale) +} + +fn pick_release(releases: &[GithubRelease], accept_beta: bool) -> Option<&GithubRelease> { + if accept_beta { + if let Some(beta) = releases.iter().find(|release| { + !release.draft && release.prerelease && release.tag_name.to_ascii_lowercase().contains("beta") + }) { + return Some(beta); + } + } + releases + .iter() + .find(|release| !release.draft && !release.prerelease) + .or_else(|| releases.iter().find(|release| !release.draft)) +} + +fn gh_auth_token() -> Option { + let output = std::process::Command::new("gh") + .args(["auth", "token"]) + .env("GH_PROMPT_DISABLED", "1") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let token = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if token.is_empty() { + None + } else { + Some(token) + } +} + +fn version_parts(raw: &str) -> Vec { + raw.trim() + .trim_start_matches('v') + .split(|c: char| !c.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .take(3) + .collect() +} + +fn is_newer(latest: &str, current: &str) -> bool { + let mut latest_parts = version_parts(latest); + let mut current_parts = version_parts(current); + while latest_parts.len() < 3 { + latest_parts.push(0); + } + while current_parts.len() < 3 { + current_parts.push(0); + } + latest_parts > current_parts +} + +pub fn download_and_install(accept_beta: bool) -> Result { + let info = check_for_update(accept_beta)?; + if !info.available { + return Err("no update available".into()); + } + let url = asset_url(accept_beta)?; + let name = url + .rsplit('/') + .next() + .unwrap_or("2code-update") + .split('?') + .next() + .unwrap_or("2code-update"); + let dest = std::env::temp_dir().join(format!("{}-{}", info.latest_version, name)); + let mut request = ureq::get(&url).set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let response = request.call().map_err(|e| e.to_string())?; + let mut file = std::fs::File::create(&dest).map_err(|e| e.to_string())?; + let mut reader = response.into_reader(); + std::io::copy(&mut reader, &mut file).map_err(|e| e.to_string())?; + file.flush().map_err(|e| e.to_string())?; + match install_kind(name) { + InstallKind::ReplaceRunningBinary => { + replace_running_binary(&dest)?; + Ok(dest.display().to_string()) + } + InstallKind::OpenInstaller => { + open::that(&dest).map_err(|e| e.to_string())?; + Ok(dest.display().to_string()) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallKind { + ReplaceRunningBinary, + OpenInstaller, +} + +pub fn install_kind(asset_name: &str) -> InstallKind { + let name = asset_name.to_ascii_lowercase(); + if name.contains("2code-linux") || name.contains("2code-macos") || name.contains("2code-windows") { + return InstallKind::ReplaceRunningBinary; + } + if name.ends_with(".dmg") + || name.ends_with(".pkg") + || name.ends_with(".msi") + || name.ends_with(".appimage") + || name.ends_with(".deb") + || name.ends_with(".rpm") + { + return InstallKind::OpenInstaller; + } + InstallKind::ReplaceRunningBinary +} + +fn replace_running_binary(downloaded: &std::path::Path) -> Result<(), String> { + let current = std::env::current_exe().map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(downloaded).map_err(|e| e.to_string())?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(downloaded, perms).map_err(|e| e.to_string())?; + } + let staged = current.with_file_name(format!( + "{}.update", + current.file_name().and_then(|n| n.to_str()).unwrap_or("2code") + )); + std::fs::copy(downloaded, &staged).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&staged).map_err(|e| e.to_string())?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&staged, perms).map_err(|e| e.to_string())?; + } + match std::fs::rename(&staged, ¤t) { + Ok(()) => Ok(()), + Err(_) => { + let backup = current.with_extension("old"); + std::fs::rename(¤t, &backup).map_err(|e| e.to_string())?; + std::fs::rename(&staged, ¤t).map_err(|e| { + let _ = std::fs::rename(&backup, ¤t); + e.to_string() + }) + } + } +} + +fn asset_url(accept_beta: bool) -> Result { + let mut request = ureq::get(GITHUB_RELEASES_API) + .set("accept", "application/vnd.github+json") + .set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let releases: Vec = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + let release = pick_release(&releases, accept_beta).ok_or_else(|| "no matching GitHub release".to_string())?; + let tag = release.tag_name.replace('/', "%2F"); + let latest_url = format!("{GITHUB_RELEASE_DOWNLOAD_BASE}/{tag}/latest.json"); + if let Ok(url) = url_from_latest_json(&latest_url) { + return Ok(url); + } + let needle = platform_asset_needle(); + release + .assets + .iter() + .find(|a| needle.iter().any(|n| a.name.to_ascii_lowercase().contains(n))) + .map(|a| a.browser_download_url.clone()) + .ok_or_else(|| "no installer asset for this platform".into()) +} + +fn url_from_latest_json(url: &str) -> Result { + let mut request = ureq::get(url).set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let latest: LatestJson = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + let key = platform_latest_key(); + latest + .platforms + .get(key) + .or_else(|| latest.platforms.values().next()) + .map(|p| p.url.clone()) + .ok_or_else(|| "latest.json has no platform url".into()) +} + +fn platform_latest_key() -> &'static str { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "darwin-aarch64" + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "darwin-x86_64" + } else if cfg!(target_os = "windows") { + "windows-x86_64" + } else { + "linux-x86_64" + } +} + +fn platform_asset_needle() -> &'static [&'static str] { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + &["macos-arm64", "darwin-aarch64", "dmg", "darwin", "macos"] + } else if cfg!(target_os = "macos") { + &["macos-x64", "darwin-x86", "dmg", "darwin", "macos"] + } else if cfg!(target_os = "windows") { + &["windows-x64", "windows", "msi", "exe"] + } else { + &["linux-x64", "linux", "appimage", "deb", "rpm"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_tag_wins() { + assert!(is_newer("3.1.0", "3.0.1")); + assert!(!is_newer("3.0.1", "3.0.1")); + assert!(!is_newer("v3.0.0", "3.0.1")); + assert!(is_newer("v4.0.0-beta.1", "3.0.1")); + } + + #[test] + fn prefers_beta_when_requested() { + let releases = vec![ + GithubRelease { + tag_name: "v3.1.0".into(), + draft: false, + prerelease: false, + html_url: String::new(), + published_at: None, + assets: Vec::new(), + }, + GithubRelease { + tag_name: "v3.2.0-beta.1".into(), + draft: false, + prerelease: true, + html_url: String::new(), + published_at: None, + assets: Vec::new(), + }, + ]; + assert_eq!(pick_release(&releases, true).unwrap().tag_name, "v3.2.0-beta.1"); + assert_eq!(pick_release(&releases, false).unwrap().tag_name, "v3.1.0"); + } + + #[test] + fn formats_github_release_date() { + assert_eq!( + crate::timefmt::format_release_date_at( + "2026-04-09T12:00:00Z", + crate::i18n::Locale::En, + crate::timefmt::parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap() * 1000, + ), + "Apr 9, 2026 (now)" + ); + assert_eq!( + crate::timefmt::format_release_date_at("not-a-date", crate::i18n::Locale::En, 0), + "not-a-date" + ); + } + + #[test] + fn gpui_release_asset_names_match_platform() { + let names = platform_asset_needle(); + if cfg!(target_os = "linux") { + assert!(names.iter().any(|n| *n == "linux-x64")); + } else if cfg!(target_os = "windows") { + assert!(names.iter().any(|n| *n == "windows-x64")); + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert!(names.iter().any(|n| *n == "macos-arm64")); + } + } + + #[test] + fn gpui_binaries_replace_in_place() { + assert_eq!(install_kind("2code-linux-x64"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code-macos-arm64"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code-windows-x64.exe"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code_3.0.1_aarch64.dmg"), InstallKind::OpenInstaller); + assert_eq!(install_kind("2code-setup.msi"), InstallKind::OpenInstaller); + } +} diff --git a/src-tauri/crates/model/src/profile.rs b/src-tauri/crates/model/src/profile.rs index 7c9f943f..180795f6 100644 --- a/src-tauri/crates/model/src/profile.rs +++ b/src-tauri/crates/model/src/profile.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::project::GitDiffStats; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = profiles)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Profile { diff --git a/src-tauri/crates/model/src/project.rs b/src-tauri/crates/model/src/project.rs index 5187bfd2..ef3c692c 100644 --- a/src-tauri/crates/model/src/project.rs +++ b/src-tauri/crates/model/src/project.rs @@ -27,7 +27,7 @@ pub struct ProjectTerminalTemplate { pub commands: Vec, } -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = projects)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Project { @@ -58,7 +58,7 @@ pub struct UpdateProject { pub folder: Option, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct ProjectWithProfiles { pub id: String, pub name: String, diff --git a/src-tauri/crates/model/src/project_group.rs b/src-tauri/crates/model/src/project_group.rs index 2ac3f5c1..65bfdf6b 100644 --- a/src-tauri/crates/model/src/project_group.rs +++ b/src-tauri/crates/model/src/project_group.rs @@ -2,7 +2,7 @@ use crate::schema::project_groups; use diesel::prelude::*; use serde::Serialize; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = project_groups)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct ProjectGroup { diff --git a/src-tauri/crates/model/src/pty.rs b/src-tauri/crates/model/src/pty.rs index b1e9688f..6c9cc441 100644 --- a/src-tauri/crates/model/src/pty.rs +++ b/src-tauri/crates/model/src/pty.rs @@ -2,7 +2,7 @@ use crate::schema::pty_sessions; use diesel::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone)] #[diesel(table_name = pty_sessions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct PtySessionRecord { diff --git a/src-tauri/tests/integration_pty_db.rs b/src-tauri/tests/integration_pty_db.rs index d2e11ac6..398cc731 100644 --- a/src-tauri/tests/integration_pty_db.rs +++ b/src-tauri/tests/integration_pty_db.rs @@ -92,6 +92,20 @@ fn wait_for_flush_sender(ctx: &PtyContext, session_id: &str) { panic!("flush sender was not registered for session {session_id}"); } +fn wait_for_history_contains(logs: &Path, session_id: &str, needle: &str) -> String { + let attempts = if cfg!(windows) { 80 } else { 40 }; + let mut history_text = String::new(); + for _ in 0..attempts { + let history = service::pty::get_history(logs, session_id); + history_text = String::from_utf8_lossy(&history).into_owned(); + if history_text.contains(needle) { + return history_text; + } + std::thread::sleep(Duration::from_millis(100)); + } + history_text +} + fn pty_config(cwd: String) -> PtyConfig { PtyConfig { shell: test_shell(), @@ -500,12 +514,11 @@ fn create_session_executes_startup_commands() { ) .unwrap(); - std::thread::sleep(std::time::Duration::from_secs(2)); + wait_for_flush_sender(&ctx, &session_id); + let history_text = wait_for_history_contains(&logs, &session_id, "tmpl-ok"); infra::pty::close_all_sessions(&sessions); infra::pty::join_all_read_threads(&read_threads); - let history = service::pty::get_history(&logs, &session_id); - let history_text = String::from_utf8_lossy(&history); assert!( history_text.contains("tmpl-ok"), "history did not contain startup output: {history_text:?}", diff --git a/start.sh b/start.sh index 0d6231d9..72650906 100755 --- a/start.sh +++ b/start.sh @@ -4,4 +4,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}" -nr start +# Primary product entry: native GPUI shell. +if command -v just >/dev/null 2>&1; then + exec just gpui +fi + +cd src-gpui +exec cargo run