diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bdcf41d96..3c47b4af29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -634,6 +634,40 @@ jobs: path.write_text(json.dumps(data, indent=2) + "\n") PY + python3 - <$NEXT_VERSION\2', + content, + count=1, + ) + if count != 1: + raise SystemExit("Failed to update codex-monitor version in src-tauri/Cargo.lock") + path.write_text(content) + PY + python3 - < `src/*` +- `@app/*` -> `src/features/app/*` +- `@settings/*` -> `src/features/settings/*` +- `@threads/*` -> `src/features/threads/*` +- `@services/*` -> `src/services/*` +- `@utils/*` -> `src/utils/*` -### Backend (Daemon) +## Key File Anchors +- Frontend composition root: `src/App.tsx` +- Frontend IPC wrapper: `src/services/tauri.ts` +- Frontend event hub: `src/services/events.ts` +- App command registry: `src-tauri/src/lib.rs` - Daemon entrypoint: `src-tauri/src/bin/codex_monitor_daemon.rs` -- Daemon imports shared cores via `#[path = "../shared/mod.rs"] mod shared;` - -## Architecture Guidelines - -### Frontend Guidelines +- Daemon RPC router: `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` +- Shared workspaces core: `src-tauri/src/shared/workspaces_core.rs` + `src-tauri/src/shared/workspaces_core/*` +- Shared git UI core: `src-tauri/src/shared/git_ui_core.rs` + `src-tauri/src/shared/git_ui_core/*` +- Threads reducer entrypoint: `src/features/threads/hooks/useThreadsReducer.ts` +- Threads reducer slices: `src/features/threads/hooks/threadReducer/*` -- Composition root: keep orchestration in `src/App.tsx`. -- Components: presentational only. Props in, UI out. No Tauri IPC. -- Hooks: own state, side effects, and event wiring. -- Utils: pure helpers only in `src/utils/`. -- Services: all Tauri IPC goes through `src/services/`. -- Types: shared UI types live in `src/types.ts`. -- Styles: one CSS file per UI area under `src/styles/`. +For broader path maps, use `docs/codebase-map.md`. -Keep `src/App.tsx` lean: +## App/Daemon Parity Checklist -- Keep it to wiring: hook composition, layout, and assembly. -- Move stateful logic/effects into hooks under `src/features/app/hooks/`. -- Keep Tauri IPC, menu listeners, and subscriptions out of `src/App.tsx`. +When changing backend behavior that can run remotely: -### Design System Usage +1. Shared core logic updated (or explicitly app-only/daemon-only). +2. App surface updated (`src-tauri/src/lib.rs` + adapter). +3. Frontend IPC updated (`src/services/tauri.ts`) when needed. +4. Daemon RPC updated (`rpc.rs` + `rpc/*`) when needed. +5. Contract/test coverage updated. -Use the design-system layer for shared UI shells and tokenized styling. +## Design System Rule (High-Level) -- Primitive component locations: - - `src/features/design-system/components/modal/ModalShell.tsx` - - `src/features/design-system/components/toast/ToastPrimitives.tsx` - - `src/features/design-system/components/panel/PanelPrimitives.tsx` - - `src/features/design-system/components/popover/PopoverPrimitives.tsx` - - Toast sub-primitives: `ToastHeader`, `ToastActions`, `ToastError` (in `ToastPrimitives.tsx`) - - Panel sub-primitives: `PanelMeta`, `PanelSearchField`, `PanelNavList`, `PanelNavItem` (in `PanelPrimitives.tsx`) - - Popover sub-primitives: `PopoverMenuItem` (in `PopoverPrimitives.tsx`) -- Diff theming and style bridge: - - `src/features/design-system/diff/diffViewerTheme.ts` -- DS token/style locations: - - `src/styles/ds-tokens.css` - - `src/styles/ds-modal.css` - - `src/styles/ds-toast.css` - - `src/styles/ds-panel.css` - - `src/styles/ds-popover.css` - - `src/styles/ds-diff.css` - -Naming conventions: - -- DS CSS classes use `.ds-*` prefixes. -- DS CSS variables use `--ds-*` prefixes. -- DS React primitives use `PascalCase` component names (`ModalShell`, `ToastCard`, `ToastHeader`, `ToastActions`, `ToastError`, `PanelFrame`, `PanelHeader`, `PanelMeta`, `PanelSearchField`, `PanelNavList`, `PanelNavItem`, `PopoverSurface`, `PopoverMenuItem`). -- Feature CSS should keep feature-prefixed classes (`.worktree-*`, `.update-*`) for content/layout specifics. - -Do: - -- Use DS primitives first for shared shells (modal wrappers, toast cards/viewports, panel shells/headers, popover/dropdown surfaces). -- Pull shared visual tokens from `--ds-*` variables. -- Keep feature styles focused on feature-specific layout/content, not duplicated shell chrome. -- Centralize shared animation/chrome in DS stylesheets when used by multiple feature families. +Use existing design-system primitives and tokens for shared shell chrome. +Do not reintroduce duplicated modal/toast/panel/popover shell styling in feature CSS. -Don't: +(See existing DS files and lint guardrails for implementation details.) -- Recreate fixed modal backdrops/cards in feature CSS when `ModalShell` is used. -- Duplicate toast card chrome (background/border/shadow/padding/enter animation) per toast family. -- Duplicate panel shell layout/header alignment in feature styles when `PanelFrame`/`PanelHeader` already provide it. -- Recreate popover/dropdown shell chrome in feature CSS when `PopoverSurface`/`PopoverMenuItem` already provide it. -- Add new non-DS color constants for shared shells; add/extend DS tokens instead. +## Safety and Git Behavior -Migration guidance for new/updated components: +- Prefer safe git operations (`status`, `diff`, `log`). +- Do not reset/revert unrelated user changes. +- If unrelated changes appear, continue focusing on owned files unless they block correctness. +- If conflicts impact correctness, call them out and choose the safest path. +- Fix root cause, not band-aids. -1. Start by wrapping UI in the closest DS primitive. -2. Migrate shared shell styles into DS CSS (`ds-*.css`) and delete redundant feature-level shell selectors. -3. Keep only feature-local classes for spacing/content/interaction details. -4. For legacy selectors that are still referenced, keep minimal compatibility aliases temporarily. -5. Remove compatibility aliases once callsites reach zero, then rerun lint/typecheck/tests. +## Validation Matrix -Anti-duplication guidance: +Run validations based on touched areas: -- Before adding shell styles, search for existing DS token/primitive coverage. -- If two or more feature files need the same shell rule, move it to DS CSS immediately. -- Prefer extending DS primitives/tokens over introducing another feature-specific wrapper class. -- During refactors, remove unused legacy selectors once callsites are migrated. +- Always: `npm run typecheck` +- Frontend behavior/state/hooks/components: `npm run test` +- Rust backend changes: `cd src-tauri && cargo check` +- Use targeted tests for touched modules before full-suite runs when iterating. -Enforcement workflow: +## Quick Runbook -- Lint guardrails for DS-targeted files live in `.eslintrc.cjs`. -- Popover guardrails are enforced for migrated popover files (`MainHeader`, `Sidebar`, `SidebarHeader`, `SidebarCornerActions`, `OpenAppMenu`, `LaunchScript*`, `ComposerInput`, `FilePreviewPopover`, `WorkspaceHome`) to require `PopoverSurface`/`PopoverMenuItem`. -- Codemod scripts live in `scripts/codemods/`: - - `modal-shell-codemod.mjs` - - `panel-shell-codemod.mjs` - - `toast-shell-codemod.mjs` -- Run `npm run codemod:ds:dry` before UI shell migration PRs. -- Keep `npm run lint:ds`/`npm run lint` green for modal/toast/panel/popover/diff files. - -### Backend Guidelines - -- Shared logic goes in `src-tauri/src/shared/` first. -- App and daemon are thin adapters around shared cores. -- Avoid duplicating git/worktree/codex/settings/files logic in adapters. -- Prefer explicit, readable adapter helpers over clever abstractions. -- Do not folderize single-file features unless you are splitting them. - -## Daemon: How and When to Add Code - -The daemon runs backend logic outside the Tauri app. - -### When to Update the Daemon - -Update the daemon when one of these is true: - -- A Tauri command is used in remote mode. -- The daemon exposes the same behavior over its JSON-RPC transport. -- Shared core behavior changes and the daemon wiring must pass new inputs. - -### Where Code Goes - -1. Shared behavior or domain logic: - - Add or update code in `src-tauri/src/shared/*.rs`. -2. App-only behavior: - - Update the app adapters or Tauri commands. -3. Daemon-only transport/wiring behavior: - - Update `src-tauri/src/bin/codex_monitor_daemon.rs`. - -### How to Add a New Backend Command - -1. Implement the core logic in a shared module. -2. Wire it in the app. - - Add a Tauri command in `src-tauri/src/lib.rs`. - - Call the shared core from the appropriate adapter. - - Mirror it in `src/services/tauri.ts`. -3. Wire it in the daemon. - - Add a daemon method that calls the same shared core. - - Add the JSON-RPC handler branch in `codex_monitor_daemon.rs`. - -### Adapter Patterns to Reuse - -- Shared git unit wrapper: - - `workspaces_core::run_git_command_unit(...)` -- App spawn adapter: - - `spawn_with_app(...)` in `src-tauri/src/workspaces/commands.rs` -- Daemon spawn adapter: - - `spawn_with_client(...)` in `src-tauri/src/bin/codex_monitor_daemon.rs` -- Daemon wrapper modules: - - `mod codex { ... }` and `mod files { ... }` in `codex_monitor_daemon.rs` - -If you find yourself copying logic between app and daemon, extract it into `src-tauri/src/shared/`. - -## App-Server Flow - -- Backend spawns `codex app-server` using the `codex` binary. -- Initialize with `initialize` and then `initialized`. -- Do not send requests before initialization. -- JSON-RPC notifications stream over stdout. -- Threads are listed via `thread/list` and resumed via `thread/resume`. -- Archiving uses `thread/archive`. - -## Event Stack (Tauri → React) - -The app uses a shared event hub so each native event has one `listen` and many subscribers. - -- Backend emits: `src-tauri/src/lib.rs` emits events to the main window. -- Frontend hub: `src/services/events.ts` defines `createEventHub` and module-level hubs. -- React subscription: use `useTauriEvent(subscribeX, handler)`. - -### Adding a New Tauri Event - -1. Emit the event in `src-tauri/src/lib.rs`. -2. Add a hub and `subscribeX` helper in `src/services/events.ts`. -3. Subscribe via `useTauriEvent` in a hook or component. -4. Update `src/services/events.test.ts` if you add new subscription helpers. - -## Workspace Persistence - -- Workspaces live in `workspaces.json` under the app data directory. -- Settings live in `settings.json` under the app data directory. -- On launch, the app connects each workspace once and loads its thread list. - -## Common Changes (Where to Look First) - -- UI layout or styling: - - `src/features/*/components/*` and `src/styles/*` -- App-server events: - - `src/features/app/hooks/useAppServerEvents.ts` -- Tauri IPC shape: - - `src/services/tauri.ts` and `src-tauri/src/lib.rs` -- Shared backend behavior: - - `src-tauri/src/shared/*` -- Workspaces/worktrees: - - Shared core: `src-tauri/src/shared/workspaces_core.rs` - - App adapters: `src-tauri/src/workspaces/*` - - Daemon wiring: `src-tauri/src/bin/codex_monitor_daemon.rs` -- Settings and Codex config: - - Shared core: `src-tauri/src/shared/settings_core.rs` - - App adapters: `src-tauri/src/codex/config.rs`, `src-tauri/src/settings/mod.rs` - - Daemon wiring: `src-tauri/src/bin/codex_monitor_daemon.rs` -- Files: - - Shared core: `src-tauri/src/shared/files_core.rs` - - App adapters: `src-tauri/src/files/*` -- Codex threads/approvals/login: - - Shared core: `src-tauri/src/shared/codex_core.rs` - - App adapters: `src-tauri/src/codex/*` - - Daemon wiring: `src-tauri/src/bin/codex_monitor_daemon.rs` - -## Threads Feature Split (Frontend) - -`useThreads` is a composition layer that wires focused hooks and shared utilities. - -- Orchestration: `src/features/threads/hooks/useThreads.ts` -- Actions: `src/features/threads/hooks/useThreadActions.ts` -- Approvals: `src/features/threads/hooks/useThreadApprovals.ts` -- Event handlers: `src/features/threads/hooks/useThreadEventHandlers.ts` -- Messaging: `src/features/threads/hooks/useThreadMessaging.ts` -- Storage: `src/features/threads/hooks/useThreadStorage.ts` -- Status helpers: `src/features/threads/hooks/useThreadStatus.ts` -- Selectors: `src/features/threads/hooks/useThreadSelectors.ts` -- Rate limits: `src/features/threads/hooks/useThreadRateLimits.ts` -- Collab links: `src/features/threads/hooks/useThreadLinking.ts` - -## Running Locally +Core local commands (keep these inline for daily use): ```bash npm install -npm run tauri dev -``` - -## iOS (WIP) - -- iOS is supported as WIP. -- Simulator: -```bash -./scripts/build_run_ios.sh -``` -- USB device: -```bash -./scripts/build_run_ios_device.sh --list-devices -./scripts/build_run_ios_device.sh --device "Dimillian’s iPhone" --team Z6P74P6T99 -``` -- If signing is not ready: -```bash -./scripts/build_run_ios_device.sh --open-xcode -``` - -## Release Build - -```bash -npm run tauri build -``` - -## Type Checking - -```bash +npm run doctor:strict +npm run tauri:dev +npm run test npm run typecheck +cd src-tauri && cargo check ``` -## Tests +Release build: ```bash -npm run test +npm run tauri:build ``` +Focused test runs: + ```bash -npm run test:watch +npm run test -- ``` -## Validation - -At the end of a task: +## Hotspots -1. Run `npm run lint`. -2. Run `npm run test` when you touched threads, settings, updater, shared utils, or backend cores. -3. Run `npm run typecheck`. -4. If you changed Rust backend code, run `cargo check` in `src-tauri`. +Use extra care in high-churn/high-complexity files: -## Notes - -- The window uses `titleBarStyle: "Overlay"` and macOS private APIs for transparency. -- Avoid breaking JSON-RPC format; the app-server is strict. -- App settings and Codex feature toggles are best-effort synced to `CODEX_HOME/config.toml`. -- UI preferences live in `localStorage`. -- GitHub issues require `gh` to be installed and authenticated. -- Custom prompts are loaded from `$CODEX_HOME/prompts` (or `~/.codex/prompts`). +- `src/App.tsx` +- `src/features/settings/components/SettingsView.tsx` +- `src/features/threads/hooks/useThreadsReducer.ts` +- `src-tauri/src/shared/git_ui_core.rs` +- `src-tauri/src/shared/workspaces_core.rs` +- `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` -## Error Toasts +## Canonical References -- Use `pushErrorToast` from `src/services/toasts.ts` for user-facing errors. -- Toast wiring: - - Hook: `src/features/notifications/hooks/useErrorToasts.ts` - - UI: `src/features/notifications/components/ErrorToasts.tsx` - - Styles: `src/styles/error-toasts.css` +- Task-oriented code map: `docs/codebase-map.md` +- Setup/build/release/test commands: `README.md` diff --git a/README.md b/README.md index 41dc7b8945..115f673cb9 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ rustup target add x86_64-apple-ios ``` - Apple signing configured (development team). - - Set `bundle.iOS.developmentTeam` in `src-tauri/tauri.conf.json`, or + - Set `bundle.iOS.developmentTeam` in `src-tauri/tauri.ios.conf.json` (preferred), or - pass `--team ` to the device script. ### Run on iOS Simulator @@ -169,6 +169,17 @@ If signing is not ready yet, open Xcode from the script flow: ./scripts/build_run_ios_device.sh --open-xcode ``` +### iOS TestFlight Release (Scripted) + +Use the end-to-end script to archive, upload, configure compliance, assign beta group, and submit for beta review. + +```bash +./scripts/release_testflight_ios.sh +``` + +The script auto-loads release metadata from `.testflight.local.env` (gitignored). +For new setups, copy `.testflight.local.env.example` to `.testflight.local.env` and fill values. + ## Release Build Build the production Tauri bundle: @@ -215,18 +226,30 @@ npm run typecheck cd src-tauri && cargo check ``` +## Codebase Navigation + +For task-oriented file lookup ("if you need X, edit Y"), use: + +- `docs/codebase-map.md` + ## Project Structure ``` src/ features/ feature-sliced UI + hooks + features/app/bootstrap/ app bootstrap orchestration + features/app/orchestration/ app layout/thread/workspace orchestration + features/threads/hooks/threadReducer/ thread reducer slices services/ Tauri IPC wrapper styles/ split CSS by area types.ts shared types src-tauri/ src/lib.rs Tauri app backend command registry src/bin/codex_monitor_daemon.rs remote daemon JSON-RPC process + src/bin/codex_monitor_daemon/rpc/ daemon RPC domain handlers src/shared/ shared backend core used by app + daemon + src/shared/git_ui_core/ git/github shared core modules + src/shared/workspaces_core/ workspace/worktree shared core modules src/workspaces/ workspace/worktree adapters src/codex/ codex app-server adapters src/files/ file adapters @@ -243,7 +266,8 @@ src-tauri/ - Selecting a thread always calls `thread/resume` to refresh messages from disk. - CLI sessions appear if their `cwd` matches the workspace path; they are not live-streamed unless resumed. - The app uses `codex app-server` over stdio; see `src-tauri/src/lib.rs` and `src-tauri/src/codex/`. -- The remote daemon entrypoint is `src-tauri/src/bin/codex_monitor_daemon.rs`; shared domain logic lives in `src-tauri/src/shared/`. +- The remote daemon entrypoint is `src-tauri/src/bin/codex_monitor_daemon.rs`; RPC routing lives in `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` and domain handlers in `src-tauri/src/bin/codex_monitor_daemon/rpc/`. +- Shared domain logic lives in `src-tauri/src/shared/` (notably `src-tauri/src/shared/git_ui_core/` and `src-tauri/src/shared/workspaces_core/`). - Codex home resolves from workspace settings (if set), then legacy `.codexmonitor/`, then `$CODEX_HOME`/`~/.codex`. - Worktree agents live under the app data directory (`worktrees/`); legacy `.codex-worktrees/` paths remain supported, and the app no longer edits repo `.gitignore` files. - UI state (panel sizes, reduced transparency toggle, recent thread activity) is stored in `localStorage`. diff --git a/docs/app-server-events.md b/docs/app-server-events.md index a1d3ac7f22..45559543f8 100644 --- a/docs/app-server-events.md +++ b/docs/app-server-events.md @@ -1,4 +1,4 @@ -# App-Server Events Reference (Codex `383b45279efda1ef611a4aa286621815fe656b8a`) +# App-Server Events Reference (Codex `2c5eeb6b1fb32776b9c4d3d3ff62b55aa3c464a3`) This document helps agents quickly answer: - Which app-server events CodexMonitor supports right now. @@ -48,9 +48,10 @@ Primary outgoing request layer: ## Supported Events (Current) These are the app-server methods currently supported in -`src/utils/appServerEvents.ts` (`SUPPORTED_APP_SERVER_METHODS`) and routed in -`useAppServerEvents.ts`. +`src/utils/appServerEvents.ts` (`SUPPORTED_APP_SERVER_METHODS`) and then either +routed in `useAppServerEvents.ts` or handled in feature-specific subscriptions. +- `app/list/updated` - `codex/connected` - `*requestApproval` methods (matched via `isApprovalRequestMethod(method)`; suffix check) @@ -98,7 +99,6 @@ CodexMonitor status: Compared against Codex app-server protocol v2 notifications, the following events are currently not routed: -- `app/list/updated` - `rawResponseItem/completed` - `item/mcpToolCall/progress` - `mcpServer/oauthLogin/completed` @@ -138,6 +138,7 @@ Compared against Codex v2 request methods, CodexMonitor currently does not send: - `thread/unarchive` - `thread/rollback` +- `thread/backgroundTerminals/clean` - `thread/loaded/list` - `thread/read` - `skills/remote/read` diff --git a/docs/codebase-map.md b/docs/codebase-map.md new file mode 100644 index 0000000000..c3a92a03e7 --- /dev/null +++ b/docs/codebase-map.md @@ -0,0 +1,141 @@ +# Codebase Map (Task-Oriented) + +Canonical navigation guide for CodexMonitor. Use this as: "if you need X, edit Y". + +## Start Here: How Changes Flow + +For backend behavior, follow this path in order: + +1. Frontend callsite: `src/features/**` hooks/components +2. Frontend IPC API: `src/services/tauri.ts` +3. Tauri command registration: `src-tauri/src/lib.rs` (`invoke_handler`) +4. App adapter: `src-tauri/src/{codex,workspaces,git,files,settings,prompts}/*` +5. Shared core source of truth: `src-tauri/src/shared/*` +6. Daemon RPC method parity: `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` +7. Daemon state/wiring implementation: `src-tauri/src/bin/codex_monitor_daemon.rs` + +If a behavior must work in both app and daemon, implement it in `src-tauri/src/shared/*` first. + +## If You Need X, Edit Y + +| Need | Primary files to edit | +| --- | --- | +| App-level UI composition/layout wiring | `src/App.tsx`, `src/features/app/components/AppLayout.tsx`, `src/features/app/bootstrap/*`, `src/features/app/orchestration/*`, `src/features/app/hooks/*` | +| Add/change Tauri IPC methods used by frontend | `src/services/tauri.ts`, `src-tauri/src/lib.rs`, matching backend adapter module | +| Add/change app-server event handling in UI | `src/services/events.ts`, `src/features/app/hooks/useAppServerEvents.ts`, `src/utils/appServerEvents.ts`, `src/features/threads/utils/threadNormalize.ts` | +| Change thread state transitions | `src/features/threads/hooks/useThreadsReducer.ts`, `src/features/threads/hooks/threadReducer/*`, `src/features/threads/hooks/useThreads.ts`, focused thread hooks under `src/features/threads/hooks/*` | +| Change workspace lifecycle/worktree behavior | `src/features/workspaces/hooks/useWorkspaces.ts`, `src-tauri/src/workspaces/commands.rs`, `src-tauri/src/shared/workspaces_core.rs`, `src-tauri/src/shared/workspaces_core/*`, `src-tauri/src/shared/worktree_core.rs` | +| Change settings model/load/update | `src/features/settings/components/SettingsView.tsx`, `src/features/settings/hooks/useAppSettings.ts`, `src/services/tauri.ts`, `src-tauri/src/settings/mod.rs`, `src-tauri/src/shared/settings_core.rs`, `src-tauri/src/types.rs`, `src/types.ts` | +| Change Git/GitHub backend behavior | `src/features/git/hooks/*`, `src/services/tauri.ts`, `src-tauri/src/git/mod.rs`, `src-tauri/src/shared/git_ui_core.rs`, `src-tauri/src/shared/git_ui_core/*`, `src-tauri/src/shared/git_core.rs`, `src-tauri/src/bin/codex_monitor_daemon/rpc.rs`, `src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs` | +| Change prompts CRUD/listing behavior | `src/features/prompts/hooks/useCustomPrompts.ts`, `src/features/prompts/components/PromptPanel.tsx`, `src/services/tauri.ts`, `src-tauri/src/prompts.rs`, `src-tauri/src/shared/prompts_core.rs`, `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` | +| Change file read/write for Agents/config | `src/services/tauri.ts`, `src-tauri/src/files/mod.rs`, `src-tauri/src/shared/files_core.rs`, `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` | +| Add/change daemon JSON-RPC surface | `src-tauri/src/bin/codex_monitor_daemon/rpc.rs`, `src-tauri/src/bin/codex_monitor_daemon/rpc/*`, `src-tauri/src/bin/codex_monitor_daemon.rs`, matching shared core | + +## Frontend Navigation + +- Composition root: `src/App.tsx` +- App bootstrap orchestration: `src/features/app/bootstrap/*` +- App layout/thread/workspace orchestration: `src/features/app/orchestration/*` +- Tauri IPC wrapper: `src/services/tauri.ts` +- Tauri event hub (single-listener fanout): `src/services/events.ts` +- Event subscription hook: `src/features/app/hooks/useTauriEvent.ts` +- App-server event router: `src/features/app/hooks/useAppServerEvents.ts` +- Shared frontend types: `src/types.ts` + +### Import Aliases + +Use TS/Vite aliases for refactor-safe imports: + +- `@/*` -> `src/*` +- `@app/*` -> `src/features/app/*` +- `@settings/*` -> `src/features/settings/*` +- `@threads/*` -> `src/features/threads/*` +- `@services/*` -> `src/services/*` +- `@utils/*` -> `src/utils/*` + +### Threads + +- Orchestrator: `src/features/threads/hooks/useThreads.ts` +- Reducer composition entrypoint: `src/features/threads/hooks/useThreadsReducer.ts` +- Reducer slices: `src/features/threads/hooks/threadReducer/*` +- Event-focused handlers: `src/features/threads/hooks/useThreadEventHandlers.ts`, `src/features/threads/hooks/useThreadTurnEvents.ts`, `src/features/threads/hooks/useThreadItemEvents.ts`, `src/features/threads/hooks/useThreadApprovalEvents.ts`, `src/features/threads/hooks/useThreadUserInputEvents.ts` +- Message send/steer/interrupt: `src/features/threads/hooks/useThreadMessaging.ts` +- Persistence/local thread metadata: `src/features/threads/hooks/useThreadStorage.ts`, `src/features/threads/utils/threadStorage.ts` + +### Workspaces + +- Workspace state and lifecycle: `src/features/workspaces/hooks/useWorkspaces.ts` +- Workspace home behavior: `src/features/workspaces/hooks/useWorkspaceHome.ts` +- Workspace file list and reads in app layer: `src/features/app/hooks/useWorkspaceFileListing.ts`, `src/features/workspaces/hooks/useWorkspaceFiles.ts` + +### Settings + +- Main settings surface: `src/features/settings/components/SettingsView.tsx` +- Settings state + persistence flow: `src/features/settings/hooks/useAppSettings.ts`, `src/features/app/hooks/useAppSettingsController.ts` +- Typed settings contracts: `src/types.ts` + +### Git + +- Git UI hooks: `src/features/git/hooks/*` +- Git panel components: `src/features/git/components/*` +- Branch workflows: `src/features/git/hooks/useGitBranches.ts`, `src/features/git/hooks/useBranchSwitcher.ts` + +### Prompts + +- Prompt UI and workflow: `src/features/prompts/components/PromptPanel.tsx`, `src/features/prompts/hooks/useCustomPrompts.ts` + +## Backend App (Tauri) Navigation + +- Command registry (what frontend can invoke): `src-tauri/src/lib.rs` +- Codex adapters: `src-tauri/src/codex/mod.rs` +- Workspace/worktree adapters: `src-tauri/src/workspaces/commands.rs` +- Git adapters: `src-tauri/src/git/mod.rs` +- Settings adapters: `src-tauri/src/settings/mod.rs` +- Prompts adapters: `src-tauri/src/prompts.rs` +- File adapters: `src-tauri/src/files/mod.rs` +- Event emission implementation: `src-tauri/src/event_sink.rs` +- Event payload definitions: `src-tauri/src/backend/events.rs` + +## Daemon Navigation + +- Daemon entrypoint and state/wiring: `src-tauri/src/bin/codex_monitor_daemon.rs` +- Daemon JSON-RPC dispatcher/router: `src-tauri/src/bin/codex_monitor_daemon/rpc.rs` +- Daemon domain handlers: `src-tauri/src/bin/codex_monitor_daemon/rpc/*` +- Daemon transport: `src-tauri/src/bin/codex_monitor_daemon/transport.rs` + +When adding a new method, keep method names and payload shape aligned with `src/services/tauri.ts` and app commands in `src-tauri/src/lib.rs`. + +## Shared Cores (Source of Truth) + +All cross-runtime domain behavior belongs in `src-tauri/src/shared/*`: + +- Codex threads/approvals/account/skills/config: `src-tauri/src/shared/codex_core.rs` +- Codex helper commands: `src-tauri/src/shared/codex_aux_core.rs` +- Codex update/version helpers: `src-tauri/src/shared/codex_update_core.rs` +- Workspaces/worktrees: `src-tauri/src/shared/workspaces_core.rs`, `src-tauri/src/shared/workspaces_core/*`, `src-tauri/src/shared/worktree_core.rs` +- Settings model/update: `src-tauri/src/shared/settings_core.rs` +- Files read/write: `src-tauri/src/shared/files_core.rs` +- Git and GitHub logic: `src-tauri/src/shared/git_core.rs`, `src-tauri/src/shared/git_ui_core.rs`, `src-tauri/src/shared/git_ui_core/*` +- Prompts CRUD/listing: `src-tauri/src/shared/prompts_core.rs` +- Usage snapshot and aggregation: `src-tauri/src/shared/local_usage_core.rs` +- Orbit connectivity/auth helpers: `src-tauri/src/shared/orbit_core.rs` +- Process helpers: `src-tauri/src/shared/process_core.rs` + +## Events Map (Backend -> Frontend) + +- Backend emits through sink: `src-tauri/src/event_sink.rs` +- App-server event name: `app-server-event` +- Terminal event names: `terminal-output`, `terminal-exit` +- Frontend fanout hubs: `src/services/events.ts` +- Frontend routing into thread state: `src/features/app/hooks/useAppServerEvents.ts` -> thread hooks/reducer under `src/features/threads/hooks/*` + +If event payload format changes, update parser/guards first in `src/utils/appServerEvents.ts`. + +## Type Contract Files + +Keep Rust and TypeScript contracts in sync: + +- Rust backend types: `src-tauri/src/types.rs` +- Frontend types: `src/types.ts` + +This is required for settings, workspace metadata, app-server payload handling, and RPC response decoding. diff --git a/docs/mobile-ios-cloudflare-blueprint.md b/docs/mobile-ios-cloudflare-blueprint.md index f228909648..fc5ab64430 100644 --- a/docs/mobile-ios-cloudflare-blueprint.md +++ b/docs/mobile-ios-cloudflare-blueprint.md @@ -237,7 +237,7 @@ Implemented in shared core + daemon/app adapters: - Workspace/app extras: - `add_clone`, `apply_worktree_changes`, `open_workspace_in`, `get_open_app_icon` - Utility commands: - - `codex_doctor`, `get_commit_message_prompt`, `generate_commit_message`, `generate_run_metadata`, `local_usage_snapshot`, `send_notification_fallback`, `is_macos_debug_build`, `menu_set_accelerators` + - `codex_doctor`, `generate_commit_message`, `generate_run_metadata`, `local_usage_snapshot`, `send_notification_fallback`, `is_macos_debug_build`, `menu_set_accelerators` Out of scope for this mobile phase: diff --git a/package-lock.json b/package-lock.json index 46b98bda06..452765434f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-monitor", - "version": "0.7.47", + "version": "0.7.51", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-monitor", - "version": "0.7.47", + "version": "0.7.51", "hasInstallScript": true, "dependencies": { "@pierre/diffs": "^1.0.6", diff --git a/package.json b/package.json index 73fd55318b..f74aa43183 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-monitor", "private": true, - "version": "0.7.47", + "version": "0.7.51", "type": "module", "scripts": { "sync:material-icons": "node scripts/sync-material-icons.mjs", diff --git a/scripts/build_run_ios.sh b/scripts/build_run_ios.sh index 203e8547fb..4c69aa5677 100755 --- a/scripts/build_run_ios.sh +++ b/scripts/build_run_ios.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone Air}" TARGET="${TARGET:-aarch64-sim}" -BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" SKIP_BUILD=0 CLEAN_BUILD=1 IOS_APP_ICONSET_DIR="src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset" @@ -20,7 +20,7 @@ Builds the iOS simulator app, installs it on a booted simulator, and launches it Options: --simulator Simulator name (default: "iPhone Air") --target Tauri iOS target (default: "aarch64-sim") - --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor) + --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor.ios) --skip-build Skip the build and only install + launch the existing app --no-clean Do not remove stale src-tauri/gen/apple/build before build -h, --help Show this help diff --git a/scripts/build_run_ios_device.sh b/scripts/build_run_ios_device.sh index b3c3a98f0d..958695f8ba 100755 --- a/scripts/build_run_ios_device.sh +++ b/scripts/build_run_ios_device.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" DEVICE="" TARGET="${TARGET:-aarch64}" -BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" DEVELOPMENT_TEAM="${APPLE_DEVELOPMENT_TEAM:-}" SKIP_BUILD=0 OPEN_XCODE=0 @@ -24,7 +24,7 @@ Options: --device Required unless --list-devices is used. Accepts UDID, serial, UUID, or device name. --target Tauri iOS target (default: aarch64) - --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor) + --bundle-id Bundle id to launch (default: com.dimillian.codexmonitor.ios) --team Apple development team ID (sets APPLE_DEVELOPMENT_TEAM) --skip-build Skip build and only install + launch existing app --open-xcode Open Xcode after build instead of install/launch via devicectl @@ -112,8 +112,12 @@ sync_ios_icons() { has_configured_ios_team() { node -e ' const fs = require("fs"); - const cfg = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); - const team = cfg?.bundle?.iOS?.developmentTeam; + const baseCfg = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); + let iosCfg = {}; + try { + iosCfg = JSON.parse(fs.readFileSync("src-tauri/tauri.ios.conf.json", "utf8")); + } catch (_) {} + const team = iosCfg?.bundle?.iOS?.developmentTeam ?? baseCfg?.bundle?.iOS?.developmentTeam; process.exit(team && String(team).trim() ? 0 : 1); ' >/dev/null 2>&1 } @@ -147,7 +151,7 @@ fi if [[ "$SKIP_BUILD" -eq 0 && -z "${APPLE_DEVELOPMENT_TEAM:-}" ]]; then if ! has_configured_ios_team; then echo "Missing iOS signing team." >&2 - echo "Set one via --team or APPLE_DEVELOPMENT_TEAM, or set bundle.iOS.developmentTeam in src-tauri/tauri.conf.json." >&2 + echo "Set one via --team or APPLE_DEVELOPMENT_TEAM, or set bundle.iOS.developmentTeam in src-tauri/tauri.ios.conf.json (or src-tauri/tauri.conf.json)." >&2 echo "Tip: First-time setup can be done with --open-xcode." >&2 exit 1 fi diff --git a/scripts/release_testflight_ios.sh b/scripts/release_testflight_ios.sh new file mode 100755 index 0000000000..94c59132ec --- /dev/null +++ b/scripts/release_testflight_ios.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +DEFAULT_ENV_FILE=".testflight.local.env" +ENV_FILE="${TESTFLIGHT_ENV_FILE:-$DEFAULT_ENV_FILE}" +if [[ -f "$ENV_FILE" ]]; then + set -a + # shellcheck source=/dev/null + . "$ENV_FILE" + set +a +fi + +TARGET="${TARGET:-aarch64}" +BUNDLE_ID="${BUNDLE_ID:-com.dimillian.codexmonitor.ios}" +APP_ID="${APP_ID:-}" +IPA_PATH="${IPA_PATH:-}" +BUILD_NUMBER="${BUILD_NUMBER:-}" +LOCALE="${LOCALE:-en-US}" +BETA_GROUP_NAME="${BETA_GROUP_NAME:-Beta Testers}" +BETA_DESCRIPTION="${BETA_DESCRIPTION:-Codex Monitor iOS beta build for external testing.}" +FEEDBACK_EMAIL="${FEEDBACK_EMAIL:-}" +REVIEW_FIRST_NAME="${REVIEW_FIRST_NAME:-}" +REVIEW_LAST_NAME="${REVIEW_LAST_NAME:-}" +REVIEW_CONTACT_EMAIL="${REVIEW_CONTACT_EMAIL:-}" +REVIEW_CONTACT_PHONE="${REVIEW_CONTACT_PHONE:-}" +REVIEW_NOTES="${REVIEW_NOTES:-Codex Monitor iOS beta build for external testing.}" +SKIP_BUILD=0 +SKIP_SUBMIT=0 + +usage() { + cat <<'USAGE' +Usage: scripts/release_testflight_ios.sh [options] + +Builds iOS release IPA, uploads to App Store Connect, applies export compliance, +adds build to a TestFlight group, and submits for external beta review. + +Defaults are auto-loaded from .testflight.local.env (gitignored) when present. +Override the path with TESTFLIGHT_ENV_FILE=/path/to/file. + +Options: + --app-id App Store Connect app ID (auto-resolved by bundle id if omitted) + --bundle-id Bundle identifier (default: com.dimillian.codexmonitor.ios) + --ipa IPA path (default: src-tauri/gen/apple/build/arm64/Codex Monitor.ipa) + --target Tauri iOS target (default: aarch64) + --build-number Build number used during archive (default: current unix timestamp) + --skip-build Skip Tauri archive/export step and reuse existing IPA + --skip-submit Do not submit for external beta review + --group-name TestFlight beta group name (default: Beta Testers) + --locale Beta localization locale (default: en-US) + --beta-description Beta app description (used for localization) + --feedback-email Beta feedback email (defaults to review contact email) + +Review metadata (required for external submission if not already set in ASC): + --review-first-name + --review-last-name + --review-email + --review-phone + --review-notes + +Examples: + ./scripts/release_testflight_ios.sh + ./scripts/release_testflight_ios.sh --skip-build --ipa "src-tauri/gen/apple/build/arm64/Codex Monitor.ipa" +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app-id) + APP_ID="${2:-}" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:-}" + shift 2 + ;; + --ipa) + IPA_PATH="${2:-}" + shift 2 + ;; + --target) + TARGET="${2:-}" + shift 2 + ;; + --build-number) + BUILD_NUMBER="${2:-}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-submit) + SKIP_SUBMIT=1 + shift + ;; + --group-name) + BETA_GROUP_NAME="${2:-}" + shift 2 + ;; + --locale) + LOCALE="${2:-}" + shift 2 + ;; + --beta-description) + BETA_DESCRIPTION="${2:-}" + shift 2 + ;; + --feedback-email) + FEEDBACK_EMAIL="${2:-}" + shift 2 + ;; + --review-first-name) + REVIEW_FIRST_NAME="${2:-}" + shift 2 + ;; + --review-last-name) + REVIEW_LAST_NAME="${2:-}" + shift 2 + ;; + --review-email) + REVIEW_CONTACT_EMAIL="${2:-}" + shift 2 + ;; + --review-phone) + REVIEW_CONTACT_PHONE="${2:-}" + shift 2 + ;; + --review-notes) + REVIEW_NOTES="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +log() { + echo "[testflight] $*" +} + +fail() { + echo "[testflight] ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + fail "Missing required command: $1" + fi +} + +resolve_npm() { + if command -v npm >/dev/null 2>&1; then + command -v npm + return + fi + + for candidate in /opt/homebrew/bin/npm /usr/local/bin/npm; do + if [[ -x "$candidate" ]]; then + echo "$candidate" + return + fi + done + + if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then + # shellcheck source=/dev/null + . "${NVM_DIR}/nvm.sh" + if command -v npm >/dev/null 2>&1; then + command -v npm + return + fi + fi + + return 1 +} + +sync_ios_icons() { + local iconset_dir="src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset" + if [[ ! -d "$iconset_dir" ]]; then + return + fi + if compgen -G "src-tauri/icons/ios/*.png" >/dev/null; then + cp -f src-tauri/icons/ios/*.png "$iconset_dir"/ + fi +} + +json_get() { + local json="$1" + local expr="$2" + jq -r "$expr" <<<"$json" +} + +require_cmd asc +require_cmd jq + +log "Checking App Store Connect authentication" +asc auth status --validate >/dev/null + +if [[ -z "$APP_ID" ]]; then + log "Resolving app id for bundle id: $BUNDLE_ID" + apps_json="$(asc apps list --bundle-id "$BUNDLE_ID" --output json)" + APP_ID="$(json_get "$apps_json" '.data[0].id // empty')" + [[ -n "$APP_ID" ]] || fail "No ASC app found for bundle id '$BUNDLE_ID'" +fi + +log "Using app id: $APP_ID" + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + NPM_BIN="$(resolve_npm || true)" + [[ -n "$NPM_BIN" ]] || fail "Unable to find npm in PATH or common install locations" + + if [[ -z "$BUILD_NUMBER" ]]; then + BUILD_NUMBER="$(date +%s)" + fi + + log "Building iOS archive and exporting IPA (build number: $BUILD_NUMBER)" + sync_ios_icons + "$NPM_BIN" run tauri -- ios build --target "$TARGET" --export-method app-store-connect --build-number "$BUILD_NUMBER" --ci +fi + +if [[ -z "$IPA_PATH" ]]; then + IPA_PATH="src-tauri/gen/apple/build/arm64/Codex Monitor.ipa" +fi + +[[ -f "$IPA_PATH" ]] || fail "IPA not found at: $IPA_PATH" + +log "Uploading IPA to ASC" +asc builds upload --app "$APP_ID" --ipa "$IPA_PATH" --wait --output json >/dev/null + +latest_json="$(asc builds latest --app "$APP_ID" --platform IOS --output json)" +BUILD_ID="$(json_get "$latest_json" '.data.id // empty')" +BUILD_VERSION="$(json_get "$latest_json" '.data.attributes.version // empty')" +BUILD_UPLOADED_AT="$(json_get "$latest_json" '.data.attributes.uploadedDate // empty')" +[[ -n "$BUILD_ID" ]] || fail "Unable to resolve uploaded build id" + +log "Latest uploaded build: id=$BUILD_ID version=$BUILD_VERSION uploaded=$BUILD_UPLOADED_AT" + +beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" +internal_state="$(json_get "$beta_detail_json" '.data.attributes.internalBuildState // empty')" +external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + +if [[ "$internal_state" == "MISSING_EXPORT_COMPLIANCE" || "$external_state" == "MISSING_EXPORT_COMPLIANCE" ]]; then + log "Export compliance missing; resolving encryption declaration" + declarations_json="$(asc encryption declarations list --app "$APP_ID" --output json)" + declaration_id="$(json_get "$declarations_json" '.data[0].id // empty')" + + if [[ -z "$declaration_id" ]]; then + create_decl_json="$(asc encryption declarations create \ + --app "$APP_ID" \ + --app-description "Uses standard third-party cryptography for encrypted network transport (e.g. HTTPS/TLS)." \ + --contains-proprietary-cryptography=false \ + --contains-third-party-cryptography=true \ + --available-on-french-store=true \ + --output json)" + declaration_id="$(json_get "$create_decl_json" '.data.id // empty')" + [[ -n "$declaration_id" ]] || fail "Failed to create encryption declaration" + log "Created encryption declaration: $declaration_id" + else + log "Reusing encryption declaration: $declaration_id" + fi + + asc encryption declarations assign-builds --id "$declaration_id" --build "$BUILD_ID" --output json >/dev/null || true + + for _ in {1..12}; do + beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" + internal_state="$(json_get "$beta_detail_json" '.data.attributes.internalBuildState // empty')" + external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + if [[ "$internal_state" != "MISSING_EXPORT_COMPLIANCE" && "$external_state" != "MISSING_EXPORT_COMPLIANCE" ]]; then + break + fi + sleep 5 + done +fi + +log "Build beta state: internal=$internal_state external=$external_state" + +groups_json="$(asc testflight beta-groups list --app "$APP_ID" --output json)" +BETA_GROUP_ID="$(jq -r --arg name "$BETA_GROUP_NAME" '.data[] | select(.attributes.name == $name) | .id' <<<"$groups_json" | head -n 1)" + +if [[ -z "$BETA_GROUP_ID" ]]; then + log "Creating beta group: $BETA_GROUP_NAME" + group_create_json="$(asc testflight beta-groups create --app "$APP_ID" --name "$BETA_GROUP_NAME" --output json)" + BETA_GROUP_ID="$(json_get "$group_create_json" '.data.id // empty')" + [[ -n "$BETA_GROUP_ID" ]] || fail "Failed to create beta group" +fi + +log "Using beta group: $BETA_GROUP_NAME ($BETA_GROUP_ID)" +asc builds add-groups --build "$BUILD_ID" --group "$BETA_GROUP_ID" --output json >/dev/null || true + +if [[ -z "$FEEDBACK_EMAIL" ]]; then + FEEDBACK_EMAIL="$REVIEW_CONTACT_EMAIL" +fi + +localizations_json="$(asc beta-app-localizations list --app "$APP_ID" --output json)" +localization_id="$(jq -r --arg locale "$LOCALE" '.data[] | select(.attributes.locale == $locale) | .id' <<<"$localizations_json" | head -n 1)" + +if [[ -z "$localization_id" ]]; then + log "Creating beta localization for locale: $LOCALE" + create_loc_cmd=(asc beta-app-localizations create --app "$APP_ID" --locale "$LOCALE" --description "$BETA_DESCRIPTION" --output json) + if [[ -n "$FEEDBACK_EMAIL" ]]; then + create_loc_cmd+=(--feedback-email "$FEEDBACK_EMAIL") + fi + create_loc_json="$("${create_loc_cmd[@]}")" + localization_id="$(json_get "$create_loc_json" '.data.id // empty')" + [[ -n "$localization_id" ]] || fail "Failed to create beta app localization" +else + log "Updating beta localization for locale: $LOCALE" + update_loc_cmd=(asc beta-app-localizations update --id "$localization_id" --description "$BETA_DESCRIPTION" --output json) + if [[ -n "$FEEDBACK_EMAIL" ]]; then + update_loc_cmd+=(--feedback-email "$FEEDBACK_EMAIL") + fi + "${update_loc_cmd[@]}" >/dev/null +fi + +review_json="$(asc testflight review get --app "$APP_ID" --output json)" + +current_first_name="$(json_get "$review_json" '.data[0].attributes.contactFirstName // empty')" +current_last_name="$(json_get "$review_json" '.data[0].attributes.contactLastName // empty')" +current_contact_email="$(json_get "$review_json" '.data[0].attributes.contactEmail // empty')" +current_contact_phone="$(json_get "$review_json" '.data[0].attributes.contactPhone // empty')" + +[[ -n "$REVIEW_FIRST_NAME" ]] || REVIEW_FIRST_NAME="$current_first_name" +[[ -n "$REVIEW_LAST_NAME" ]] || REVIEW_LAST_NAME="$current_last_name" +[[ -n "$REVIEW_CONTACT_EMAIL" ]] || REVIEW_CONTACT_EMAIL="$current_contact_email" +[[ -n "$REVIEW_CONTACT_PHONE" ]] || REVIEW_CONTACT_PHONE="$current_contact_phone" + +[[ -n "$REVIEW_FIRST_NAME" ]] || fail "Missing review first name. Pass --review-first-name or set REVIEW_FIRST_NAME in $ENV_FILE" +[[ -n "$REVIEW_LAST_NAME" ]] || fail "Missing review last name. Pass --review-last-name or set REVIEW_LAST_NAME in $ENV_FILE" +[[ -n "$REVIEW_CONTACT_EMAIL" ]] || fail "Missing review email. Pass --review-email or set REVIEW_CONTACT_EMAIL in $ENV_FILE" +[[ -n "$REVIEW_CONTACT_PHONE" ]] || fail "Missing review phone. Pass --review-phone or set REVIEW_CONTACT_PHONE in $ENV_FILE" + +log "Updating beta review contact metadata" +asc testflight review update \ + --id "$APP_ID" \ + --contact-first-name "$REVIEW_FIRST_NAME" \ + --contact-last-name "$REVIEW_LAST_NAME" \ + --contact-email "$REVIEW_CONTACT_EMAIL" \ + --contact-phone "$REVIEW_CONTACT_PHONE" \ + --notes "$REVIEW_NOTES" \ + --output json >/dev/null + +beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" +external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" + +if [[ "$SKIP_SUBMIT" -eq 1 ]]; then + log "Skipping external review submit by request (--skip-submit)" + echo + echo "Build ID: $BUILD_ID" + echo "App ID: $APP_ID" + echo "Group ID: $BETA_GROUP_ID" + echo "External state:$external_state" + exit 0 +fi + +if [[ "$external_state" == "READY_FOR_BETA_SUBMISSION" ]]; then + log "Submitting build for external beta review" + set +e + submit_output="$(asc testflight review submit --build "$BUILD_ID" --confirm --output json 2>&1)" + submit_status=$? + set -e + if [[ "$submit_status" -ne 0 ]]; then + if grep -q "Another build is in review" <<<"$submit_output"; then + log "Submission blocked because another build is already in beta review" + else + echo "$submit_output" >&2 + fail "Failed to submit build for external beta review" + fi + fi + beta_detail_json="$(asc builds build-beta-detail get --build "$BUILD_ID" --output json)" + external_state="$(json_get "$beta_detail_json" '.data.attributes.externalBuildState // empty')" +elif [[ "$external_state" == "WAITING_FOR_BETA_REVIEW" || "$external_state" == "IN_BETA_TESTING" ]]; then + log "Build already submitted/available for external testing" +else + fail "Build is not ready for external submit (state: $external_state)" +fi + +echo +log "Completed TestFlight release flow" +echo "App ID: $APP_ID" +echo "Build ID: $BUILD_ID" +echo "Build version: $BUILD_VERSION" +echo "Beta group: $BETA_GROUP_NAME ($BETA_GROUP_ID)" +echo "External state: $external_state" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2d884ca5a9..d147a7f48c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "codex-monitor" -version = "0.1.0" +version = "0.7.51" dependencies = [ "base64 0.22.1", "block2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf53999ff7..f1d9e47002 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-monitor" -version = "0.1.0" +version = "0.7.51" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj index 265150e99b..aa1500bfca 100644 --- a/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj +++ b/src-tauri/gen/apple/codex-monitor.xcodeproj/project.pbxproj @@ -492,9 +492,9 @@ "-lz", "-liconv", ); - "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; @@ -529,9 +529,9 @@ "-lz", "-liconv", ); - "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.dimillian.codexmonitor.ios; PRODUCT_NAME = "Codex Monitor"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist index f16c2721b9..760d8d9edf 100644 --- a/src-tauri/gen/apple/codex-monitor_iOS/Info.plist +++ b/src-tauri/gen/apple/codex-monitor_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.7.47 + 0.7.51 CFBundleVersion - 0.7.47 + 0.7.51 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml index 8e4c75a782..fa830dc7f7 100644 --- a/src-tauri/gen/apple/project.yml +++ b/src-tauri/gen/apple/project.yml @@ -67,8 +67,8 @@ targets: ARCHS: [arm64] VALID_ARCHS: arm64 OTHER_LDFLAGS: $(inherited) -lz -liconv - LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) - LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) + LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(DT_TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME) ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 groups: [app] diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index 40bca7cf45..ecb860d259 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -52,6 +52,8 @@ fn build_initialize_params(client_version: &str) -> Value { }) } +const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); + pub(crate) struct WorkspaceSession { pub(crate) entry: WorkspaceEntry, pub(crate) child: Mutex, @@ -77,9 +79,24 @@ impl WorkspaceSession { let id = self.next_id.fetch_add(1, Ordering::SeqCst); let (tx, rx) = oneshot::channel(); self.pending.lock().await.insert(id, tx); - self.write_message(json!({ "id": id, "method": method, "params": params })) - .await?; - rx.await.map_err(|_| "request canceled".to_string()) + if let Err(error) = self + .write_message(json!({ "id": id, "method": method, "params": params })) + .await + { + self.pending.lock().await.remove(&id); + return Err(error); + } + match timeout(REQUEST_TIMEOUT, rx).await { + Ok(Ok(value)) => Ok(value), + Ok(Err(_)) => Err("request canceled".to_string()), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(format!( + "request timed out after {} seconds", + REQUEST_TIMEOUT.as_secs() + )) + } + } } pub(crate) async fn send_notification( @@ -406,6 +423,9 @@ pub(crate) async fn spawn_workspace_session( } } } + + // Ensure pending foreground requests cannot accumulate after process output ends. + session_clone.pending.lock().await.clear(); }); let workspace_id = entry.id.clone(); diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 10d9a40532..47006eb34f 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -100,6 +100,7 @@ use workspace_settings::apply_workspace_settings_update; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732"; const MAX_IN_FLIGHT_RPC_PER_CONNECTION: usize = 32; +const DAEMON_NAME: &str = "codex-monitor-daemon"; fn spawn_with_client( event_sink: DaemonEventSink, @@ -166,6 +167,8 @@ struct DaemonState { app_settings: Mutex, event_sink: DaemonEventSink, codex_login_cancels: Mutex>, + daemon_mode: String, + daemon_binary_path: Option, } #[derive(Serialize, Deserialize)] @@ -180,6 +183,14 @@ impl DaemonState { let settings_path = config.data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); let app_settings = read_settings(&settings_path).unwrap_or_default(); + let daemon_mode = if config.orbit_url.is_some() { + "orbit".to_string() + } else { + "tcp".to_string() + }; + let daemon_binary_path = std::env::current_exe() + .ok() + .and_then(|path| path.to_str().map(str::to_string)); Self { data_dir: config.data_dir.clone(), workspaces: Mutex::new(workspaces), @@ -189,9 +200,21 @@ impl DaemonState { app_settings: Mutex::new(app_settings), event_sink, codex_login_cancels: Mutex::new(HashMap::new()), + daemon_mode, + daemon_binary_path, } } + fn daemon_info(&self) -> Value { + json!({ + "name": DAEMON_NAME, + "version": env!("CARGO_PKG_VERSION"), + "pid": std::process::id(), + "mode": self.daemon_mode, + "binaryPath": self.daemon_binary_path, + }) + } + async fn list_workspaces(&self) -> Vec { workspaces_core::list_workspaces_core(&self.workspaces, &self.sessions).await } @@ -813,6 +836,7 @@ impl DaemonState { effort: Option, access_mode: Option, images: Option>, + app_mentions: Option>, collaboration_mode: Option, ) -> Result { codex_core::send_user_message_core( @@ -824,6 +848,7 @@ impl DaemonState { effort, access_mode, images, + app_mentions, collaboration_mode, ) .await @@ -836,6 +861,7 @@ impl DaemonState { turn_id: String, text: String, images: Option>, + app_mentions: Option>, ) -> Result { codex_core::turn_steer_core( &self.sessions, @@ -844,6 +870,7 @@ impl DaemonState { turn_id, text, images, + app_mentions, ) .await } @@ -902,8 +929,9 @@ impl DaemonState { workspace_id: String, cursor: Option, limit: Option, + thread_id: Option, ) -> Result { - codex_core::apps_list_core(&self.sessions, workspace_id, cursor, limit).await + codex_core::apps_list_core(&self.sessions, workspace_id, cursor, limit, thread_id).await } async fn respond_to_server_request( @@ -1110,6 +1138,15 @@ impl DaemonState { .await } + async fn checkout_github_pull_request( + &self, + workspace_id: String, + pr_number: u64, + ) -> Result<(), String> { + git_ui_core::checkout_github_pull_request_core(&self.workspaces, workspace_id, pr_number) + .await + } + async fn list_git_branches(&self, workspace_id: String) -> Result { git_ui_core::list_git_branches_core(&self.workspaces, workspace_id).await } @@ -1212,17 +1249,6 @@ impl DaemonState { codex_aux_core::codex_doctor_core(&self.app_settings, codex_bin, codex_args).await } - async fn get_commit_message_prompt(&self, workspace_id: String) -> Result { - let repo_root = - git_ui_core::resolve_repo_root_for_workspace_core(&self.workspaces, workspace_id) - .await?; - let diff = git_ui_core::collect_workspace_diff_core(&repo_root)?; - if diff.trim().is_empty() { - return Err("No changes to generate commit message for".to_string()); - } - Ok(codex_aux_core::build_commit_message_prompt(&diff)) - } - async fn generate_commit_message(&self, workspace_id: String) -> Result { let repo_root = git_ui_core::resolve_repo_root_for_workspace_core( &self.workspaces, @@ -1230,27 +1256,20 @@ impl DaemonState { ) .await?; let diff = git_ui_core::collect_workspace_diff_core(&repo_root)?; - if diff.trim().is_empty() { - return Err("No changes to generate commit message for".to_string()); - } - let prompt = codex_aux_core::build_commit_message_prompt(&diff); - let response = codex_aux_core::run_background_prompt_core( + let commit_message_prompt = { + let settings = self.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + codex_aux_core::generate_commit_message_core( &self.sessions, workspace_id, - prompt, + &diff, + &commit_message_prompt, |workspace_id, thread_id| { emit_background_thread_hide(&self.event_sink, workspace_id, thread_id); }, - "Timeout waiting for commit message generation", - "Unknown error during commit message generation", ) - .await?; - - let trimmed = response.trim().to_string(); - if trimmed.is_empty() { - return Err("No commit message was generated".to_string()); - } - Ok(trimmed) + .await } async fn generate_run_metadata( @@ -1258,47 +1277,15 @@ impl DaemonState { workspace_id: String, prompt: String, ) -> Result { - let cleaned_prompt = prompt.trim(); - if cleaned_prompt.is_empty() { - return Err("Prompt is required.".to_string()); - } - - let title_prompt = codex_aux_core::build_run_metadata_prompt(cleaned_prompt); - let response_text = codex_aux_core::run_background_prompt_core( + codex_aux_core::generate_run_metadata_core( &self.sessions, workspace_id, - title_prompt, + &prompt, |workspace_id, thread_id| { emit_background_thread_hide(&self.event_sink, workspace_id, thread_id); }, - "Timeout waiting for metadata generation", - "Unknown error during metadata generation", ) - .await?; - - let trimmed = response_text.trim(); - if trimmed.is_empty() { - return Err("No metadata was generated".to_string()); - } - let json_value = codex_aux_core::extract_json_value(trimmed) - .ok_or_else(|| "Failed to parse metadata JSON".to_string())?; - let title = json_value - .get("title") - .and_then(|v| v.as_str()) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .ok_or_else(|| "Missing title in metadata".to_string())?; - let worktree_name = json_value - .get("worktreeName") - .or_else(|| json_value.get("worktree_name")) - .and_then(|v| v.as_str()) - .map(codex_aux_core::sanitize_run_worktree_name) - .filter(|v| !v.is_empty()) - .ok_or_else(|| "Missing worktree name in metadata".to_string())?; - Ok(json!({ - "title": title, - "worktreeName": worktree_name - })) + .await } async fn local_usage_snapshot( @@ -1637,6 +1624,8 @@ mod tests { app_settings: Mutex::new(AppSettings::default()), event_sink: DaemonEventSink { tx }, codex_login_cancels: Mutex::new(HashMap::new()), + daemon_mode: "tcp".to_string(), + daemon_binary_path: Some("/tmp/codex-monitor-daemon".to_string()), } } @@ -1743,6 +1732,34 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); }); } + + #[test] + fn rpc_daemon_info_reports_identity() { + run_async_test(async { + let tmp = make_temp_dir("rpc-daemon-info"); + let state = test_state(&tmp); + + let result = rpc::handle_rpc_request( + &state, + "daemon_info", + json!({}), + "daemon-test".to_string(), + ) + .await + .expect("daemon_info should succeed"); + + assert_eq!( + result.get("name").and_then(Value::as_str), + Some(DAEMON_NAME) + ); + assert_eq!(result.get("mode").and_then(Value::as_str), Some("tcp")); + assert_eq!( + result.get("version").and_then(Value::as_str), + Some(env!("CARGO_PKG_VERSION")) + ); + let _ = std::fs::remove_dir_all(&tmp); + }); + } } fn main() { diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs index b6294bc5fd..f7dc160e23 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc.rs @@ -1,5 +1,18 @@ use super::*; +#[path = "rpc/codex.rs"] +mod codex; +#[path = "rpc/daemon.rs"] +mod daemon; +#[path = "rpc/dispatcher.rs"] +mod dispatcher; +#[path = "rpc/git.rs"] +mod git; +#[path = "rpc/prompts.rs"] +mod prompts; +#[path = "rpc/workspace.rs"] +mod workspace; + pub(super) fn build_error_response(id: Option, message: &str) -> Option { let id = id?; Some( @@ -51,7 +64,7 @@ pub(super) fn parse_auth_token(params: &Value) -> Option { } } -fn parse_string(value: &Value, key: &str) -> Result { +pub(super) fn parse_string(value: &Value, key: &str) -> Result { match value { Value::Object(map) => map .get(key) @@ -62,7 +75,7 @@ fn parse_string(value: &Value, key: &str) -> Result { } } -fn parse_optional_string(value: &Value, key: &str) -> Option { +pub(super) fn parse_optional_string(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map .get(key) @@ -72,7 +85,7 @@ fn parse_optional_string(value: &Value, key: &str) -> Option { } } -fn parse_optional_u32(value: &Value, key: &str) -> Option { +pub(super) fn parse_optional_u32(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).and_then(|value| value.as_u64()).and_then(|v| { if v > u32::MAX as u64 { @@ -85,14 +98,14 @@ fn parse_optional_u32(value: &Value, key: &str) -> Option { } } -fn parse_optional_bool(value: &Value, key: &str) -> Option { +pub(super) fn parse_optional_bool(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).and_then(|value| value.as_bool()), _ => None, } } -fn parse_optional_string_array(value: &Value, key: &str) -> Option> { +pub(super) fn parse_optional_string_array(value: &Value, key: &str) -> Option> { match value { Value::Object(map) => map .get(key) @@ -107,712 +120,24 @@ fn parse_optional_string_array(value: &Value, key: &str) -> Option> } } -fn parse_string_array(value: &Value, key: &str) -> Result, String> { +pub(super) fn parse_string_array(value: &Value, key: &str) -> Result, String> { parse_optional_string_array(value, key).ok_or_else(|| format!("missing `{key}`")) } -fn parse_optional_value(value: &Value, key: &str) -> Option { +pub(super) fn parse_optional_value(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).cloned(), _ => None, } } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FileReadRequest { - scope: file_policy::FileScope, - kind: file_policy::FileKind, - workspace_id: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FileWriteRequest { - scope: file_policy::FileScope, - kind: file_policy::FileKind, - workspace_id: Option, - content: String, -} - -fn parse_file_read_request(params: &Value) -> Result { - serde_json::from_value(params.clone()).map_err(|err| err.to_string()) -} - -fn parse_file_write_request(params: &Value) -> Result { - serde_json::from_value(params.clone()).map_err(|err| err.to_string()) -} - pub(super) async fn handle_rpc_request( state: &DaemonState, method: &str, params: Value, client_version: String, ) -> Result { - match method { - "ping" => Ok(json!({ "ok": true })), - "daemon_shutdown" => { - tokio::spawn(async { - sleep(Duration::from_millis(100)).await; - std::process::exit(0); - }); - Ok(json!({ "ok": true })) - } - "list_workspaces" => { - let workspaces = state.list_workspaces().await; - serde_json::to_value(workspaces).map_err(|err| err.to_string()) - } - "is_workspace_path_dir" => { - let path = parse_string(¶ms, "path")?; - let is_dir = state.is_workspace_path_dir(path).await; - serde_json::to_value(is_dir).map_err(|err| err.to_string()) - } - "add_workspace" => { - let path = parse_string(¶ms, "path")?; - let codex_bin = parse_optional_string(¶ms, "codex_bin"); - let workspace = state.add_workspace(path, codex_bin, client_version).await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "add_worktree" => { - let parent_id = parse_string(¶ms, "parentId")?; - let branch = parse_string(¶ms, "branch")?; - let name = parse_optional_string(¶ms, "name"); - let copy_agents_md = parse_optional_bool(¶ms, "copyAgentsMd").unwrap_or(true); - let workspace = state - .add_worktree(parent_id, branch, name, copy_agents_md, client_version) - .await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "worktree_setup_status" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let status = state.worktree_setup_status(workspace_id).await?; - serde_json::to_value(status).map_err(|err| err.to_string()) - } - "worktree_setup_mark_ran" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.worktree_setup_mark_ran(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "connect_workspace" => { - let id = parse_string(¶ms, "id")?; - state.connect_workspace(id, client_version).await?; - Ok(json!({ "ok": true })) - } - "remove_workspace" => { - let id = parse_string(¶ms, "id")?; - state.remove_workspace(id).await?; - Ok(json!({ "ok": true })) - } - "remove_worktree" => { - let id = parse_string(¶ms, "id")?; - state.remove_worktree(id).await?; - Ok(json!({ "ok": true })) - } - "rename_worktree" => { - let id = parse_string(¶ms, "id")?; - let branch = parse_string(¶ms, "branch")?; - let workspace = state.rename_worktree(id, branch, client_version).await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "rename_worktree_upstream" => { - let id = parse_string(¶ms, "id")?; - let old_branch = parse_string(¶ms, "oldBranch")?; - let new_branch = parse_string(¶ms, "newBranch")?; - state - .rename_worktree_upstream(id, old_branch, new_branch) - .await?; - Ok(json!({ "ok": true })) - } - "update_workspace_settings" => { - let id = parse_string(¶ms, "id")?; - let settings_value = match params { - Value::Object(map) => map.get("settings").cloned().unwrap_or(Value::Null), - _ => Value::Null, - }; - let settings: WorkspaceSettings = - serde_json::from_value(settings_value).map_err(|err| err.to_string())?; - let workspace = state - .update_workspace_settings(id, settings, client_version) - .await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "update_workspace_codex_bin" => { - let id = parse_string(¶ms, "id")?; - let codex_bin = parse_optional_string(¶ms, "codex_bin"); - let workspace = state.update_workspace_codex_bin(id, codex_bin).await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "list_workspace_files" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let files = state.list_workspace_files(workspace_id).await?; - serde_json::to_value(files).map_err(|err| err.to_string()) - } - "read_workspace_file" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - let response = state.read_workspace_file(workspace_id, path).await?; - serde_json::to_value(response).map_err(|err| err.to_string()) - } - "file_read" => { - let request = parse_file_read_request(¶ms)?; - let response = state - .file_read(request.scope, request.kind, request.workspace_id) - .await?; - serde_json::to_value(response).map_err(|err| err.to_string()) - } - "file_write" => { - let request = parse_file_write_request(¶ms)?; - state - .file_write( - request.scope, - request.kind, - request.workspace_id, - request.content, - ) - .await?; - serde_json::to_value(json!({ "ok": true })).map_err(|err| err.to_string()) - } - "forge_list_bundled_templates" => { - let templates = state.forge_list_bundled_templates().await?; - serde_json::to_value(templates).map_err(|err| err.to_string()) - } - "forge_get_installed_template" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let installed = state.forge_get_installed_template(workspace_id).await?; - serde_json::to_value(installed).map_err(|err| err.to_string()) - } - "forge_install_template" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let template_id = parse_string(¶ms, "templateId")?; - let installed = state - .forge_install_template(workspace_id, template_id) - .await?; - serde_json::to_value(installed).map_err(|err| err.to_string()) - } - "forge_uninstall_template" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.forge_uninstall_template(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "forge_list_plans" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plans = state.forge_list_plans(workspace_id).await?; - serde_json::to_value(plans).map_err(|err| err.to_string()) - } - "forge_get_plan_prompt" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let prompt = state.forge_get_plan_prompt(workspace_id).await?; - serde_json::to_value(prompt).map_err(|err| err.to_string()) - } - "forge_prepare_execution" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plan_id = parse_string(¶ms, "planId")?; - state.forge_prepare_execution(workspace_id, plan_id).await?; - Ok(json!({ "ok": true })) - } - "forge_reset_execution_progress" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plan_id = parse_string(¶ms, "planId")?; - state - .forge_reset_execution_progress(workspace_id, plan_id) - .await?; - Ok(json!({ "ok": true })) - } - "forge_get_next_phase_prompt" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plan_id = parse_string(¶ms, "planId")?; - let prompt = state.forge_get_next_phase_prompt(workspace_id, plan_id).await?; - serde_json::to_value(prompt).map_err(|err| err.to_string()) - } - "forge_get_phase_status" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plan_id = parse_string(¶ms, "planId")?; - let task_id = parse_string(¶ms, "taskId")?; - let phase_id = parse_string(¶ms, "phaseId")?; - let status = state - .forge_get_phase_status(workspace_id, plan_id, task_id, phase_id) - .await?; - serde_json::to_value(status).map_err(|err| err.to_string()) - } - "forge_run_phase_checks" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let plan_id = parse_string(¶ms, "planId")?; - let task_id = parse_string(¶ms, "taskId")?; - let phase_id = parse_string(¶ms, "phaseId")?; - let result = state - .forge_run_phase_checks(workspace_id, plan_id, task_id, phase_id) - .await?; - serde_json::to_value(result).map_err(|err| err.to_string()) - } - "get_app_settings" => { - let settings = state.get_app_settings().await; - serde_json::to_value(settings).map_err(|err| err.to_string()) - } - "update_app_settings" => { - let settings_value = match params { - Value::Object(map) => map.get("settings").cloned().unwrap_or(Value::Null), - _ => Value::Null, - }; - let settings: AppSettings = - serde_json::from_value(settings_value).map_err(|err| err.to_string())?; - let updated = state.update_app_settings(settings).await?; - serde_json::to_value(updated).map_err(|err| err.to_string()) - } - "orbit_connect_test" => { - let result = state.orbit_connect_test().await?; - serde_json::to_value(result).map_err(|err| err.to_string()) - } - "orbit_sign_in_start" => { - let result = state.orbit_sign_in_start().await?; - serde_json::to_value(result).map_err(|err| err.to_string()) - } - "orbit_sign_in_poll" => { - let device_code = parse_string(¶ms, "deviceCode")?; - let result = state.orbit_sign_in_poll(device_code).await?; - serde_json::to_value(result).map_err(|err| err.to_string()) - } - "orbit_sign_out" => { - let result = state.orbit_sign_out().await?; - serde_json::to_value(result).map_err(|err| err.to_string()) - } - "get_codex_config_path" => { - let path = settings_core::get_codex_config_path_core()?; - Ok(Value::String(path)) - } - "get_config_model" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.get_config_model(workspace_id).await - } - "start_thread" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.start_thread(workspace_id).await - } - "resume_thread" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - state.resume_thread(workspace_id, thread_id).await - } - "fork_thread" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - state.fork_thread(workspace_id, thread_id).await - } - "list_threads" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let cursor = parse_optional_string(¶ms, "cursor"); - let limit = parse_optional_u32(¶ms, "limit"); - let sort_key = parse_optional_string(¶ms, "sortKey"); - state - .list_threads(workspace_id, cursor, limit, sort_key) - .await - } - "list_mcp_server_status" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let cursor = parse_optional_string(¶ms, "cursor"); - let limit = parse_optional_u32(¶ms, "limit"); - state - .list_mcp_server_status(workspace_id, cursor, limit) - .await - } - "archive_thread" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - state.archive_thread(workspace_id, thread_id).await - } - "compact_thread" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - state.compact_thread(workspace_id, thread_id).await - } - "set_thread_name" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - let name = parse_string(¶ms, "name")?; - state.set_thread_name(workspace_id, thread_id, name).await - } - "send_user_message" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - let text = parse_string(¶ms, "text")?; - let model = parse_optional_string(¶ms, "model"); - let effort = parse_optional_string(¶ms, "effort"); - let access_mode = parse_optional_string(¶ms, "accessMode"); - let images = parse_optional_string_array(¶ms, "images"); - let collaboration_mode = parse_optional_value(¶ms, "collaborationMode"); - state - .send_user_message( - workspace_id, - thread_id, - text, - model, - effort, - access_mode, - images, - collaboration_mode, - ) - .await - } - "turn_interrupt" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - let turn_id = parse_string(¶ms, "turnId")?; - state.turn_interrupt(workspace_id, thread_id, turn_id).await - } - "turn_steer" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - let turn_id = parse_string(¶ms, "turnId")?; - let text = parse_string(¶ms, "text")?; - let images = parse_optional_string_array(¶ms, "images"); - state - .turn_steer(workspace_id, thread_id, turn_id, text, images) - .await - } - "start_review" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let thread_id = parse_string(¶ms, "threadId")?; - let target = params - .as_object() - .and_then(|map| map.get("target")) - .cloned() - .ok_or("missing `target`")?; - let delivery = parse_optional_string(¶ms, "delivery"); - state - .start_review(workspace_id, thread_id, target, delivery) - .await - } - "model_list" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.model_list(workspace_id).await - } - "collaboration_mode_list" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.collaboration_mode_list(workspace_id).await - } - "account_rate_limits" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.account_rate_limits(workspace_id).await - } - "account_read" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.account_read(workspace_id).await - } - "codex_login" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.codex_login(workspace_id).await - } - "codex_login_cancel" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.codex_login_cancel(workspace_id).await - } - "skills_list" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.skills_list(workspace_id).await - } - "apps_list" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let cursor = parse_optional_string(¶ms, "cursor"); - let limit = parse_optional_u32(¶ms, "limit"); - state.apps_list(workspace_id, cursor, limit).await - } - "respond_to_server_request" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let map = params.as_object().ok_or("missing requestId")?; - let request_id = map - .get("requestId") - .cloned() - .filter(|value| value.is_number() || value.is_string()) - .ok_or("missing requestId")?; - let result = map.get("result").cloned().ok_or("missing `result`")?; - state - .respond_to_server_request(workspace_id, request_id, result) - .await - } - "remember_approval_rule" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let command = parse_string_array(¶ms, "command")?; - state.remember_approval_rule(workspace_id, command).await - } - "add_clone" => { - let source_workspace_id = parse_string(¶ms, "sourceWorkspaceId")?; - let copies_folder = parse_string(¶ms, "copiesFolder")?; - let copy_name = parse_string(¶ms, "copyName")?; - let workspace = state - .add_clone( - source_workspace_id, - copies_folder, - copy_name, - client_version, - ) - .await?; - serde_json::to_value(workspace).map_err(|err| err.to_string()) - } - "apply_worktree_changes" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.apply_worktree_changes(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "open_workspace_in" => { - let path = parse_string(¶ms, "path")?; - let app = parse_optional_string(¶ms, "app"); - let command = parse_optional_string(¶ms, "command"); - let args = parse_optional_string_array(¶ms, "args").unwrap_or_default(); - state.open_workspace_in(path, app, args, command).await?; - Ok(json!({ "ok": true })) - } - "get_open_app_icon" => { - let app_name = parse_string(¶ms, "appName")?; - let icon = state.get_open_app_icon(app_name).await?; - serde_json::to_value(icon).map_err(|err| err.to_string()) - } - "get_git_status" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.get_git_status(workspace_id).await - } - "list_git_roots" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let depth = parse_optional_u32(¶ms, "depth").map(|value| value as usize); - let roots = state.list_git_roots(workspace_id, depth).await?; - serde_json::to_value(roots).map_err(|err| err.to_string()) - } - "get_git_diffs" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let diffs = state.get_git_diffs(workspace_id).await?; - serde_json::to_value(diffs).map_err(|err| err.to_string()) - } - "get_git_log" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let limit = parse_optional_u32(¶ms, "limit").map(|value| value as usize); - let log = state.get_git_log(workspace_id, limit).await?; - serde_json::to_value(log).map_err(|err| err.to_string()) - } - "get_git_commit_diff" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let sha = parse_string(¶ms, "sha")?; - let diff = state.get_git_commit_diff(workspace_id, sha).await?; - serde_json::to_value(diff).map_err(|err| err.to_string()) - } - "get_git_remote" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let remote = state.get_git_remote(workspace_id).await?; - serde_json::to_value(remote).map_err(|err| err.to_string()) - } - "stage_git_file" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - state.stage_git_file(workspace_id, path).await?; - Ok(json!({ "ok": true })) - } - "stage_git_all" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.stage_git_all(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "unstage_git_file" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - state.unstage_git_file(workspace_id, path).await?; - Ok(json!({ "ok": true })) - } - "revert_git_file" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - state.revert_git_file(workspace_id, path).await?; - Ok(json!({ "ok": true })) - } - "revert_git_all" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.revert_git_all(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "commit_git" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let message = parse_string(¶ms, "message")?; - state.commit_git(workspace_id, message).await?; - Ok(json!({ "ok": true })) - } - "push_git" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.push_git(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "pull_git" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.pull_git(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "fetch_git" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.fetch_git(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "sync_git" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.sync_git(workspace_id).await?; - Ok(json!({ "ok": true })) - } - "get_github_issues" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let issues = state.get_github_issues(workspace_id).await?; - serde_json::to_value(issues).map_err(|err| err.to_string()) - } - "get_github_pull_requests" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let prs = state.get_github_pull_requests(workspace_id).await?; - serde_json::to_value(prs).map_err(|err| err.to_string()) - } - "get_github_pull_request_diff" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let pr_number = - parse_optional_u64(¶ms, "prNumber").ok_or("missing or invalid `prNumber`")?; - let diff = state - .get_github_pull_request_diff(workspace_id, pr_number) - .await?; - serde_json::to_value(diff).map_err(|err| err.to_string()) - } - "get_github_pull_request_comments" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let pr_number = - parse_optional_u64(¶ms, "prNumber").ok_or("missing or invalid `prNumber`")?; - let comments = state - .get_github_pull_request_comments(workspace_id, pr_number) - .await?; - serde_json::to_value(comments).map_err(|err| err.to_string()) - } - "list_git_branches" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - state.list_git_branches(workspace_id).await - } - "checkout_git_branch" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let name = parse_string(¶ms, "name")?; - state.checkout_git_branch(workspace_id, name).await?; - Ok(json!({ "ok": true })) - } - "create_git_branch" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let name = parse_string(¶ms, "name")?; - state.create_git_branch(workspace_id, name).await?; - Ok(json!({ "ok": true })) - } - "prompts_list" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let prompts = state.prompts_list(workspace_id).await?; - serde_json::to_value(prompts).map_err(|err| err.to_string()) - } - "prompts_workspace_dir" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let dir = state.prompts_workspace_dir(workspace_id).await?; - Ok(Value::String(dir)) - } - "prompts_global_dir" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let dir = state.prompts_global_dir(workspace_id).await?; - Ok(Value::String(dir)) - } - "prompts_create" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let scope = parse_string(¶ms, "scope")?; - let name = parse_string(¶ms, "name")?; - let description = parse_optional_string(¶ms, "description"); - let argument_hint = parse_optional_string(¶ms, "argumentHint"); - let content = parse_string(¶ms, "content")?; - let prompt = state - .prompts_create( - workspace_id, - scope, - name, - description, - argument_hint, - content, - ) - .await?; - serde_json::to_value(prompt).map_err(|err| err.to_string()) - } - "prompts_update" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - let name = parse_string(¶ms, "name")?; - let description = parse_optional_string(¶ms, "description"); - let argument_hint = parse_optional_string(¶ms, "argumentHint"); - let content = parse_string(¶ms, "content")?; - let prompt = state - .prompts_update( - workspace_id, - path, - name, - description, - argument_hint, - content, - ) - .await?; - serde_json::to_value(prompt).map_err(|err| err.to_string()) - } - "prompts_delete" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - state.prompts_delete(workspace_id, path).await?; - Ok(json!({ "ok": true })) - } - "prompts_move" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let path = parse_string(¶ms, "path")?; - let scope = parse_string(¶ms, "scope")?; - let prompt = state.prompts_move(workspace_id, path, scope).await?; - serde_json::to_value(prompt).map_err(|err| err.to_string()) - } - "codex_doctor" => { - let codex_bin = parse_optional_string(¶ms, "codexBin"); - let codex_args = parse_optional_string(¶ms, "codexArgs"); - state.codex_doctor(codex_bin, codex_args).await - } - "get_commit_message_prompt" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let prompt = state.get_commit_message_prompt(workspace_id).await?; - Ok(Value::String(prompt)) - } - "generate_commit_message" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let message = state.generate_commit_message(workspace_id).await?; - Ok(Value::String(message)) - } - "generate_run_metadata" => { - let workspace_id = parse_string(¶ms, "workspaceId")?; - let prompt = parse_string(¶ms, "prompt")?; - state.generate_run_metadata(workspace_id, prompt).await - } - "local_usage_snapshot" => { - let days = parse_optional_u32(¶ms, "days"); - let workspace_path = parse_optional_string(¶ms, "workspacePath"); - let snapshot = state.local_usage_snapshot(days, workspace_path).await?; - serde_json::to_value(snapshot).map_err(|err| err.to_string()) - } - "menu_set_accelerators" => { - let updates: Vec = match ¶ms { - Value::Object(map) => map - .get("updates") - .cloned() - .map(serde_json::from_value) - .transpose() - .map_err(|err| err.to_string())? - .unwrap_or_default(), - _ => Vec::new(), - }; - state.menu_set_accelerators(updates).await?; - Ok(json!({ "ok": true })) - } - "is_macos_debug_build" => { - let is_debug = state.is_macos_debug_build().await; - Ok(Value::Bool(is_debug)) - } - "send_notification_fallback" => { - let title = parse_string(¶ms, "title")?; - let body = parse_string(¶ms, "body")?; - state.send_notification_fallback(title, body).await?; - Ok(json!({ "ok": true })) - } - _ => Err(format!("unknown method: {method}")), - } + dispatcher::dispatch_rpc_request(state, method, ¶ms, &client_version).await } pub(super) async fn forward_events( diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs new file mode 100644 index 0000000000..35e75ffe26 --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/codex.rs @@ -0,0 +1,338 @@ +use super::*; + +pub(super) async fn try_handle( + state: &DaemonState, + method: &str, + params: &Value, +) -> Option> { + match method { + "get_codex_config_path" => { + let path = match settings_core::get_codex_config_path_core() { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(Ok(Value::String(path))) + } + "get_config_model" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.get_config_model(workspace_id).await) + } + "start_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.start_thread(workspace_id).await) + } + "resume_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.resume_thread(workspace_id, thread_id).await) + } + "fork_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.fork_thread(workspace_id, thread_id).await) + } + "list_threads" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let cursor = parse_optional_string(params, "cursor"); + let limit = parse_optional_u32(params, "limit"); + let sort_key = parse_optional_string(params, "sortKey"); + Some( + state + .list_threads(workspace_id, cursor, limit, sort_key) + .await, + ) + } + "list_mcp_server_status" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let cursor = parse_optional_string(params, "cursor"); + let limit = parse_optional_u32(params, "limit"); + Some( + state + .list_mcp_server_status(workspace_id, cursor, limit) + .await, + ) + } + "archive_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.archive_thread(workspace_id, thread_id).await) + } + "compact_thread" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.compact_thread(workspace_id, thread_id).await) + } + "set_thread_name" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = match parse_string(params, "name") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.set_thread_name(workspace_id, thread_id, name).await) + } + "send_user_message" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let text = match parse_string(params, "text") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let model = parse_optional_string(params, "model"); + let effort = parse_optional_string(params, "effort"); + let access_mode = parse_optional_string(params, "accessMode"); + let images = parse_optional_string_array(params, "images"); + let app_mentions = parse_optional_value(params, "appMentions") + .and_then(|value| value.as_array().cloned()); + let collaboration_mode = parse_optional_value(params, "collaborationMode"); + Some( + state + .send_user_message( + workspace_id, + thread_id, + text, + model, + effort, + access_mode, + images, + app_mentions, + collaboration_mode, + ) + .await, + ) + } + "turn_interrupt" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let turn_id = match parse_string(params, "turnId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.turn_interrupt(workspace_id, thread_id, turn_id).await) + } + "turn_steer" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let turn_id = match parse_string(params, "turnId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let text = match parse_string(params, "text") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let images = parse_optional_string_array(params, "images"); + let app_mentions = parse_optional_value(params, "appMentions") + .and_then(|value| value.as_array().cloned()); + Some( + state + .turn_steer(workspace_id, thread_id, turn_id, text, images, app_mentions) + .await, + ) + } + "start_review" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let thread_id = match parse_string(params, "threadId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let target = match params + .as_object() + .and_then(|map| map.get("target")) + .cloned() + .ok_or("missing `target`") + { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let delivery = parse_optional_string(params, "delivery"); + Some( + state + .start_review(workspace_id, thread_id, target, delivery) + .await, + ) + } + "model_list" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.model_list(workspace_id).await) + } + "collaboration_mode_list" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.collaboration_mode_list(workspace_id).await) + } + "account_rate_limits" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.account_rate_limits(workspace_id).await) + } + "account_read" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.account_read(workspace_id).await) + } + "codex_login" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.codex_login(workspace_id).await) + } + "codex_login_cancel" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.codex_login_cancel(workspace_id).await) + } + "skills_list" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.skills_list(workspace_id).await) + } + "apps_list" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let cursor = parse_optional_string(params, "cursor"); + let limit = parse_optional_u32(params, "limit"); + let thread_id = parse_optional_string(params, "threadId"); + Some( + state + .apps_list(workspace_id, cursor, limit, thread_id) + .await, + ) + } + "respond_to_server_request" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let map = match params.as_object().ok_or("missing requestId") { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let request_id = match map + .get("requestId") + .cloned() + .filter(|value| value.is_number() || value.is_string()) + .ok_or("missing requestId") + { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let result = match map.get("result").cloned().ok_or("missing `result`") { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + Some( + state + .respond_to_server_request(workspace_id, request_id, result) + .await, + ) + } + "remember_approval_rule" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let command = match parse_string_array(params, "command") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.remember_approval_rule(workspace_id, command).await) + } + "codex_doctor" => { + let codex_bin = parse_optional_string(params, "codexBin"); + let codex_args = parse_optional_string(params, "codexArgs"); + Some(state.codex_doctor(codex_bin, codex_args).await) + } + "generate_run_metadata" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prompt = match parse_string(params, "prompt") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.generate_run_metadata(workspace_id, prompt).await) + } + _ => None, + } +} diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/daemon.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/daemon.rs new file mode 100644 index 0000000000..e491f0372e --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/daemon.rs @@ -0,0 +1,60 @@ +use super::*; + +pub(super) async fn try_handle( + state: &DaemonState, + method: &str, + params: &Value, +) -> Option> { + match method { + "ping" => Some(Ok(json!({ "ok": true }))), + "daemon_info" => Some(Ok(state.daemon_info())), + "daemon_shutdown" => { + tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + std::process::exit(0); + }); + Some(Ok(json!({ "ok": true }))) + } + "menu_set_accelerators" => { + let updates: Vec = match params { + Value::Object(map) => match map + .get("updates") + .cloned() + .map(serde_json::from_value) + .transpose() + { + Ok(value) => value.unwrap_or_default(), + Err(err) => return Some(Err(err.to_string())), + }, + _ => Vec::new(), + }; + Some( + state + .menu_set_accelerators(updates) + .await + .map(|_| json!({ "ok": true })), + ) + } + "is_macos_debug_build" => { + let is_debug = state.is_macos_debug_build().await; + Some(Ok(Value::Bool(is_debug))) + } + "send_notification_fallback" => { + let title = match parse_string(params, "title") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let body = match parse_string(params, "body") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .send_notification_fallback(title, body) + .await + .map(|_| json!({ "ok": true })), + ) + } + _ => None, + } +} diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/dispatcher.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/dispatcher.rs new file mode 100644 index 0000000000..781b3d7bbc --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/dispatcher.rs @@ -0,0 +1,30 @@ +use super::*; + +pub(super) async fn dispatch_rpc_request( + state: &DaemonState, + method: &str, + params: &Value, + client_version: &str, +) -> Result { + if let Some(result) = daemon::try_handle(state, method, params).await { + return result; + } + + if let Some(result) = workspace::try_handle(state, method, params, client_version).await { + return result; + } + + if let Some(result) = codex::try_handle(state, method, params).await { + return result; + } + + if let Some(result) = git::try_handle(state, method, params).await { + return result; + } + + if let Some(result) = prompts::try_handle(state, method, params).await { + return result; + } + + Err(format!("unknown method: {method}")) +} diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs new file mode 100644 index 0000000000..dbcb8e9d32 --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -0,0 +1,345 @@ +use super::*; + +pub(super) async fn try_handle( + state: &DaemonState, + method: &str, + params: &Value, +) -> Option> { + match method { + "get_git_status" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.get_git_status(workspace_id).await) + } + "list_git_roots" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let depth = parse_optional_u32(params, "depth").map(|value| value as usize); + let roots = match state.list_git_roots(workspace_id, depth).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(roots).map_err(|err| err.to_string())) + } + "get_git_diffs" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let diffs = match state.get_git_diffs(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(diffs).map_err(|err| err.to_string())) + } + "get_git_log" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let limit = parse_optional_u32(params, "limit").map(|value| value as usize); + let log = match state.get_git_log(workspace_id, limit).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(log).map_err(|err| err.to_string())) + } + "get_git_commit_diff" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let sha = match parse_string(params, "sha") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let diff = match state.get_git_commit_diff(workspace_id, sha).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(diff).map_err(|err| err.to_string())) + } + "get_git_remote" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let remote = match state.get_git_remote(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(remote).map_err(|err| err.to_string())) + } + "stage_git_file" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .stage_git_file(workspace_id, path) + .await + .map(|_| json!({ "ok": true })), + ) + } + "stage_git_all" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .stage_git_all(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "unstage_git_file" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .unstage_git_file(workspace_id, path) + .await + .map(|_| json!({ "ok": true })), + ) + } + "revert_git_file" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .revert_git_file(workspace_id, path) + .await + .map(|_| json!({ "ok": true })), + ) + } + "revert_git_all" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .revert_git_all(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "commit_git" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let message = match parse_string(params, "message") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .commit_git(workspace_id, message) + .await + .map(|_| json!({ "ok": true })), + ) + } + "push_git" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .push_git(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "pull_git" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .pull_git(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "fetch_git" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .fetch_git(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "sync_git" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .sync_git(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "get_github_issues" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let issues = match state.get_github_issues(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(issues).map_err(|err| err.to_string())) + } + "get_github_pull_requests" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prs = match state.get_github_pull_requests(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(prs).map_err(|err| err.to_string())) + } + "get_github_pull_request_diff" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let pr_number = match super::super::parse_optional_u64(params, "prNumber") + .ok_or("missing or invalid `prNumber`") + { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let diff = match state + .get_github_pull_request_diff(workspace_id, pr_number) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(diff).map_err(|err| err.to_string())) + } + "get_github_pull_request_comments" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let pr_number = match super::super::parse_optional_u64(params, "prNumber") + .ok_or("missing or invalid `prNumber`") + { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let comments = match state + .get_github_pull_request_comments(workspace_id, pr_number) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(comments).map_err(|err| err.to_string())) + } + "checkout_github_pull_request" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let pr_number = match super::super::parse_optional_u64(params, "prNumber") + .ok_or("missing or invalid `prNumber`") + { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + Some( + state + .checkout_github_pull_request(workspace_id, pr_number) + .await + .map(|_| json!({ "ok": true })), + ) + } + "list_git_branches" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(state.list_git_branches(workspace_id).await) + } + "checkout_git_branch" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = match parse_string(params, "name") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .checkout_git_branch(workspace_id, name) + .await + .map(|_| json!({ "ok": true })), + ) + } + "create_git_branch" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = match parse_string(params, "name") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .create_git_branch(workspace_id, name) + .await + .map(|_| json!({ "ok": true })), + ) + } + "generate_commit_message" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let message = match state.generate_commit_message(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(Ok(Value::String(message))) + } + _ => None, + } +} diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/prompts.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/prompts.rs new file mode 100644 index 0000000000..353ea4c171 --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/prompts.rs @@ -0,0 +1,149 @@ +use super::*; + +pub(super) async fn try_handle( + state: &DaemonState, + method: &str, + params: &Value, +) -> Option> { + match method { + "prompts_list" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prompts = match state.prompts_list(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(prompts).map_err(|err| err.to_string())) + } + "prompts_workspace_dir" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let dir = match state.prompts_workspace_dir(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(Ok(Value::String(dir))) + } + "prompts_global_dir" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let dir = match state.prompts_global_dir(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(Ok(Value::String(dir))) + } + "prompts_create" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let scope = match parse_string(params, "scope") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = match parse_string(params, "name") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let description = parse_optional_string(params, "description"); + let argument_hint = parse_optional_string(params, "argumentHint"); + let content = match parse_string(params, "content") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prompt = match state + .prompts_create( + workspace_id, + scope, + name, + description, + argument_hint, + content, + ) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(prompt).map_err(|err| err.to_string())) + } + "prompts_update" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = match parse_string(params, "name") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let description = parse_optional_string(params, "description"); + let argument_hint = parse_optional_string(params, "argumentHint"); + let content = match parse_string(params, "content") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prompt = match state + .prompts_update( + workspace_id, + path, + name, + description, + argument_hint, + content, + ) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(prompt).map_err(|err| err.to_string())) + } + "prompts_delete" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .prompts_delete(workspace_id, path) + .await + .map(|_| json!({ "ok": true })), + ) + } + "prompts_move" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let scope = match parse_string(params, "scope") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let prompt = match state.prompts_move(workspace_id, path, scope).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(prompt).map_err(|err| err.to_string())) + } + _ => None, + } +} diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs new file mode 100644 index 0000000000..d26b8c36d5 --- /dev/null +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -0,0 +1,404 @@ +use super::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileReadRequest { + scope: file_policy::FileScope, + kind: file_policy::FileKind, + workspace_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileWriteRequest { + scope: file_policy::FileScope, + kind: file_policy::FileKind, + workspace_id: Option, + content: String, +} + +fn parse_file_read_request(params: &Value) -> Result { + serde_json::from_value(params.clone()).map_err(|err| err.to_string()) +} + +fn parse_file_write_request(params: &Value) -> Result { + serde_json::from_value(params.clone()).map_err(|err| err.to_string()) +} + +pub(super) async fn try_handle( + state: &DaemonState, + method: &str, + params: &Value, + client_version: &str, +) -> Option> { + match method { + "list_workspaces" => { + let workspaces = state.list_workspaces().await; + Some(serde_json::to_value(workspaces).map_err(|err| err.to_string())) + } + "is_workspace_path_dir" => { + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let is_dir = state.is_workspace_path_dir(path).await; + Some(serde_json::to_value(is_dir).map_err(|err| err.to_string())) + } + "add_workspace" => { + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let codex_bin = parse_optional_string(params, "codex_bin"); + let workspace = match state + .add_workspace(path, codex_bin, client_version.to_string()) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "add_worktree" => { + let parent_id = match parse_string(params, "parentId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let branch = match parse_string(params, "branch") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let name = parse_optional_string(params, "name"); + let copy_agents_md = parse_optional_bool(params, "copyAgentsMd").unwrap_or(true); + let workspace = match state + .add_worktree( + parent_id, + branch, + name, + copy_agents_md, + client_version.to_string(), + ) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "worktree_setup_status" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let status = match state.worktree_setup_status(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(status).map_err(|err| err.to_string())) + } + "worktree_setup_mark_ran" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .worktree_setup_mark_ran(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "connect_workspace" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .connect_workspace(id, client_version.to_string()) + .await + .map(|_| json!({ "ok": true })), + ) + } + "remove_workspace" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .remove_workspace(id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "remove_worktree" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .remove_worktree(id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "rename_worktree" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let branch = match parse_string(params, "branch") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let workspace = match state + .rename_worktree(id, branch, client_version.to_string()) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "rename_worktree_upstream" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let old_branch = match parse_string(params, "oldBranch") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let new_branch = match parse_string(params, "newBranch") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .rename_worktree_upstream(id, old_branch, new_branch) + .await + .map(|_| json!({ "ok": true })), + ) + } + "update_workspace_settings" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let settings_value = match params { + Value::Object(map) => map.get("settings").cloned().unwrap_or(Value::Null), + _ => Value::Null, + }; + let settings: WorkspaceSettings = match serde_json::from_value(settings_value) { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let workspace = match state + .update_workspace_settings(id, settings, client_version.to_string()) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "update_workspace_codex_bin" => { + let id = match parse_string(params, "id") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let codex_bin = parse_optional_string(params, "codex_bin"); + let workspace = match state.update_workspace_codex_bin(id, codex_bin).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "list_workspace_files" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let files = match state.list_workspace_files(workspace_id).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(files).map_err(|err| err.to_string())) + } + "read_workspace_file" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let response = match state.read_workspace_file(workspace_id, path).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(response).map_err(|err| err.to_string())) + } + "file_read" => { + let request = match parse_file_read_request(params) { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let response = match state + .file_read(request.scope, request.kind, request.workspace_id) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(response).map_err(|err| err.to_string())) + } + "file_write" => { + let request = match parse_file_write_request(params) { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + if let Err(err) = state + .file_write( + request.scope, + request.kind, + request.workspace_id, + request.content, + ) + .await + { + return Some(Err(err)); + } + Some(serde_json::to_value(json!({ "ok": true })).map_err(|err| err.to_string())) + } + "get_app_settings" => { + let settings = state.get_app_settings().await; + Some(serde_json::to_value(settings).map_err(|err| err.to_string())) + } + "update_app_settings" => { + let settings_value = match params { + Value::Object(map) => map.get("settings").cloned().unwrap_or(Value::Null), + _ => Value::Null, + }; + let settings: AppSettings = match serde_json::from_value(settings_value) { + Ok(value) => value, + Err(err) => return Some(Err(err.to_string())), + }; + let updated = match state.update_app_settings(settings).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(updated).map_err(|err| err.to_string())) + } + "orbit_connect_test" => { + let result = match state.orbit_connect_test().await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(result).map_err(|err| err.to_string())) + } + "orbit_sign_in_start" => { + let result = match state.orbit_sign_in_start().await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(result).map_err(|err| err.to_string())) + } + "orbit_sign_in_poll" => { + let device_code = match parse_string(params, "deviceCode") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let result = match state.orbit_sign_in_poll(device_code).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(result).map_err(|err| err.to_string())) + } + "orbit_sign_out" => { + let result = match state.orbit_sign_out().await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(result).map_err(|err| err.to_string())) + } + "add_clone" => { + let source_workspace_id = match parse_string(params, "sourceWorkspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let copies_folder = match parse_string(params, "copiesFolder") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let copy_name = match parse_string(params, "copyName") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let workspace = match state + .add_clone( + source_workspace_id, + copies_folder, + copy_name, + client_version.to_string(), + ) + .await + { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(workspace).map_err(|err| err.to_string())) + } + "apply_worktree_changes" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some( + state + .apply_worktree_changes(workspace_id) + .await + .map(|_| json!({ "ok": true })), + ) + } + "open_workspace_in" => { + let path = match parse_string(params, "path") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let app = parse_optional_string(params, "app"); + let command = parse_optional_string(params, "command"); + let args = parse_optional_string_array(params, "args").unwrap_or_default(); + Some( + state + .open_workspace_in(path, app, args, command) + .await + .map(|_| json!({ "ok": true })), + ) + } + "get_open_app_icon" => { + let app_name = match parse_string(params, "appName") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let icon = match state.get_open_app_icon(app_name).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(icon).map_err(|err| err.to_string())) + } + "local_usage_snapshot" => { + let days = parse_optional_u32(params, "days"); + let workspace_path = parse_optional_string(params, "workspacePath"); + let snapshot = match state.local_usage_snapshot(days, workspace_path).await { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + Some(serde_json::to_value(snapshot).map_err(|err| err.to_string())) + } + _ => None, + } +} diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 1a97864cdb..03097f3d7b 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -234,6 +234,7 @@ pub(crate) async fn send_user_message( effort: Option, access_mode: Option, images: Option>, + app_mentions: Option>, collaboration_mode: Option, state: State<'_, AppState>, app: AppHandle, @@ -253,6 +254,7 @@ pub(crate) async fn send_user_message( payload.insert("effort".to_string(), json!(effort)); payload.insert("accessMode".to_string(), json!(access_mode)); payload.insert("images".to_string(), json!(images)); + payload.insert("appMentions".to_string(), json!(app_mentions)); if let Some(mode) = collaboration_mode { if !mode.is_null() { payload.insert("collaborationMode".to_string(), mode); @@ -276,6 +278,7 @@ pub(crate) async fn send_user_message( effort, access_mode, images, + app_mentions, collaboration_mode, ) .await @@ -288,6 +291,7 @@ pub(crate) async fn turn_steer( turn_id: String, text: String, images: Option>, + app_mentions: Option>, state: State<'_, AppState>, app: AppHandle, ) -> Result { @@ -308,6 +312,7 @@ pub(crate) async fn turn_steer( "turnId": turn_id, "text": text, "images": images, + "appMentions": app_mentions, }), ) .await; @@ -320,6 +325,7 @@ pub(crate) async fn turn_steer( turn_id, text, images, + app_mentions, ) .await } @@ -511,6 +517,7 @@ pub(crate) async fn apps_list( workspace_id: String, cursor: Option, limit: Option, + thread_id: Option, state: State<'_, AppState>, app: AppHandle, ) -> Result { @@ -519,12 +526,17 @@ pub(crate) async fn apps_list( &*state, app, "apps_list", - json!({ "workspaceId": workspace_id, "cursor": cursor, "limit": limit }), + json!({ + "workspaceId": workspace_id, + "cursor": cursor, + "limit": limit, + "threadId": thread_id + }), ) .await; } - codex_core::apps_list_core(&state.sessions, workspace_id, cursor, limit).await + codex_core::apps_list_core(&state.sessions, workspace_id, cursor, limit, thread_id).await } #[tauri::command] @@ -550,36 +562,6 @@ pub(crate) async fn respond_to_server_request( .await } -/// Gets the diff content for commit message generation -#[tauri::command] -pub(crate) async fn get_commit_message_prompt( - workspace_id: String, - state: State<'_, AppState>, - app: AppHandle, -) -> Result { - if remote_backend::is_remote_mode(&*state).await { - let value = remote_backend::call_remote( - &*state, - app, - "get_commit_message_prompt", - json!({ "workspaceId": workspace_id }), - ) - .await?; - return serde_json::from_value(value).map_err(|err| err.to_string()); - } - - // Get the diff from git - let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; - - if diff.trim().is_empty() { - return Err("No changes to generate commit message for".to_string()); - } - - Ok(crate::shared::codex_aux_core::build_commit_message_prompt( - &diff, - )) -} - #[tauri::command] pub(crate) async fn remember_approval_rule( workspace_id: String, @@ -628,15 +610,15 @@ pub(crate) async fn generate_commit_message( let diff = crate::git::get_workspace_diff(&workspace_id, &state).await?; - if diff.trim().is_empty() { - return Err("No changes to generate commit message for".to_string()); - } - - let prompt = crate::shared::codex_aux_core::build_commit_message_prompt(&diff); - let response = crate::shared::codex_aux_core::run_background_prompt_core( + let commit_message_prompt = { + let settings = state.app_settings.lock().await; + settings.commit_message_prompt.clone() + }; + crate::shared::codex_aux_core::generate_commit_message_core( &state.sessions, workspace_id, - prompt, + &diff, + &commit_message_prompt, |workspace_id, thread_id| { let _ = app.emit( "app-server-event", @@ -652,17 +634,8 @@ pub(crate) async fn generate_commit_message( }, ); }, - "Timeout waiting for commit message generation", - "Unknown error during commit message generation", ) - .await?; - - let trimmed = response.trim().to_string(); - if trimmed.is_empty() { - return Err("No commit message was generated".to_string()); - } - - Ok(trimmed) + .await } #[tauri::command] @@ -682,16 +655,10 @@ pub(crate) async fn generate_run_metadata( .await; } - let cleaned_prompt = prompt.trim(); - if cleaned_prompt.is_empty() { - return Err("Prompt is required.".to_string()); - } - - let title_prompt = crate::shared::codex_aux_core::build_run_metadata_prompt(cleaned_prompt); - let response_text = crate::shared::codex_aux_core::run_background_prompt_core( + crate::shared::codex_aux_core::generate_run_metadata_core( &state.sessions, workspace_id, - title_prompt, + &prompt, |workspace_id, thread_id| { let _ = app.emit( "app-server-event", @@ -707,34 +674,6 @@ pub(crate) async fn generate_run_metadata( }, ); }, - "Timeout waiting for metadata generation", - "Unknown error during metadata generation", ) - .await?; - - let trimmed = response_text.trim(); - if trimmed.is_empty() { - return Err("No metadata was generated".to_string()); - } - - let json_value = crate::shared::codex_aux_core::extract_json_value(trimmed) - .ok_or_else(|| "Failed to parse metadata JSON".to_string())?; - let title = json_value - .get("title") - .and_then(|v| v.as_str()) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .ok_or_else(|| "Missing title in metadata".to_string())?; - let worktree_name = json_value - .get("worktreeName") - .or_else(|| json_value.get("worktree_name")) - .and_then(|v| v.as_str()) - .map(crate::shared::codex_aux_core::sanitize_run_worktree_name) - .filter(|v| !v.is_empty()) - .ok_or_else(|| "Missing worktree name in metadata".to_string())?; - - Ok(json!({ - "title": title, - "worktreeName": worktree_name - })) + .await } diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index 5b2b2b1c1c..acfc198183 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -402,6 +402,22 @@ pub(crate) async fn get_github_pull_request_comments( .await } +#[tauri::command] +pub(crate) async fn checkout_github_pull_request( + workspace_id: String, + pr_number: u64, + state: State<'_, AppState>, + app: AppHandle, +) -> Result<(), String> { + try_remote_unit!( + state, + app, + "checkout_github_pull_request", + json!({ "workspaceId": &workspace_id, "prNumber": pr_number }) + ); + git_ui_core::checkout_github_pull_request_core(&state.workspaces, workspace_id, pr_number).await +} + #[tauri::command] pub(crate) async fn list_git_branches( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2564e89d94..3be2b414d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,10 @@ +#[cfg(desktop)] +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; +#[cfg(desktop)] +use tauri::RunEvent; #[cfg(target_os = "macos")] -use tauri::{RunEvent, WindowEvent}; +use tauri::WindowEvent; mod backend; mod codex; @@ -37,6 +41,29 @@ mod utils; mod window; mod workspaces; +#[cfg(desktop)] +static EXIT_CLEANUP_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +#[cfg(desktop)] +fn keep_daemon_running_after_close(app_handle: &tauri::AppHandle) -> bool { + let state = app_handle.state::(); + tauri::async_runtime::block_on(async { + state + .app_settings + .lock() + .await + .keep_daemon_running_after_app_close + }) +} + +#[cfg(desktop)] +async fn stop_managed_daemons_for_exit(app_handle: tauri::AppHandle) { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_stop(state).await; + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_stop(state).await; +} + #[tauri::command] fn is_mobile_runtime() -> bool { cfg!(any(target_os = "ios", target_os = "android")) @@ -50,6 +77,17 @@ pub fn run() { if std::env::var_os("__NV_PRIME_RENDER_OFFLOAD").is_none() { std::env::set_var("__NV_PRIME_RENDER_OFFLOAD", "1"); } + let is_wayland = std::env::var("XDG_SESSION_TYPE") + .map(|session| session.eq_ignore_ascii_case("wayland")) + .unwrap_or(false) + || std::env::var_os("WAYLAND_DISPLAY").is_some(); + let has_nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists(); + if is_wayland + && has_nvidia + && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() + { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } // Work around sporadic blank WebKitGTK renders on X11 by disabling compositing mode. if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); @@ -80,6 +118,75 @@ pub fn run() { .setup(|app| { let state = state::AppState::load(&app.handle()); app.manage(state); + #[cfg(desktop)] + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let state = app_handle.state::(); + let settings = state.app_settings.lock().await.clone(); + if matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Tcp + ) { + if matches!(settings.backend_mode, crate::types::BackendMode::Remote) { + // Remote mode: ensure daemon is up and version-current. + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_start(state).await; + } else { + // Local mode: only enforce version if daemon is already running. + let state = app_handle.state::(); + if let Ok(status) = tailscale::tailscale_daemon_status(state).await { + if matches!(status.state, crate::types::TcpDaemonState::Running) { + let state = app_handle.state::(); + let _ = tailscale::tailscale_daemon_start(state).await; + } + } + } + } + + if matches!(settings.backend_mode, crate::types::BackendMode::Remote) + && matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Orbit + ) + { + if settings.orbit_auto_start_runner { + if settings.keep_daemon_running_after_app_close { + // Avoid duplicate detached Orbit runners across relaunches. + // orbit_runner_start can still be called manually from Settings. + let state = app_handle.state::(); + let _ = orbit::orbit_runner_status(state).await; + } else { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } else { + let state = app_handle.state::(); + if let Ok(status) = orbit::orbit_runner_status(state).await { + if matches!(status.state, crate::types::OrbitRunnerState::Running) { + // Enforce version for a currently running managed runner. + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } + } + } else if matches!( + settings.remote_backend_provider, + crate::types::RemoteBackendProvider::Orbit + ) { + // Local mode with Orbit selected: only enforce version if runner is already running. + let state = app_handle.state::(); + if let Ok(status) = orbit::orbit_runner_status(state).await { + if matches!(status.state, crate::types::OrbitRunnerState::Running) + && !settings.keep_daemon_running_after_app_close + { + let state = app_handle.state::(); + let _ = orbit::orbit_runner_start(state).await; + } + } + } + }); + } #[cfg(target_os = "ios")] { if let Some(main_webview) = app.get_webview_window("main") { @@ -134,7 +241,6 @@ pub fn run() { codex::start_review, codex::respond_to_server_request, codex::remember_approval_rule, - codex::get_commit_message_prompt, codex::generate_commit_message, codex::generate_run_metadata, codex::resume_thread, @@ -166,6 +272,7 @@ pub fn run() { git::get_github_pull_requests, git::get_github_pull_request_diff, git::get_github_pull_request_comments, + git::checkout_github_pull_request, workspaces::list_workspace_files, workspaces::read_workspace_file, workspaces::open_workspace_in, @@ -231,6 +338,22 @@ pub fn run() { .expect("error while running tauri application"); app.run(|app_handle, event| { + #[cfg(desktop)] + if let RunEvent::ExitRequested { api, .. } = event { + if !EXIT_CLEANUP_IN_PROGRESS.load(Ordering::SeqCst) + && !keep_daemon_running_after_close(app_handle) + { + api.prevent_exit(); + EXIT_CLEANUP_IN_PROGRESS.store(true, Ordering::SeqCst); + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + stop_managed_daemons_for_exit(app_handle.clone()).await; + app_handle.exit(0); + }); + } + return; + } + #[cfg(target_os = "macos")] if let RunEvent::Reopen { .. } = event { if let Some(window) = app_handle.get_webview_window("main") { diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 5df3badc1a..03664e9e72 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -94,7 +94,7 @@ pub(crate) fn build_menu( let new_clone_agent_item = MenuItemBuilder::with_id("file_new_clone_agent", "New Clone Agent").build(handle)?; let add_workspace_item = - MenuItemBuilder::with_id("file_add_workspace", "Add Workspace...").build(handle)?; + MenuItemBuilder::with_id("file_add_workspace", "Add Workspaces...").build(handle)?; registry.register("file_new_agent", &new_agent_item); registry.register("file_new_worktree_agent", &new_worktree_agent_item); diff --git a/src-tauri/src/orbit/mod.rs b/src-tauri/src/orbit/mod.rs index b6a976b607..5e29be26b5 100644 --- a/src-tauri/src/orbit/mod.rs +++ b/src-tauri/src/orbit/mod.rs @@ -1,7 +1,9 @@ use std::process::Stdio; use std::time::{SystemTime, UNIX_EPOCH}; +use serde::{Deserialize, Serialize}; use tauri::State; +use tokio::fs; use crate::daemon_binary::resolve_daemon_binary_path; use crate::shared::orbit_core; @@ -13,6 +15,82 @@ use crate::types::{ OrbitSignInStatus, OrbitSignOutResult, }; +const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const ORBIT_RUNNER_RECORD_FILE: &str = "orbit_runner.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OrbitRunnerRecord { + pid: u32, + version: String, + orbit_url: Option, + started_at_ms: Option, +} + +fn orbit_runner_record_path(state: &AppState) -> Option { + state + .settings_path + .parent() + .map(|parent| parent.join(ORBIT_RUNNER_RECORD_FILE)) +} + +async fn load_orbit_runner_record(state: &AppState) -> Option { + let path = orbit_runner_record_path(state)?; + let payload = fs::read(path).await.ok()?; + serde_json::from_slice(&payload).ok() +} + +async fn save_orbit_runner_record(state: &AppState, record: &OrbitRunnerRecord) { + let Some(path) = orbit_runner_record_path(state) else { + return; + }; + let Ok(payload) = serde_json::to_vec(record) else { + return; + }; + let _ = fs::write(path, payload).await; +} + +async fn clear_orbit_runner_record(state: &AppState) { + let Some(path) = orbit_runner_record_path(state) else { + return; + }; + let _ = fs::remove_file(path).await; +} + +#[cfg(unix)] +async fn is_pid_running(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) => code != libc::ESRCH, + None => false, + } +} + +#[cfg(windows)] +async fn is_pid_running(pid: u32) -> bool { + let output = match tokio_command("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .output() + .await + { + Ok(output) => output, + Err(_) => return false, + }; + if !output.status.success() { + return false; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().any(|line| line.contains(&format!("\"{pid}\""))) +} + +#[cfg(not(any(unix, windows)))] +async fn is_pid_running(_pid: u32) -> bool { + false +} + fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -24,6 +102,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { let Some(child) = runtime.child.as_mut() else { runtime.status.state = OrbitRunnerState::Stopped; runtime.status.pid = None; + runtime.managed_version = None; return; }; @@ -48,6 +127,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { orbit_url: runtime.status.orbit_url.clone(), }; } + runtime.managed_version = None; } Ok(None) => { runtime.status.state = OrbitRunnerState::Running; @@ -62,6 +142,7 @@ async fn refresh_runner_runtime(runtime: &mut OrbitRunnerRuntime) { last_error: Some(format!("Failed to inspect runner process: {err}")), orbit_url: runtime.status.orbit_url.clone(), }; + runtime.managed_version = None; } } } @@ -155,10 +236,63 @@ pub(crate) async fn orbit_runner_start( .map(|path| path.to_path_buf()) .ok_or_else(|| "Unable to resolve app data directory".to_string())?; + let persisted_runner = load_orbit_runner_record(&state).await; + let mut runtime = state.orbit_runner.lock().await; refresh_runner_runtime(&mut runtime).await; if matches!(runtime.status.state, OrbitRunnerState::Running) { - return Ok(runtime.status.clone()); + if runtime.managed_version.as_deref() == Some(CURRENT_APP_VERSION) { + return Ok(runtime.status.clone()); + } + + if runtime.child.is_none() { + let pid_display = runtime + .status + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let message = format!( + "Orbit runner (pid {pid_display}) is already running outside this app process. Stop it first to avoid duplicate runners." + ); + runtime.status.last_error = Some(message.clone()); + return Err(message); + } + + if let Some(mut child) = runtime.child.take() { + kill_child_process_tree(&mut child).await; + let _ = child.wait().await; + } + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Stopped, + pid: None, + started_at_ms: None, + last_error: None, + orbit_url: runtime.status.orbit_url.clone(), + }; + runtime.managed_version = None; + } + + if let Some(record) = persisted_runner { + if is_pid_running(record.pid).await { + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Running, + pid: Some(record.pid), + started_at_ms: record.started_at_ms, + last_error: None, + orbit_url: record.orbit_url.or_else(|| Some(ws_url.clone())), + }; + runtime.managed_version = Some(record.version.clone()); + if record.version == CURRENT_APP_VERSION { + return Ok(runtime.status.clone()); + } + let message = format!( + "Orbit runner version {} does not match app version {}. Stop the existing runner before starting a new one.", + record.version, CURRENT_APP_VERSION + ); + runtime.status.last_error = Some(message.clone()); + return Err(message); + } + clear_orbit_runner_record(&state).await; } let mut command = tokio_command(&daemon_binary); @@ -210,6 +344,19 @@ pub(crate) async fn orbit_runner_start( orbit_url: Some(ws_url), }; runtime.child = Some(child); + runtime.managed_version = Some(CURRENT_APP_VERSION.to_string()); + if let Some(pid) = runtime.status.pid { + save_orbit_runner_record( + &state, + &OrbitRunnerRecord { + pid, + version: CURRENT_APP_VERSION.to_string(), + orbit_url: runtime.status.orbit_url.clone(), + started_at_ms: runtime.status.started_at_ms, + }, + ) + .await; + } Ok(runtime.status.clone()) } @@ -222,6 +369,7 @@ pub(crate) async fn orbit_runner_stop( if let Some(mut child) = runtime.child.take() { kill_child_process_tree(&mut child).await; let _ = child.wait().await; + clear_orbit_runner_record(&state).await; } runtime.status = OrbitRunnerStatus { @@ -231,6 +379,7 @@ pub(crate) async fn orbit_runner_stop( last_error: None, orbit_url: runtime.status.orbit_url.clone(), }; + runtime.managed_version = None; Ok(runtime.status.clone()) } @@ -248,6 +397,22 @@ pub(crate) async fn orbit_runner_status( let mut runtime = state.orbit_runner.lock().await; refresh_runner_runtime(&mut runtime).await; + if !matches!(runtime.status.state, OrbitRunnerState::Running) { + if let Some(record) = load_orbit_runner_record(&state).await { + if is_pid_running(record.pid).await { + runtime.status = OrbitRunnerStatus { + state: OrbitRunnerState::Running, + pid: Some(record.pid), + started_at_ms: record.started_at_ms, + last_error: None, + orbit_url: record.orbit_url.clone(), + }; + runtime.managed_version = Some(record.version); + } else { + clear_orbit_runner_record(&state).await; + } + } + } if runtime.status.orbit_url.is_none() { runtime.status.orbit_url = configured_orbit_url; } diff --git a/src-tauri/src/remote_backend/mod.rs b/src-tauri/src/remote_backend/mod.rs index d1a4f554ce..a453195d59 100644 --- a/src-tauri/src/remote_backend/mod.rs +++ b/src-tauri/src/remote_backend/mod.rs @@ -6,9 +6,11 @@ mod transport; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::Duration; use tauri::AppHandle; use tokio::sync::Mutex; +use tokio::time::timeout; use crate::state::AppState; use crate::types::{BackendMode, RemoteBackendProvider}; @@ -18,6 +20,9 @@ use self::protocol::{build_request_line, DEFAULT_REMOTE_HOST, DISCONNECTED_MESSA use self::tcp_transport::TcpTransport; use self::transport::{PendingMap, RemoteTransport, RemoteTransportConfig, RemoteTransportKind}; +const REMOTE_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); +const REMOTE_SEND_TIMEOUT: Duration = Duration::from_secs(15); + pub(crate) fn normalize_path_for_remote(path: String) -> String { let trimmed = path.trim(); if trimmed.is_empty() { @@ -58,7 +63,7 @@ pub(crate) struct RemoteBackend { } struct RemoteBackendInner { - out_tx: tokio::sync::mpsc::UnboundedSender, + out_tx: tokio::sync::mpsc::Sender, pending: Arc>, next_id: AtomicU64, connected: Arc, @@ -75,12 +80,32 @@ impl RemoteBackend { self.inner.pending.lock().await.insert(id, tx); let message = build_request_line(id, method, params)?; - if self.inner.out_tx.send(message).is_err() { - self.inner.pending.lock().await.remove(&id); - return Err(DISCONNECTED_MESSAGE.to_string()); + match timeout(REMOTE_SEND_TIMEOUT, self.inner.out_tx.send(message)).await { + Ok(Ok(())) => {} + Ok(Err(_)) => { + self.inner.pending.lock().await.remove(&id); + return Err(DISCONNECTED_MESSAGE.to_string()); + } + Err(_) => { + self.inner.pending.lock().await.remove(&id); + return Err(format!( + "remote backend request dispatch timed out after {} seconds", + REMOTE_SEND_TIMEOUT.as_secs() + )); + } } - rx.await.map_err(|_| DISCONNECTED_MESSAGE.to_string())? + match timeout(REMOTE_REQUEST_TIMEOUT, rx).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(DISCONNECTED_MESSAGE.to_string()), + Err(_) => { + self.inner.pending.lock().await.remove(&id); + Err(format!( + "remote backend request timed out after {} seconds", + REMOTE_REQUEST_TIMEOUT.as_secs() + )) + } + } } } @@ -141,7 +166,6 @@ fn can_retry_after_disconnect(method: &str) -> bool { | "forge_uninstall_template" | "get_config_model" | "get_git_commit_diff" - | "get_commit_message_prompt" | "get_git_diffs" | "get_git_log" | "get_git_remote" @@ -266,7 +290,6 @@ mod tests { fn retries_only_retry_safe_methods_after_disconnect() { assert!(can_retry_after_disconnect("resume_thread")); assert!(can_retry_after_disconnect("list_threads")); - assert!(can_retry_after_disconnect("get_commit_message_prompt")); assert!(can_retry_after_disconnect("local_usage_snapshot")); assert!(can_retry_after_disconnect("forge_list_plans")); assert!(can_retry_after_disconnect("forge_get_plan_prompt")); diff --git a/src-tauri/src/remote_backend/orbit_ws_transport.rs b/src-tauri/src/remote_backend/orbit_ws_transport.rs index 676d6b3993..d1c52b6c0f 100644 --- a/src-tauri/src/remote_backend/orbit_ws_transport.rs +++ b/src-tauri/src/remote_backend/orbit_ws_transport.rs @@ -15,6 +15,7 @@ use super::transport::{ }; pub(crate) struct OrbitWsTransport; +const OUTBOUND_QUEUE_CAPACITY: usize = 512; impl RemoteTransport for OrbitWsTransport { fn connect(&self, app: AppHandle, config: RemoteTransportConfig) -> TransportFuture { @@ -29,7 +30,7 @@ impl RemoteTransport for OrbitWsTransport { .map_err(|err| format!("Failed to connect to Orbit relay at {ws_url}: {err}"))?; let (mut writer, mut reader) = stream.split(); - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); let pending = Arc::new(Mutex::new(PendingMap::new())); let pending_for_writer = Arc::clone(&pending); let pending_for_reader = Arc::clone(&pending); diff --git a/src-tauri/src/remote_backend/transport.rs b/src-tauri/src/remote_backend/transport.rs index 63ec039d80..7ffbeee802 100644 --- a/src-tauri/src/remote_backend/transport.rs +++ b/src-tauri/src/remote_backend/transport.rs @@ -12,6 +12,7 @@ use tokio::sync::{mpsc, oneshot, Mutex}; use super::protocol::{parse_incoming_line, IncomingMessage, DISCONNECTED_MESSAGE}; pub(crate) type PendingMap = HashMap>>; +const OUTBOUND_QUEUE_CAPACITY: usize = 512; #[derive(Clone, Debug)] pub(crate) enum RemoteTransportConfig { @@ -48,7 +49,7 @@ impl RemoteTransportConfig { } pub(crate) struct TransportConnection { - pub(crate) out_tx: mpsc::UnboundedSender, + pub(crate) out_tx: mpsc::Sender, pub(crate) pending: Arc>, pub(crate) connected: Arc, } @@ -69,7 +70,7 @@ where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - let (out_tx, mut out_rx) = mpsc::unbounded_channel::(); + let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); let pending = Arc::new(Mutex::new(PendingMap::new())); let pending_for_writer = Arc::clone(&pending); let pending_for_reader = Arc::clone(&pending); diff --git a/src-tauri/src/settings/mod.rs b/src-tauri/src/settings/mod.rs index 00fd1c7676..6b1da28a7f 100644 --- a/src-tauri/src/settings/mod.rs +++ b/src-tauri/src/settings/mod.rs @@ -4,7 +4,7 @@ use crate::shared::settings_core::{ get_app_settings_core, get_codex_config_path_core, update_app_settings_core, }; use crate::state::AppState; -use crate::types::AppSettings; +use crate::types::{AppSettings, BackendMode, RemoteBackendProvider}; use crate::window; #[tauri::command] @@ -29,6 +29,7 @@ pub(crate) async fn update_app_settings( if should_reset_remote_backend(&previous, &updated) { *state.remote_backend.lock().await = None; } + ensure_remote_runtime_for_settings(&updated, state).await; let _ = window::apply_window_appearance(&window, updated.theme.as_str()); Ok(updated) } @@ -56,6 +57,26 @@ fn should_reset_remote_backend(previous: &AppSettings, updated: &AppSettings) -> || previous.orbit_ws_url != updated.orbit_ws_url } +async fn ensure_remote_runtime_for_settings(settings: &AppSettings, state: State<'_, AppState>) { + if cfg!(any(target_os = "android", target_os = "ios")) { + return; + } + if !matches!(settings.backend_mode, BackendMode::Remote) { + return; + } + + match settings.remote_backend_provider { + RemoteBackendProvider::Tcp => { + let _ = crate::tailscale::tailscale_daemon_start(state).await; + } + RemoteBackendProvider::Orbit => { + if settings.orbit_auto_start_runner { + let _ = crate::orbit::orbit_runner_start(state).await; + } + } + } +} + #[cfg(test)] mod tests { use super::should_reset_remote_backend; diff --git a/src-tauri/src/shared/codex_aux_core.rs b/src-tauri/src/shared/codex_aux_core.rs index 69807472ff..c4dacbbdb5 100644 --- a/src-tauri/src/shared/codex_aux_core.rs +++ b/src-tauri/src/shared/codex_aux_core.rs @@ -12,14 +12,33 @@ use crate::backend::app_server::{ use crate::shared::process_core::tokio_command; use crate::types::AppSettings; -pub(crate) fn build_commit_message_prompt(diff: &str) -> String { - format!( - "Generate a concise git commit message for the following changes. \ +const DEFAULT_COMMIT_MESSAGE_PROMPT: &str = "Generate a concise git commit message for the following changes. \ Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ Keep the summary line under 72 characters. \ Only output the commit message, nothing else.\n\n\ -Changes:\n{diff}" - ) +Changes:\n{diff}"; + +pub(crate) fn build_commit_message_prompt(diff: &str, template: &str) -> String { + let base = if template.trim().is_empty() { + DEFAULT_COMMIT_MESSAGE_PROMPT + } else { + template + }; + if base.contains("{diff}") { + base.replace("{diff}", diff) + } else { + format!("{base}\n\nChanges:\n{diff}") + } +} + +pub(crate) fn build_commit_message_prompt_for_diff( + diff: &str, + template: &str, +) -> Result { + if diff.trim().is_empty() { + return Err("No changes to generate commit message for".to_string()); + } + Ok(build_commit_message_prompt(diff, template)) } pub(crate) fn build_run_metadata_prompt(cleaned_prompt: &str) -> String { @@ -41,6 +60,33 @@ Task:\n{cleaned_prompt}" ) } +pub(crate) fn parse_run_metadata_value(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("No metadata was generated".to_string()); + } + let json_value = + extract_json_value(trimmed).ok_or_else(|| "Failed to parse metadata JSON".to_string())?; + let title = json_value + .get("title") + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "Missing title in metadata".to_string())?; + let worktree_name = json_value + .get("worktreeName") + .or_else(|| json_value.get("worktree_name")) + .and_then(|v| v.as_str()) + .map(sanitize_run_worktree_name) + .filter(|v| !v.is_empty()) + .ok_or_else(|| "Missing worktree name in metadata".to_string())?; + + Ok(json!({ + "title": title, + "worktreeName": worktree_name + })) +} + pub(crate) fn extract_json_value(raw: &str) -> Option { let start = raw.find('{')?; let end = raw.rfind('}')?; @@ -306,7 +352,10 @@ where let mut response_text = String::new(); let collect_result = timeout(Duration::from_secs(60), async { - while let Some(event) = rx.recv().await { + loop { + let Some(event) = rx.recv().await else { + return Err("Background response stream closed before completion".to_string()); + }; let method = event.get("method").and_then(|m| m.as_str()).unwrap_or(""); match method { "item/agentMessage/delta" => { @@ -353,3 +402,85 @@ where Ok(trimmed) } + +pub(crate) async fn generate_commit_message_core( + sessions: &Mutex>>, + workspace_id: String, + diff: &str, + template: &str, + on_hide_thread: F, +) -> Result +where + F: Fn(&str, &str), +{ + let prompt = build_commit_message_prompt_for_diff(diff, template)?; + run_background_prompt_core( + sessions, + workspace_id, + prompt, + on_hide_thread, + "Timeout waiting for commit message generation", + "Unknown error during commit message generation", + ) + .await +} + +pub(crate) async fn generate_run_metadata_core( + sessions: &Mutex>>, + workspace_id: String, + prompt: &str, + on_hide_thread: F, +) -> Result +where + F: Fn(&str, &str), +{ + let cleaned_prompt = prompt.trim(); + if cleaned_prompt.is_empty() { + return Err("Prompt is required.".to_string()); + } + + let metadata_prompt = build_run_metadata_prompt(cleaned_prompt); + let response = run_background_prompt_core( + sessions, + workspace_id, + metadata_prompt, + on_hide_thread, + "Timeout waiting for metadata generation", + "Unknown error during metadata generation", + ) + .await?; + + parse_run_metadata_value(&response) +} + +#[cfg(test)] +mod tests { + use super::{build_commit_message_prompt_for_diff, parse_run_metadata_value}; + + #[test] + fn build_commit_message_prompt_for_diff_requires_changes() { + let result = build_commit_message_prompt_for_diff(" ", "{diff}"); + assert_eq!( + result.expect_err("should fail"), + "No changes to generate commit message for" + ); + } + + #[test] + fn parse_run_metadata_value_normalizes_worktree_name_alias() { + let raw = r#"{"title":"Fix Login Redirect Loop","worktree_name":"fix-login-redirect-loop"}"#; + let parsed = parse_run_metadata_value(raw).expect("parse metadata"); + assert_eq!(parsed["title"], "Fix Login Redirect Loop"); + assert_eq!(parsed["worktreeName"], "fix/login-redirect-loop"); + } + + #[test] + fn parse_run_metadata_value_requires_title() { + let raw = r#"{"worktreeName":"feat/example"}"#; + let result = parse_run_metadata_value(raw); + assert_eq!( + result.expect_err("should fail"), + "Missing title in metadata" + ); + } +} diff --git a/src-tauri/src/shared/codex_core.rs b/src-tauri/src/shared/codex_core.rs index cad5ea4d58..92c217aa54 100644 --- a/src-tauri/src/shared/codex_core.rs +++ b/src-tauri/src/shared/codex_core.rs @@ -1,5 +1,5 @@ use serde_json::{json, Map, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; @@ -155,7 +155,11 @@ pub(crate) async fn set_thread_name_core( session.send_request("thread/name/set", params).await } -fn build_turn_input_items(text: String, images: Option>) -> Result, String> { +fn build_turn_input_items( + text: String, + images: Option>, + app_mentions: Option>, +) -> Result, String> { let trimmed_text = text.trim(); let mut input: Vec = Vec::new(); if !trimmed_text.is_empty() { @@ -177,6 +181,33 @@ fn build_turn_input_items(text: String, images: Option>) -> Result = HashSet::new(); + for mention in mentions { + let object = mention + .as_object() + .ok_or_else(|| "invalid app mention payload".to_string())?; + let name = object + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "invalid app mention name".to_string())?; + let path = object + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "invalid app mention path".to_string())?; + if !path.starts_with("app://") || path.len() <= "app://".len() { + return Err("invalid app mention path".to_string()); + } + if !seen_paths.insert(path.to_string()) { + continue; + } + input.push(json!({ "type": "mention", "name": name, "path": path })); + } + } if input.is_empty() { return Err("empty user message".to_string()); } @@ -192,6 +223,7 @@ pub(crate) async fn send_user_message_core( effort: Option, access_mode: Option, images: Option>, + app_mentions: Option>, collaboration_mode: Option, ) -> Result { let session = get_session_clone(sessions, &workspace_id).await?; @@ -212,7 +244,7 @@ pub(crate) async fn send_user_message_core( "on-request" }; - let input = build_turn_input_items(text, images)?; + let input = build_turn_input_items(text, images, app_mentions)?; let mut params = Map::new(); params.insert("threadId".to_string(), json!(thread_id)); @@ -239,12 +271,13 @@ pub(crate) async fn turn_steer_core( turn_id: String, text: String, images: Option>, + app_mentions: Option>, ) -> Result { if turn_id.trim().is_empty() { return Err("missing active turn id".to_string()); } let session = get_session_clone(sessions, &workspace_id).await?; - let input = build_turn_input_items(text, images)?; + let input = build_turn_input_items(text, images, app_mentions)?; let params = json!({ "threadId": thread_id, "expectedTurnId": turn_id, @@ -486,9 +519,10 @@ pub(crate) async fn apps_list_core( workspace_id: String, cursor: Option, limit: Option, + thread_id: Option, ) -> Result { let session = get_session_clone(sessions, &workspace_id).await?; - let params = json!({ "cursor": cursor, "limit": limit }); + let params = json!({ "cursor": cursor, "limit": limit, "threadId": thread_id }); session.send_request("app/list", params).await } diff --git a/src-tauri/src/shared/codex_update_core.rs b/src-tauri/src/shared/codex_update_core.rs index 17b50acf92..abd82bfefb 100644 --- a/src-tauri/src/shared/codex_update_core.rs +++ b/src-tauri/src/shared/codex_update_core.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use serde_json::Value; use std::time::Duration; diff --git a/src-tauri/src/shared/git_core.rs b/src-tauri/src/shared/git_core.rs index cbe24b6c03..682e116dea 100644 --- a/src-tauri/src/shared/git_core.rs +++ b/src-tauri/src/shared/git_core.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::path::PathBuf; use crate::shared::process_core::tokio_command; @@ -131,6 +129,8 @@ pub(crate) async fn git_remote_branch_exists_live( Err(format_git_error(&output.stdout, &output.stderr)) } +// Used by daemon-only worktree orchestration paths. +#[allow(dead_code)] pub(crate) async fn git_remote_branch_exists_local( repo_path: &PathBuf, remote: &str, @@ -183,6 +183,8 @@ pub(crate) async fn git_find_remote_for_branch_live( Ok(None) } +// Used by daemon-only worktree orchestration paths. +#[allow(dead_code)] pub(crate) async fn git_find_remote_tracking_branch_local( repo_path: &PathBuf, branch: &str, diff --git a/src-tauri/src/shared/git_ui_core.rs b/src-tauri/src/shared/git_ui_core.rs index f7eaa7f4de..f7959c450c 100644 --- a/src-tauri/src/shared/git_ui_core.rs +++ b/src-tauri/src/shared/git_ui_core.rs @@ -1,1431 +1,45 @@ use std::collections::HashMap; -use std::fs; use std::path::{Path, PathBuf}; -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use git2::{BranchType, DiffOptions, Repository, Sort, Status, StatusOptions}; -use serde_json::{json, Value}; +use serde_json::Value; use tokio::sync::Mutex; -use crate::git_utils::{ - checkout_branch, commit_to_entry, diff_patch_to_string, diff_stats_for_path, image_mime_type, - list_git_roots as scan_git_roots, parse_github_repo, resolve_git_root, -}; -use crate::shared::process_core::tokio_command; use crate::types::{ - AppSettings, BranchInfo, GitCommitDiff, GitFileDiff, GitFileStatus, GitHubIssue, - GitHubIssuesResponse, GitHubPullRequest, GitHubPullRequestComment, GitHubPullRequestDiff, - GitHubPullRequestsResponse, GitLogResponse, WorkspaceEntry, + AppSettings, GitCommitDiff, GitFileDiff, GitHubIssuesResponse, GitHubPullRequestComment, + GitHubPullRequestDiff, GitHubPullRequestsResponse, GitLogResponse, WorkspaceEntry, }; -use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; - -const INDEX_SKIP_WORKTREE_FLAG: u16 = 0x4000; -const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; -const MAX_TEXT_DIFF_BYTES: usize = 2 * 1024 * 1024; - -fn encode_image_base64(data: &[u8]) -> Option { - if data.len() > MAX_IMAGE_BYTES { - return None; - } - Some(STANDARD.encode(data)) -} - -fn blob_to_base64(blob: git2::Blob) -> Option { - if blob.size() > MAX_IMAGE_BYTES { - return None; - } - encode_image_base64(blob.content()) -} - -fn read_image_base64(path: &Path) -> Option { - let metadata = fs::metadata(path).ok()?; - if metadata.len() > MAX_IMAGE_BYTES as u64 { - return None; - } - let data = fs::read(path).ok()?; - encode_image_base64(&data) -} - -fn bytes_look_binary(bytes: &[u8]) -> bool { - bytes.iter().take(8192).any(|byte| *byte == 0) -} - -fn split_lines_preserving_newlines(content: &str) -> Vec { - if content.is_empty() { - return Vec::new(); - } - content - .split_inclusive('\n') - .map(ToString::to_string) - .collect() -} - -fn blob_to_lines(blob: git2::Blob<'_>) -> Option> { - if blob.size() > MAX_TEXT_DIFF_BYTES || blob.is_binary() { - return None; - } - let content = String::from_utf8_lossy(blob.content()); - Some(split_lines_preserving_newlines(content.as_ref())) -} - -fn read_text_lines(path: &Path) -> Option> { - let metadata = fs::metadata(path).ok()?; - if metadata.len() > MAX_TEXT_DIFF_BYTES as u64 { - return None; - } - let data = fs::read(path).ok()?; - if bytes_look_binary(&data) { - return None; - } - let content = String::from_utf8_lossy(&data); - Some(split_lines_preserving_newlines(content.as_ref())) -} - -async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { - let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; - let output = tokio_command(git_bin) - .args(args) - .current_dir(repo_root) - .env("PATH", git_env_path()) - .output() - .await - .map_err(|e| format!("Failed to run git: {e}"))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("Git command failed.".to_string()); - } - Err(detail.to_string()) -} - -fn action_paths_for_file(repo_root: &Path, path: &str) -> Vec { - let target = normalize_git_path(path).trim().to_string(); - if target.is_empty() { - return Vec::new(); - } - - let repo = match Repository::open(repo_root) { - Ok(repo) => repo, - Err(_) => return vec![target], - }; - - let mut status_options = StatusOptions::new(); - status_options - .include_untracked(true) - .recurse_untracked_dirs(true) - .renames_head_to_index(true) - .renames_index_to_workdir(true) - .include_ignored(false); - - let statuses = match repo.statuses(Some(&mut status_options)) { - Ok(statuses) => statuses, - Err(_) => return vec![target], - }; - - for entry in statuses.iter() { - let status = entry.status(); - if !(status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED)) { - continue; - } - let delta = entry.index_to_workdir().or_else(|| entry.head_to_index()); - let Some(delta) = delta else { - continue; - }; - let (Some(old_path), Some(new_path)) = (delta.old_file().path(), delta.new_file().path()) - else { - continue; - }; - let old_path = normalize_git_path(old_path.to_string_lossy().as_ref()); - let new_path = normalize_git_path(new_path.to_string_lossy().as_ref()); - if old_path != target && new_path != target { - continue; - } - if old_path == new_path || new_path.is_empty() { - return vec![target]; - } - let mut result = Vec::new(); - if !old_path.is_empty() { - result.push(old_path); - } - if !new_path.is_empty() && !result.contains(&new_path) { - result.push(new_path); - } - return if result.is_empty() { - vec![target] - } else { - result - }; - } - - vec![target] -} - -fn parse_upstream_ref(name: &str) -> Option<(String, String)> { - let trimmed = name.strip_prefix("refs/remotes/").unwrap_or(name); - let mut parts = trimmed.splitn(2, '/'); - let remote = parts.next()?; - let branch = parts.next()?; - if remote.is_empty() || branch.is_empty() { - return None; - } - Some((remote.to_string(), branch.to_string())) -} - -fn upstream_remote_and_branch(repo_root: &Path) -> Result, String> { - let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; - let head = match repo.head() { - Ok(head) => head, - Err(_) => return Ok(None), - }; - if !head.is_branch() { - return Ok(None); - } - let branch_name = match head.shorthand() { - Some(name) => name, - None => return Ok(None), - }; - let branch = repo - .find_branch(branch_name, BranchType::Local) - .map_err(|e| e.to_string())?; - let upstream_branch = match branch.upstream() { - Ok(upstream) => upstream, - Err(_) => return Ok(None), - }; - let upstream_ref = upstream_branch.get(); - let upstream_name = upstream_ref.name().or_else(|| upstream_ref.shorthand()); - Ok(upstream_name.and_then(parse_upstream_ref)) -} - -async fn push_with_upstream(repo_root: &Path) -> Result<(), String> { - let upstream = upstream_remote_and_branch(repo_root)?; - if let Some((remote, branch)) = upstream { - let _ = run_git_command(repo_root, &["fetch", "--prune", remote.as_str()]).await; - let refspec = format!("HEAD:{branch}"); - return run_git_command(repo_root, &["push", remote.as_str(), refspec.as_str()]).await; - } - run_git_command(repo_root, &["push"]).await -} - -async fn fetch_with_default_remote(repo_root: &Path) -> Result<(), String> { - let upstream = upstream_remote_and_branch(repo_root)?; - if let Some((remote, _)) = upstream { - return run_git_command(repo_root, &["fetch", "--prune", remote.as_str()]).await; - } - run_git_command(repo_root, &["fetch", "--prune"]).await -} - -async fn pull_with_default_strategy(repo_root: &Path) -> Result<(), String> { - fn autostash_unsupported(lower: &str) -> bool { - lower.contains("unknown option") && lower.contains("autostash") - } - - fn needs_reconcile_strategy(lower: &str) -> bool { - lower.contains("need to specify how to reconcile divergent branches") - || lower.contains("you have divergent branches") - } - - match run_git_command(repo_root, &["pull", "--autostash"]).await { - Ok(()) => Ok(()), - Err(err) => { - let lower = err.to_lowercase(); - if autostash_unsupported(&lower) { - match run_git_command(repo_root, &["pull"]).await { - Ok(()) => Ok(()), - Err(no_autostash_err) => { - let no_autostash_lower = no_autostash_err.to_lowercase(); - if needs_reconcile_strategy(&no_autostash_lower) { - return run_git_command(repo_root, &["pull", "--no-rebase"]).await; - } - Err(no_autostash_err) - } - } - } else if needs_reconcile_strategy(&lower) { - match run_git_command(repo_root, &["pull", "--no-rebase", "--autostash"]).await { - Ok(()) => Ok(()), - Err(merge_err) => { - let merge_lower = merge_err.to_lowercase(); - if autostash_unsupported(&merge_lower) { - return run_git_command(repo_root, &["pull", "--no-rebase"]).await; - } - Err(merge_err) - } - } - } else { - Err(err) - } - } - } -} - -fn status_for_index(status: Status) -> Option<&'static str> { - if status.contains(Status::INDEX_NEW) { - Some("A") - } else if status.contains(Status::INDEX_MODIFIED) { - Some("M") - } else if status.contains(Status::INDEX_DELETED) { - Some("D") - } else if status.contains(Status::INDEX_RENAMED) { - Some("R") - } else if status.contains(Status::INDEX_TYPECHANGE) { - Some("T") - } else { - None - } -} - -fn status_for_workdir(status: Status) -> Option<&'static str> { - if status.contains(Status::WT_NEW) { - Some("A") - } else if status.contains(Status::WT_MODIFIED) { - Some("M") - } else if status.contains(Status::WT_DELETED) { - Some("D") - } else if status.contains(Status::WT_RENAMED) { - Some("R") - } else if status.contains(Status::WT_TYPECHANGE) { - Some("T") - } else { - None - } -} - -fn status_for_delta(status: git2::Delta) -> &'static str { - match status { - git2::Delta::Added => "A", - git2::Delta::Modified => "M", - git2::Delta::Deleted => "D", - git2::Delta::Renamed => "R", - git2::Delta::Typechange => "T", - _ => "M", - } -} - -fn build_combined_diff(diff: &git2::Diff) -> String { - let mut combined_diff = String::new(); - for (index, delta) in diff.deltas().enumerate() { - let path = delta.new_file().path().or_else(|| delta.old_file().path()); - let Some(path) = path else { - continue; - }; - let patch = match git2::Patch::from_diff(diff, index) { - Ok(patch) => patch, - Err(_) => continue, - }; - let Some(mut patch) = patch else { - continue; - }; - let content = match diff_patch_to_string(&mut patch) { - Ok(content) => content, - Err(_) => continue, - }; - if content.trim().is_empty() { - continue; - } - if !combined_diff.is_empty() { - combined_diff.push_str("\n\n"); - } - combined_diff.push_str(&format!("=== {} ===\n", path.display())); - combined_diff.push_str(&content); - } - combined_diff -} - -fn collect_workspace_diff(repo_root: &Path) -> Result { - let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; - let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - - let mut options = DiffOptions::new(); - let index = repo.index().map_err(|e| e.to_string())?; - let diff = match head_tree.as_ref() { - Some(tree) => repo - .diff_tree_to_index(Some(tree), Some(&index), Some(&mut options)) - .map_err(|e| e.to_string())?, - None => repo - .diff_tree_to_index(None, Some(&index), Some(&mut options)) - .map_err(|e| e.to_string())?, - }; - let combined_diff = build_combined_diff(&diff); - if !combined_diff.trim().is_empty() { - return Ok(combined_diff); - } - - let mut options = DiffOptions::new(); - options - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - let diff = match head_tree.as_ref() { - Some(tree) => repo - .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) - .map_err(|e| e.to_string())?, - None => repo - .diff_tree_to_workdir_with_index(None, Some(&mut options)) - .map_err(|e| e.to_string())?, - }; - Ok(build_combined_diff(&diff)) -} - -fn github_repo_from_path(path: &Path) -> Result { - let repo = Repository::open(path).map_err(|e| e.to_string())?; - let remotes = repo.remotes().map_err(|e| e.to_string())?; - let name = if remotes.iter().any(|remote| remote == Some("origin")) { - "origin".to_string() - } else { - remotes.iter().flatten().next().unwrap_or("").to_string() - }; - if name.is_empty() { - return Err("No git remote configured.".to_string()); - } - let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; - let remote_url = remote.url().ok_or("Remote has no URL configured.")?; - parse_github_repo(remote_url).ok_or("Remote is not a GitHub repository.".to_string()) -} - -fn parse_pr_diff(diff: &str) -> Vec { - let mut entries = Vec::new(); - let mut current_lines: Vec<&str> = Vec::new(); - let mut current_old_path: Option = None; - let mut current_new_path: Option = None; - let mut current_status: Option = None; - - let finalize = |lines: &Vec<&str>, - old_path: &Option, - new_path: &Option, - status: &Option, - results: &mut Vec| { - if lines.is_empty() { - return; - } - let diff_text = lines.join("\n"); - if diff_text.trim().is_empty() { - return; - } - let status_value = status.clone().unwrap_or_else(|| "M".to_string()); - let path = if status_value == "D" { - old_path.clone().unwrap_or_default() - } else { - new_path - .clone() - .or_else(|| old_path.clone()) - .unwrap_or_default() - }; - if path.is_empty() { - return; - } - results.push(GitHubPullRequestDiff { - path: normalize_git_path(&path), - status: status_value, - diff: diff_text, - }); - }; - - for line in diff.lines() { - if line.starts_with("diff --git ") { - finalize( - ¤t_lines, - ¤t_old_path, - ¤t_new_path, - ¤t_status, - &mut entries, - ); - current_lines = vec![line]; - current_old_path = None; - current_new_path = None; - current_status = None; - - let rest = line.trim_start_matches("diff --git ").trim(); - let mut parts = rest.split_whitespace(); - let old_part = parts.next().unwrap_or("").trim_start_matches("a/"); - let new_part = parts.next().unwrap_or("").trim_start_matches("b/"); - if !old_part.is_empty() { - current_old_path = Some(old_part.to_string()); - } - if !new_part.is_empty() { - current_new_path = Some(new_part.to_string()); - } - continue; - } - if line.starts_with("new file mode ") { - current_status = Some("A".to_string()); - } else if line.starts_with("deleted file mode ") { - current_status = Some("D".to_string()); - } else if line.starts_with("rename from ") { - current_status = Some("R".to_string()); - let path = line.trim_start_matches("rename from ").trim(); - if !path.is_empty() { - current_old_path = Some(path.to_string()); - } - } else if line.starts_with("rename to ") { - current_status = Some("R".to_string()); - let path = line.trim_start_matches("rename to ").trim(); - if !path.is_empty() { - current_new_path = Some(path.to_string()); - } - } - current_lines.push(line); - } - - finalize( - ¤t_lines, - ¤t_old_path, - ¤t_new_path, - ¤t_status, - &mut entries, - ); - - entries -} - -async fn workspace_entry_for_id( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result { - let workspaces = workspaces.lock().await; - workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string()) -} - -async fn resolve_repo_root_for_workspace( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - resolve_git_root(&entry) -} -async fn get_git_status_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - - let branch_name = repo - .head() - .ok() - .and_then(|head| head.shorthand().map(|s| s.to_string())) - .unwrap_or_else(|| "unknown".to_string()); - - let mut status_options = StatusOptions::new(); - status_options - .include_untracked(true) - .recurse_untracked_dirs(true) - .renames_head_to_index(true) - .renames_index_to_workdir(true) - .include_ignored(false); - - let statuses = repo - .statuses(Some(&mut status_options)) - .map_err(|e| e.to_string())?; - - let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - let index = repo.index().ok(); - - let mut files = Vec::new(); - let mut staged_files = Vec::new(); - let mut unstaged_files = Vec::new(); - let mut total_additions = 0i64; - let mut total_deletions = 0i64; - for entry in statuses.iter() { - let path = entry.path().unwrap_or(""); - if path.is_empty() { - continue; - } - if let Some(index) = index.as_ref() { - if let Some(entry) = index.get_path(Path::new(path), 0) { - if entry.flags_extended & INDEX_SKIP_WORKTREE_FLAG != 0 { - continue; - } - } - } - let status = entry.status(); - let normalized_path = normalize_git_path(path); - let include_index = status.intersects( - Status::INDEX_NEW - | Status::INDEX_MODIFIED - | Status::INDEX_DELETED - | Status::INDEX_RENAMED - | Status::INDEX_TYPECHANGE, - ); - let include_workdir = status.intersects( - Status::WT_NEW - | Status::WT_MODIFIED - | Status::WT_DELETED - | Status::WT_RENAMED - | Status::WT_TYPECHANGE, - ); - let mut combined_additions = 0i64; - let mut combined_deletions = 0i64; - - if include_index { - let (additions, deletions) = - diff_stats_for_path(&repo, head_tree.as_ref(), path, true, false).unwrap_or((0, 0)); - if let Some(status_str) = status_for_index(status) { - staged_files.push(GitFileStatus { - path: normalized_path.clone(), - status: status_str.to_string(), - additions, - deletions, - }); - } - combined_additions += additions; - combined_deletions += deletions; - total_additions += additions; - total_deletions += deletions; - } - - if include_workdir { - let (additions, deletions) = - diff_stats_for_path(&repo, head_tree.as_ref(), path, false, true).unwrap_or((0, 0)); - if let Some(status_str) = status_for_workdir(status) { - unstaged_files.push(GitFileStatus { - path: normalized_path.clone(), - status: status_str.to_string(), - additions, - deletions, - }); - } - combined_additions += additions; - combined_deletions += deletions; - total_additions += additions; - total_deletions += deletions; - } - - if include_index || include_workdir { - let status_str = status_for_workdir(status) - .or_else(|| status_for_index(status)) - .unwrap_or("--"); - files.push(GitFileStatus { - path: normalized_path, - status: status_str.to_string(), - additions: combined_additions, - deletions: combined_deletions, - }); - } - } - - Ok(json!({ - "branchName": branch_name, - "files": files, - "stagedFiles": staged_files, - "unstagedFiles": unstaged_files, - "totalAdditions": total_additions, - "totalDeletions": total_deletions, - })) -} - -async fn stage_git_file_inner( - workspaces: &Mutex>, - workspace_id: String, - path: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - for path in action_paths_for_file(&repo_root, &path) { - run_git_command(&repo_root, &["add", "-A", "--", &path]).await?; - } - Ok(()) -} - -async fn stage_git_all_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - run_git_command(&repo_root, &["add", "-A"]).await -} - -async fn unstage_git_file_inner( - workspaces: &Mutex>, - workspace_id: String, - path: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - for path in action_paths_for_file(&repo_root, &path) { - run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await?; - } - Ok(()) -} - -async fn revert_git_file_inner( - workspaces: &Mutex>, - workspace_id: String, - path: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - for path in action_paths_for_file(&repo_root, &path) { - if run_git_command( - &repo_root, - &["restore", "--staged", "--worktree", "--", &path], - ) - .await - .is_ok() - { - continue; - } - run_git_command(&repo_root, &["clean", "-f", "--", &path]).await?; - } - Ok(()) -} - -async fn revert_git_all_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - run_git_command( - &repo_root, - &["restore", "--staged", "--worktree", "--", "."], - ) - .await?; - run_git_command(&repo_root, &["clean", "-f", "-d"]).await -} - -async fn commit_git_inner( - workspaces: &Mutex>, - workspace_id: String, - message: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - run_git_command(&repo_root, &["commit", "-m", &message]).await -} - -async fn push_git_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - push_with_upstream(&repo_root).await -} +#[path = "git_ui_core/commands.rs"] +mod commands; +#[path = "git_ui_core/context.rs"] +mod context; +#[path = "git_ui_core/diff.rs"] +mod diff; +#[path = "git_ui_core/github.rs"] +mod github; +#[path = "git_ui_core/log.rs"] +mod log; -async fn pull_git_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - pull_with_default_strategy(&repo_root).await -} - -async fn fetch_git_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - fetch_with_default_remote(&repo_root).await -} - -async fn sync_git_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - pull_with_default_strategy(&repo_root).await?; - push_with_upstream(&repo_root).await -} - -async fn list_git_roots_inner( - workspaces: &Mutex>, - workspace_id: String, - depth: Option, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let root = PathBuf::from(&entry.path); - let depth = depth.unwrap_or(2).clamp(1, 6); - Ok(scan_git_roots(&root, depth, 200)) -} - -async fn get_git_diffs_inner( - workspaces: &Mutex>, - app_settings: &Mutex, - workspace_id: String, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let ignore_whitespace_changes = { - let settings = app_settings.lock().await; - settings.git_diff_ignore_whitespace_changes - }; - - tokio::task::spawn_blocking(move || { - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); - - let mut options = DiffOptions::new(); - options - .include_untracked(true) - .recurse_untracked_dirs(true) - .show_untracked_content(true); - options.ignore_whitespace_change(ignore_whitespace_changes); - - let diff = match head_tree.as_ref() { - Some(tree) => repo - .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) - .map_err(|e| e.to_string())?, - None => repo - .diff_tree_to_workdir_with_index(None, Some(&mut options)) - .map_err(|e| e.to_string())?, - }; - - let mut results = Vec::new(); - for (index, delta) in diff.deltas().enumerate() { - let old_path = delta.old_file().path(); - let new_path = delta.new_file().path(); - let display_path = new_path.or(old_path); - let Some(display_path) = display_path else { - continue; - }; - let old_path_str = old_path.map(|path| path.to_string_lossy()); - let new_path_str = new_path.map(|path| path.to_string_lossy()); - let display_path_str = display_path.to_string_lossy(); - let normalized_path = normalize_git_path(&display_path_str); - let old_image_mime = old_path_str.as_deref().and_then(image_mime_type); - let new_image_mime = new_path_str.as_deref().and_then(image_mime_type); - let is_image = old_image_mime.is_some() || new_image_mime.is_some(); - let is_deleted = delta.status() == git2::Delta::Deleted; - let is_added = delta.status() == git2::Delta::Added; - - let old_lines = if !is_added { - head_tree - .as_ref() - .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_lines) - } else { - None - }; - - let new_lines = if !is_deleted { - match new_path { - Some(path) => { - let full_path = repo_root.join(path); - read_text_lines(&full_path) - } - None => None, - } - } else { - None - }; - - if is_image { - let old_image_data = if !is_added && old_image_mime.is_some() { - head_tree - .as_ref() - .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_base64) - } else { - None - }; - - let new_image_data = if !is_deleted && new_image_mime.is_some() { - match new_path { - Some(path) => { - let full_path = repo_root.join(path); - read_image_base64(&full_path) - } - None => None, - } - } else { - None - }; - - results.push(GitFileDiff { - path: normalized_path, - diff: String::new(), - old_lines: None, - new_lines: None, - is_binary: true, - is_image: true, - old_image_data, - new_image_data, - old_image_mime: old_image_mime.map(str::to_string), - new_image_mime: new_image_mime.map(str::to_string), - }); - continue; - } - - let patch = match git2::Patch::from_diff(&diff, index) { - Ok(patch) => patch, - Err(_) => continue, - }; - let Some(mut patch) = patch else { - continue; - }; - let content = match diff_patch_to_string(&mut patch) { - Ok(content) => content, - Err(_) => continue, - }; - if content.trim().is_empty() { - continue; - } - results.push(GitFileDiff { - path: normalized_path, - diff: content, - old_lines, - new_lines, - is_binary: false, - is_image: false, - old_image_data: None, - new_image_data: None, - old_image_mime: None, - new_image_mime: None, - }); - } - - Ok(results) - }) - .await - .map_err(|e| e.to_string())? -} - -async fn get_git_log_inner( - workspaces: &Mutex>, - workspace_id: String, - limit: Option, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let max_items = limit.unwrap_or(40); - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push_head().map_err(|e| e.to_string())?; - revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; - - let mut total = 0usize; - for oid_result in revwalk { - oid_result.map_err(|e| e.to_string())?; - total += 1; - } - - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push_head().map_err(|e| e.to_string())?; - revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; - - let mut entries = Vec::new(); - for oid_result in revwalk.take(max_items) { - let oid = oid_result.map_err(|e| e.to_string())?; - let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; - entries.push(commit_to_entry(commit)); - } - - let mut ahead = 0usize; - let mut behind = 0usize; - let mut ahead_entries = Vec::new(); - let mut behind_entries = Vec::new(); - let mut upstream = None; - - if let Ok(head) = repo.head() { - if head.is_branch() { - if let Some(branch_name) = head.shorthand() { - if let Ok(branch) = repo.find_branch(branch_name, BranchType::Local) { - if let Ok(upstream_branch) = branch.upstream() { - let upstream_ref = upstream_branch.get(); - upstream = upstream_ref - .shorthand() - .map(|name| name.to_string()) - .or_else(|| upstream_ref.name().map(|name| name.to_string())); - if let (Some(head_oid), Some(upstream_oid)) = - (head.target(), upstream_ref.target()) - { - let (ahead_count, behind_count) = repo - .graph_ahead_behind(head_oid, upstream_oid) - .map_err(|e| e.to_string())?; - ahead = ahead_count; - behind = behind_count; - - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push(head_oid).map_err(|e| e.to_string())?; - revwalk.hide(upstream_oid).map_err(|e| e.to_string())?; - revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; - for oid_result in revwalk.take(max_items) { - let oid = oid_result.map_err(|e| e.to_string())?; - let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; - ahead_entries.push(commit_to_entry(commit)); - } - - let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; - revwalk.push(upstream_oid).map_err(|e| e.to_string())?; - revwalk.hide(head_oid).map_err(|e| e.to_string())?; - revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; - for oid_result in revwalk.take(max_items) { - let oid = oid_result.map_err(|e| e.to_string())?; - let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; - behind_entries.push(commit_to_entry(commit)); - } - } - } - } - } - } - } - - Ok(GitLogResponse { - total, - entries, - ahead, - behind, - ahead_entries, - behind_entries, - upstream, - }) -} - -async fn get_git_commit_diff_inner( - workspaces: &Mutex>, - app_settings: &Mutex, - workspace_id: String, - sha: String, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - - let ignore_whitespace_changes = { - let settings = app_settings.lock().await; - settings.git_diff_ignore_whitespace_changes - }; - - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let oid = git2::Oid::from_str(&sha).map_err(|e| e.to_string())?; - let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; - let commit_tree = commit.tree().map_err(|e| e.to_string())?; - let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok()); - - let mut options = DiffOptions::new(); - options.ignore_whitespace_change(ignore_whitespace_changes); - let diff = repo - .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut options)) - .map_err(|e| e.to_string())?; - - let mut results = Vec::new(); - for (index, delta) in diff.deltas().enumerate() { - let old_path = delta.old_file().path(); - let new_path = delta.new_file().path(); - let display_path = new_path.or(old_path); - let Some(display_path) = display_path else { - continue; - }; - let old_path_str = old_path.map(|path| path.to_string_lossy()); - let new_path_str = new_path.map(|path| path.to_string_lossy()); - let display_path_str = display_path.to_string_lossy(); - let normalized_path = normalize_git_path(&display_path_str); - let old_image_mime = old_path_str.as_deref().and_then(image_mime_type); - let new_image_mime = new_path_str.as_deref().and_then(image_mime_type); - let is_image = old_image_mime.is_some() || new_image_mime.is_some(); - let is_deleted = delta.status() == git2::Delta::Deleted; - let is_added = delta.status() == git2::Delta::Added; - - let old_lines = if !is_added { - parent_tree - .as_ref() - .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_lines) - } else { - None - }; - - let new_lines = if !is_deleted { - new_path - .and_then(|path| commit_tree.get_path(path).ok()) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_lines) - } else { - None - }; - - if is_image { - let old_image_data = if !is_added && old_image_mime.is_some() { - parent_tree - .as_ref() - .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_base64) - } else { - None - }; - - let new_image_data = if !is_deleted && new_image_mime.is_some() { - new_path - .and_then(|path| commit_tree.get_path(path).ok()) - .and_then(|entry| repo.find_blob(entry.id()).ok()) - .and_then(blob_to_base64) - } else { - None - }; - - results.push(GitCommitDiff { - path: normalized_path, - status: status_for_delta(delta.status()).to_string(), - diff: String::new(), - old_lines: None, - new_lines: None, - is_binary: true, - is_image: true, - old_image_data, - new_image_data, - old_image_mime: old_image_mime.map(str::to_string), - new_image_mime: new_image_mime.map(str::to_string), - }); - continue; - } - - let patch = match git2::Patch::from_diff(&diff, index) { - Ok(patch) => patch, - Err(_) => continue, - }; - let Some(mut patch) = patch else { - continue; - }; - let content = match diff_patch_to_string(&mut patch) { - Ok(content) => content, - Err(_) => continue, - }; - if content.trim().is_empty() { - continue; - } - results.push(GitCommitDiff { - path: normalized_path, - status: status_for_delta(delta.status()).to_string(), - diff: content, - old_lines, - new_lines, - is_binary: false, - is_image: false, - old_image_data: None, - new_image_data: None, - old_image_mime: None, - new_image_mime: None, - }); - } - - Ok(results) -} - -async fn get_git_remote_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let remotes = repo.remotes().map_err(|e| e.to_string())?; - let name = if remotes.iter().any(|remote| remote == Some("origin")) { - "origin".to_string() - } else { - remotes.iter().flatten().next().unwrap_or("").to_string() - }; - if name.is_empty() { - return Ok(None); - } - let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; - Ok(remote.url().map(|url| url.to_string())) -} - -async fn get_github_issues_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo_name = github_repo_from_path(&repo_root)?; - - let output = tokio_command("gh") - .args([ - "issue", - "list", - "--repo", - &repo_name, - "--limit", - "50", - "--json", - "number,title,url,updatedAt", - ]) - .current_dir(&repo_root) - .output() - .await - .map_err(|e| format!("Failed to run gh: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("GitHub CLI command failed.".to_string()); - } - return Err(detail.to_string()); - } - - let issues: Vec = - serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; - - let search_query = format!("repo:{repo_name} is:issue is:open").replace(' ', "+"); - let total = match tokio_command("gh") - .args([ - "api", - &format!("/search/issues?q={search_query}"), - "--jq", - ".total_count", - ]) - .current_dir(&repo_root) - .output() - .await - { - Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) - .trim() - .parse::() - .unwrap_or(issues.len()), - _ => issues.len(), - }; - - Ok(GitHubIssuesResponse { total, issues }) -} - -async fn get_github_pull_requests_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo_name = github_repo_from_path(&repo_root)?; - - let output = tokio_command("gh") - .args([ - "pr", - "list", - "--repo", - &repo_name, - "--state", - "open", - "--limit", - "50", - "--json", - "number,title,url,updatedAt,createdAt,body,headRefName,baseRefName,isDraft,author", - ]) - .current_dir(&repo_root) - .output() - .await - .map_err(|e| format!("Failed to run gh: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("GitHub CLI command failed.".to_string()); - } - return Err(detail.to_string()); - } - - let pull_requests: Vec = - serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; - - let search_query = format!("repo:{repo_name} is:pr is:open").replace(' ', "+"); - let total = match tokio_command("gh") - .args([ - "api", - &format!("/search/issues?q={search_query}"), - "--jq", - ".total_count", - ]) - .current_dir(&repo_root) - .output() - .await - { - Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) - .trim() - .parse::() - .unwrap_or(pull_requests.len()), - _ => pull_requests.len(), - }; - - Ok(GitHubPullRequestsResponse { - total, - pull_requests, - }) -} - -async fn get_github_pull_request_diff_inner( - workspaces: &Mutex>, - workspace_id: String, - pr_number: u64, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo_name = github_repo_from_path(&repo_root)?; - - let output = tokio_command("gh") - .args([ - "pr", - "diff", - &pr_number.to_string(), - "--repo", - &repo_name, - "--color", - "never", - ]) - .current_dir(&repo_root) - .output() - .await - .map_err(|e| format!("Failed to run gh: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("GitHub CLI command failed.".to_string()); - } - return Err(detail.to_string()); - } - - let diff_text = String::from_utf8_lossy(&output.stdout); - Ok(parse_pr_diff(&diff_text)) -} - -async fn get_github_pull_request_comments_inner( - workspaces: &Mutex>, - workspace_id: String, - pr_number: u64, -) -> Result, String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo_name = github_repo_from_path(&repo_root)?; - - let comments_endpoint = format!("/repos/{repo_name}/issues/{pr_number}/comments?per_page=30"); - let jq_filter = r#"[.[] | {id, body, createdAt: .created_at, url: .html_url, author: (if .user then {login: .user.login} else null end)}]"#; - - let output = tokio_command("gh") - .args(["api", &comments_endpoint, "--jq", jq_filter]) - .current_dir(&repo_root) - .output() - .await - .map_err(|e| format!("Failed to run gh: {e}"))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("GitHub CLI command failed.".to_string()); - } - return Err(detail.to_string()); - } - - let comments: Vec = - serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; - - Ok(comments) -} - -async fn list_git_branches_inner( - workspaces: &Mutex>, - workspace_id: String, -) -> Result { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let mut branches = Vec::new(); - let refs = repo - .branches(Some(BranchType::Local)) - .map_err(|e| e.to_string())?; - for branch_result in refs { - let (branch, _) = branch_result.map_err(|e| e.to_string())?; - let name = branch.name().ok().flatten().unwrap_or("").to_string(); - if name.is_empty() { - continue; - } - let last_commit = branch - .get() - .target() - .and_then(|oid| repo.find_commit(oid).ok()) - .map(|commit| commit.time().seconds()) - .unwrap_or(0); - branches.push(BranchInfo { name, last_commit }); - } - branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); - Ok(json!({ "branches": branches })) -} - -async fn checkout_git_branch_inner( - workspaces: &Mutex>, - workspace_id: String, - name: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) -} - -async fn create_git_branch_inner( - workspaces: &Mutex>, - workspace_id: String, - name: String, -) -> Result<(), String> { - let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; - let repo_root = resolve_git_root(&entry)?; - let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; - let head = repo.head().map_err(|e| e.to_string())?; - let target = head.peel_to_commit().map_err(|e| e.to_string())?; - repo.branch(&name, &target, false) - .map_err(|e| e.to_string())?; - checkout_branch(&repo, &name).map_err(|e| e.to_string()) -} +#[cfg(test)] +#[path = "git_ui_core/tests.rs"] +mod tests; pub(crate) async fn resolve_repo_root_for_workspace_core( workspaces: &Mutex>, workspace_id: String, ) -> Result { - resolve_repo_root_for_workspace(workspaces, workspace_id).await + context::resolve_repo_root_for_workspace(workspaces, workspace_id).await } pub(crate) fn collect_workspace_diff_core(repo_root: &Path) -> Result { - collect_workspace_diff(repo_root) + diff::collect_workspace_diff(repo_root) } pub(crate) async fn get_git_status_core( workspaces: &Mutex>, workspace_id: String, ) -> Result { - get_git_status_inner(workspaces, workspace_id).await + diff::get_git_status_inner(workspaces, workspace_id).await } pub(crate) async fn list_git_roots_core( @@ -1433,7 +47,7 @@ pub(crate) async fn list_git_roots_core( workspace_id: String, depth: Option, ) -> Result, String> { - list_git_roots_inner(workspaces, workspace_id, depth).await + commands::list_git_roots_inner(workspaces, workspace_id, depth).await } pub(crate) async fn get_git_diffs_core( @@ -1441,7 +55,7 @@ pub(crate) async fn get_git_diffs_core( app_settings: &Mutex, workspace_id: String, ) -> Result, String> { - get_git_diffs_inner(workspaces, app_settings, workspace_id).await + diff::get_git_diffs_inner(workspaces, app_settings, workspace_id).await } pub(crate) async fn get_git_log_core( @@ -1449,7 +63,7 @@ pub(crate) async fn get_git_log_core( workspace_id: String, limit: Option, ) -> Result { - get_git_log_inner(workspaces, workspace_id, limit).await + log::get_git_log_inner(workspaces, workspace_id, limit).await } pub(crate) async fn get_git_commit_diff_core( @@ -1458,14 +72,14 @@ pub(crate) async fn get_git_commit_diff_core( workspace_id: String, sha: String, ) -> Result, String> { - get_git_commit_diff_inner(workspaces, app_settings, workspace_id, sha).await + diff::get_git_commit_diff_inner(workspaces, app_settings, workspace_id, sha).await } pub(crate) async fn get_git_remote_core( workspaces: &Mutex>, workspace_id: String, ) -> Result, String> { - get_git_remote_inner(workspaces, workspace_id).await + log::get_git_remote_inner(workspaces, workspace_id).await } pub(crate) async fn stage_git_file_core( @@ -1473,14 +87,14 @@ pub(crate) async fn stage_git_file_core( workspace_id: String, path: String, ) -> Result<(), String> { - stage_git_file_inner(workspaces, workspace_id, path).await + commands::stage_git_file_inner(workspaces, workspace_id, path).await } pub(crate) async fn stage_git_all_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - stage_git_all_inner(workspaces, workspace_id).await + commands::stage_git_all_inner(workspaces, workspace_id).await } pub(crate) async fn unstage_git_file_core( @@ -1488,7 +102,7 @@ pub(crate) async fn unstage_git_file_core( workspace_id: String, path: String, ) -> Result<(), String> { - unstage_git_file_inner(workspaces, workspace_id, path).await + commands::unstage_git_file_inner(workspaces, workspace_id, path).await } pub(crate) async fn revert_git_file_core( @@ -1496,14 +110,14 @@ pub(crate) async fn revert_git_file_core( workspace_id: String, path: String, ) -> Result<(), String> { - revert_git_file_inner(workspaces, workspace_id, path).await + commands::revert_git_file_inner(workspaces, workspace_id, path).await } pub(crate) async fn revert_git_all_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - revert_git_all_inner(workspaces, workspace_id).await + commands::revert_git_all_inner(workspaces, workspace_id).await } pub(crate) async fn commit_git_core( @@ -1511,49 +125,49 @@ pub(crate) async fn commit_git_core( workspace_id: String, message: String, ) -> Result<(), String> { - commit_git_inner(workspaces, workspace_id, message).await + commands::commit_git_inner(workspaces, workspace_id, message).await } pub(crate) async fn push_git_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - push_git_inner(workspaces, workspace_id).await + commands::push_git_inner(workspaces, workspace_id).await } pub(crate) async fn pull_git_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - pull_git_inner(workspaces, workspace_id).await + commands::pull_git_inner(workspaces, workspace_id).await } pub(crate) async fn fetch_git_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - fetch_git_inner(workspaces, workspace_id).await + commands::fetch_git_inner(workspaces, workspace_id).await } pub(crate) async fn sync_git_core( workspaces: &Mutex>, workspace_id: String, ) -> Result<(), String> { - sync_git_inner(workspaces, workspace_id).await + commands::sync_git_inner(workspaces, workspace_id).await } pub(crate) async fn get_github_issues_core( workspaces: &Mutex>, workspace_id: String, ) -> Result { - get_github_issues_inner(workspaces, workspace_id).await + github::get_github_issues_inner(workspaces, workspace_id).await } pub(crate) async fn get_github_pull_requests_core( workspaces: &Mutex>, workspace_id: String, ) -> Result { - get_github_pull_requests_inner(workspaces, workspace_id).await + github::get_github_pull_requests_inner(workspaces, workspace_id).await } pub(crate) async fn get_github_pull_request_diff_core( @@ -1561,7 +175,7 @@ pub(crate) async fn get_github_pull_request_diff_core( workspace_id: String, pr_number: u64, ) -> Result, String> { - get_github_pull_request_diff_inner(workspaces, workspace_id, pr_number).await + github::get_github_pull_request_diff_inner(workspaces, workspace_id, pr_number).await } pub(crate) async fn get_github_pull_request_comments_core( @@ -1569,14 +183,22 @@ pub(crate) async fn get_github_pull_request_comments_core( workspace_id: String, pr_number: u64, ) -> Result, String> { - get_github_pull_request_comments_inner(workspaces, workspace_id, pr_number).await + github::get_github_pull_request_comments_inner(workspaces, workspace_id, pr_number).await +} + +pub(crate) async fn checkout_github_pull_request_core( + workspaces: &Mutex>, + workspace_id: String, + pr_number: u64, +) -> Result<(), String> { + github::checkout_github_pull_request_inner(workspaces, workspace_id, pr_number).await } pub(crate) async fn list_git_branches_core( workspaces: &Mutex>, workspace_id: String, ) -> Result { - list_git_branches_inner(workspaces, workspace_id).await + commands::list_git_branches_inner(workspaces, workspace_id).await } pub(crate) async fn checkout_git_branch_core( @@ -1584,7 +206,7 @@ pub(crate) async fn checkout_git_branch_core( workspace_id: String, name: String, ) -> Result<(), String> { - checkout_git_branch_inner(workspaces, workspace_id, name).await + commands::checkout_git_branch_inner(workspaces, workspace_id, name).await } pub(crate) async fn create_git_branch_core( @@ -1592,71 +214,5 @@ pub(crate) async fn create_git_branch_core( workspace_id: String, name: String, ) -> Result<(), String> { - create_git_branch_inner(workspaces, workspace_id, name).await -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::Path; - - fn create_temp_repo() -> (PathBuf, Repository) { - let root = - std::env::temp_dir().join(format!("codex-monitor-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&root).expect("create temp repo root"); - let repo = Repository::init(&root).expect("init repo"); - (root, repo) - } - - #[test] - fn collect_workspace_diff_prefers_staged_changes() { - let (root, repo) = create_temp_repo(); - let file_path = root.join("staged.txt"); - fs::write(&file_path, "staged\n").expect("write staged file"); - let mut index = repo.index().expect("index"); - index.add_path(Path::new("staged.txt")).expect("add path"); - index.write().expect("write index"); - - let diff = collect_workspace_diff(&root).expect("collect diff"); - assert!(diff.contains("staged.txt")); - assert!(diff.contains("staged")); - } - - #[test] - fn collect_workspace_diff_falls_back_to_workdir() { - let (root, _repo) = create_temp_repo(); - let file_path = root.join("unstaged.txt"); - fs::write(&file_path, "unstaged\n").expect("write unstaged file"); - - let diff = collect_workspace_diff(&root).expect("collect diff"); - assert!(diff.contains("unstaged.txt")); - assert!(diff.contains("unstaged")); - } - - #[test] - fn action_paths_for_file_expands_renames() { - let (root, repo) = create_temp_repo(); - fs::write(root.join("a.txt"), "hello\n").expect("write file"); - - let mut index = repo.index().expect("repo index"); - index.add_path(Path::new("a.txt")).expect("add path"); - let tree_id = index.write_tree().expect("write tree"); - let tree = repo.find_tree(tree_id).expect("find tree"); - let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); - repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) - .expect("commit"); - - fs::rename(root.join("a.txt"), root.join("b.txt")).expect("rename file"); - - let mut index = repo.index().expect("repo index"); - index - .remove_path(Path::new("a.txt")) - .expect("remove old path"); - index.add_path(Path::new("b.txt")).expect("add new path"); - index.write().expect("write index"); - - let paths = action_paths_for_file(&root, "b.txt"); - assert_eq!(paths, vec!["a.txt".to_string(), "b.txt".to_string()]); - } + commands::create_git_branch_inner(workspaces, workspace_id, name).await } diff --git a/src-tauri/src/shared/git_ui_core/commands.rs b/src-tauri/src/shared/git_ui_core/commands.rs new file mode 100644 index 0000000000..6cb2d447aa --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/commands.rs @@ -0,0 +1,383 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use git2::{BranchType, Repository, Status, StatusOptions}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use crate::git_utils::{checkout_branch, list_git_roots as scan_git_roots, resolve_git_root}; +use crate::shared::process_core::tokio_command; +use crate::types::{BranchInfo, WorkspaceEntry}; +use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; + +use super::context::workspace_entry_for_id; + +async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<(), String> { + let git_bin = resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let output = tokio_command(git_bin) + .args(args) + .current_dir(repo_root) + .env("PATH", git_env_path()) + .output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git command failed.".to_string()); + } + Err(detail.to_string()) +} + +pub(super) fn action_paths_for_file(repo_root: &Path, path: &str) -> Vec { + let target = normalize_git_path(path).trim().to_string(); + if target.is_empty() { + return Vec::new(); + } + + let repo = match Repository::open(repo_root) { + Ok(repo) => repo, + Err(_) => return vec![target], + }; + + let mut status_options = StatusOptions::new(); + status_options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true) + .include_ignored(false); + + let statuses = match repo.statuses(Some(&mut status_options)) { + Ok(statuses) => statuses, + Err(_) => return vec![target], + }; + + for entry in statuses.iter() { + let status = entry.status(); + if !(status.contains(Status::WT_RENAMED) || status.contains(Status::INDEX_RENAMED)) { + continue; + } + let delta = entry.index_to_workdir().or_else(|| entry.head_to_index()); + let Some(delta) = delta else { + continue; + }; + let (Some(old_path), Some(new_path)) = (delta.old_file().path(), delta.new_file().path()) + else { + continue; + }; + let old_path = normalize_git_path(old_path.to_string_lossy().as_ref()); + let new_path = normalize_git_path(new_path.to_string_lossy().as_ref()); + if old_path != target && new_path != target { + continue; + } + if old_path == new_path || new_path.is_empty() { + return vec![target]; + } + let mut result = Vec::new(); + if !old_path.is_empty() { + result.push(old_path); + } + if !new_path.is_empty() && !result.contains(&new_path) { + result.push(new_path); + } + return if result.is_empty() { + vec![target] + } else { + result + }; + } + + vec![target] +} + +fn parse_upstream_ref(name: &str) -> Option<(String, String)> { + let trimmed = name.strip_prefix("refs/remotes/").unwrap_or(name); + let mut parts = trimmed.splitn(2, '/'); + let remote = parts.next()?; + let branch = parts.next()?; + if remote.is_empty() || branch.is_empty() { + return None; + } + Some((remote.to_string(), branch.to_string())) +} + +fn upstream_remote_and_branch(repo_root: &Path) -> Result, String> { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let head = match repo.head() { + Ok(head) => head, + Err(_) => return Ok(None), + }; + if !head.is_branch() { + return Ok(None); + } + let branch_name = match head.shorthand() { + Some(name) => name, + None => return Ok(None), + }; + let branch = repo + .find_branch(branch_name, BranchType::Local) + .map_err(|e| e.to_string())?; + let upstream_branch = match branch.upstream() { + Ok(upstream) => upstream, + Err(_) => return Ok(None), + }; + let upstream_ref = upstream_branch.get(); + let upstream_name = upstream_ref.name().or_else(|| upstream_ref.shorthand()); + Ok(upstream_name.and_then(parse_upstream_ref)) +} + +async fn push_with_upstream(repo_root: &Path) -> Result<(), String> { + let upstream = upstream_remote_and_branch(repo_root)?; + if let Some((remote, branch)) = upstream { + let _ = run_git_command(repo_root, &["fetch", "--prune", remote.as_str()]).await; + let refspec = format!("HEAD:{branch}"); + return run_git_command(repo_root, &["push", remote.as_str(), refspec.as_str()]).await; + } + run_git_command(repo_root, &["push"]).await +} + +async fn fetch_with_default_remote(repo_root: &Path) -> Result<(), String> { + let upstream = upstream_remote_and_branch(repo_root)?; + if let Some((remote, _)) = upstream { + return run_git_command(repo_root, &["fetch", "--prune", remote.as_str()]).await; + } + run_git_command(repo_root, &["fetch", "--prune"]).await +} + +async fn pull_with_default_strategy(repo_root: &Path) -> Result<(), String> { + fn autostash_unsupported(lower: &str) -> bool { + lower.contains("unknown option") && lower.contains("autostash") + } + + fn needs_reconcile_strategy(lower: &str) -> bool { + lower.contains("need to specify how to reconcile divergent branches") + || lower.contains("you have divergent branches") + } + + match run_git_command(repo_root, &["pull", "--autostash"]).await { + Ok(()) => Ok(()), + Err(err) => { + let lower = err.to_lowercase(); + if autostash_unsupported(&lower) { + match run_git_command(repo_root, &["pull"]).await { + Ok(()) => Ok(()), + Err(no_autostash_err) => { + let no_autostash_lower = no_autostash_err.to_lowercase(); + if needs_reconcile_strategy(&no_autostash_lower) { + return run_git_command(repo_root, &["pull", "--no-rebase"]).await; + } + Err(no_autostash_err) + } + } + } else if needs_reconcile_strategy(&lower) { + match run_git_command(repo_root, &["pull", "--no-rebase", "--autostash"]).await { + Ok(()) => Ok(()), + Err(merge_err) => { + let merge_lower = merge_err.to_lowercase(); + if autostash_unsupported(&merge_lower) { + return run_git_command(repo_root, &["pull", "--no-rebase"]).await; + } + Err(merge_err) + } + } + } else { + Err(err) + } + } + } +} + +pub(super) async fn stage_git_file_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + for path in action_paths_for_file(&repo_root, &path) { + run_git_command(&repo_root, &["add", "-A", "--", &path]).await?; + } + Ok(()) +} + +pub(super) async fn stage_git_all_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + run_git_command(&repo_root, &["add", "-A"]).await +} + +pub(super) async fn unstage_git_file_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + for path in action_paths_for_file(&repo_root, &path) { + run_git_command(&repo_root, &["restore", "--staged", "--", &path]).await?; + } + Ok(()) +} + +pub(super) async fn revert_git_file_inner( + workspaces: &Mutex>, + workspace_id: String, + path: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + for path in action_paths_for_file(&repo_root, &path) { + if run_git_command( + &repo_root, + &["restore", "--staged", "--worktree", "--", &path], + ) + .await + .is_ok() + { + continue; + } + run_git_command(&repo_root, &["clean", "-f", "--", &path]).await?; + } + Ok(()) +} + +pub(super) async fn revert_git_all_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + run_git_command( + &repo_root, + &["restore", "--staged", "--worktree", "--", "."], + ) + .await?; + run_git_command(&repo_root, &["clean", "-f", "-d"]).await +} + +pub(super) async fn commit_git_inner( + workspaces: &Mutex>, + workspace_id: String, + message: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + run_git_command(&repo_root, &["commit", "-m", &message]).await +} + +pub(super) async fn push_git_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + push_with_upstream(&repo_root).await +} + +pub(super) async fn pull_git_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + pull_with_default_strategy(&repo_root).await +} + +pub(super) async fn fetch_git_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + fetch_with_default_remote(&repo_root).await +} + +pub(super) async fn sync_git_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + pull_with_default_strategy(&repo_root).await?; + push_with_upstream(&repo_root).await +} + +pub(super) async fn list_git_roots_inner( + workspaces: &Mutex>, + workspace_id: String, + depth: Option, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let root = PathBuf::from(&entry.path); + let depth = depth.unwrap_or(2).clamp(1, 6); + Ok(scan_git_roots(&root, depth, 200)) +} + +pub(super) async fn list_git_branches_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let mut branches = Vec::new(); + let refs = repo + .branches(Some(BranchType::Local)) + .map_err(|e| e.to_string())?; + for branch_result in refs { + let (branch, _) = branch_result.map_err(|e| e.to_string())?; + let name = branch.name().ok().flatten().unwrap_or("").to_string(); + if name.is_empty() { + continue; + } + let last_commit = branch + .get() + .target() + .and_then(|oid| repo.find_commit(oid).ok()) + .map(|commit| commit.time().seconds()) + .unwrap_or(0); + branches.push(BranchInfo { name, last_commit }); + } + branches.sort_by(|a, b| b.last_commit.cmp(&a.last_commit)); + Ok(json!({ "branches": branches })) +} + +pub(super) async fn checkout_git_branch_inner( + workspaces: &Mutex>, + workspace_id: String, + name: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + checkout_branch(&repo, &name).map_err(|e| e.to_string()) +} + +pub(super) async fn create_git_branch_inner( + workspaces: &Mutex>, + workspace_id: String, + name: String, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let head = repo.head().map_err(|e| e.to_string())?; + let target = head.peel_to_commit().map_err(|e| e.to_string())?; + repo.branch(&name, &target, false) + .map_err(|e| e.to_string())?; + checkout_branch(&repo, &name).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/shared/git_ui_core/context.rs b/src-tauri/src/shared/git_ui_core/context.rs new file mode 100644 index 0000000000..13e02648a1 --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/context.rs @@ -0,0 +1,26 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::sync::Mutex; + +use crate::git_utils::resolve_git_root; +use crate::types::WorkspaceEntry; + +pub(super) async fn workspace_entry_for_id( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result { + let workspaces = workspaces.lock().await; + workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string()) +} + +pub(super) async fn resolve_repo_root_for_workspace( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + resolve_git_root(&entry) +} diff --git a/src-tauri/src/shared/git_ui_core/diff.rs b/src-tauri/src/shared/git_ui_core/diff.rs new file mode 100644 index 0000000000..0c6af07e8f --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/diff.rs @@ -0,0 +1,747 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use git2::{DiffOptions, Repository, Status, StatusOptions}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use crate::git_utils::{ + diff_patch_to_string, diff_stats_for_path, image_mime_type, resolve_git_root, +}; +use crate::shared::process_core::std_command; +use crate::types::{AppSettings, GitCommitDiff, GitFileDiff, GitFileStatus, WorkspaceEntry}; +use crate::utils::{git_env_path, normalize_git_path, resolve_git_binary}; + +use super::context::workspace_entry_for_id; + +const INDEX_SKIP_WORKTREE_FLAG: u16 = 0x4000; +const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; +const MAX_TEXT_DIFF_BYTES: usize = 2 * 1024 * 1024; + +fn encode_image_base64(data: &[u8]) -> Option { + if data.len() > MAX_IMAGE_BYTES { + return None; + } + Some(STANDARD.encode(data)) +} + +fn blob_to_base64(blob: git2::Blob) -> Option { + if blob.size() > MAX_IMAGE_BYTES { + return None; + } + encode_image_base64(blob.content()) +} + +fn read_image_base64(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + if metadata.len() > MAX_IMAGE_BYTES as u64 { + return None; + } + let data = fs::read(path).ok()?; + encode_image_base64(&data) +} + +fn bytes_look_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8192).any(|byte| *byte == 0) +} + +fn split_lines_preserving_newlines(content: &str) -> Vec { + if content.is_empty() { + return Vec::new(); + } + content + .split_inclusive('\n') + .map(ToString::to_string) + .collect() +} + +fn blob_to_lines(blob: git2::Blob<'_>) -> Option> { + if blob.size() > MAX_TEXT_DIFF_BYTES || blob.is_binary() { + return None; + } + let content = String::from_utf8_lossy(blob.content()); + Some(split_lines_preserving_newlines(content.as_ref())) +} + +fn read_text_lines(path: &Path) -> Option> { + let metadata = fs::metadata(path).ok()?; + if metadata.len() > MAX_TEXT_DIFF_BYTES as u64 { + return None; + } + let data = fs::read(path).ok()?; + if bytes_look_binary(&data) { + return None; + } + let content = String::from_utf8_lossy(&data); + Some(split_lines_preserving_newlines(content.as_ref())) +} + +fn status_for_index(status: Status) -> Option<&'static str> { + if status.contains(Status::INDEX_NEW) { + Some("A") + } else if status.contains(Status::INDEX_MODIFIED) { + Some("M") + } else if status.contains(Status::INDEX_DELETED) { + Some("D") + } else if status.contains(Status::INDEX_RENAMED) { + Some("R") + } else if status.contains(Status::INDEX_TYPECHANGE) { + Some("T") + } else { + None + } +} + +fn status_for_workdir(status: Status) -> Option<&'static str> { + if status.contains(Status::WT_NEW) { + Some("A") + } else if status.contains(Status::WT_MODIFIED) { + Some("M") + } else if status.contains(Status::WT_DELETED) { + Some("D") + } else if status.contains(Status::WT_RENAMED) { + Some("R") + } else if status.contains(Status::WT_TYPECHANGE) { + Some("T") + } else { + None + } +} + +fn status_for_delta(status: git2::Delta) -> &'static str { + match status { + git2::Delta::Added => "A", + git2::Delta::Modified => "M", + git2::Delta::Deleted => "D", + git2::Delta::Renamed => "R", + git2::Delta::Typechange => "T", + _ => "M", + } +} + +fn has_ignored_parent_directory(repo: &Repository, path: &Path) -> bool { + let mut current = path.parent(); + while let Some(parent) = current { + if parent.as_os_str().is_empty() { + break; + } + let probe = parent.join(".codexmonitor-ignore-probe"); + if repo.status_should_ignore(&probe).unwrap_or(false) { + return true; + } + current = parent.parent(); + } + false +} + +pub(super) fn collect_ignored_paths_with_git( + repo: &Repository, + paths: &[PathBuf], +) -> Option> { + if paths.is_empty() { + return Some(HashSet::new()); + } + + let repo_root = repo.workdir()?; + let git_bin = resolve_git_binary().ok()?; + let mut child = std_command(git_bin) + .arg("check-ignore") + .arg("--stdin") + .arg("-z") + .current_dir(repo_root) + .env("PATH", git_env_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let mut stdout = child.stdout.take()?; + let stdout_thread = std::thread::spawn(move || { + let mut buffer = Vec::new(); + stdout.read_to_end(&mut buffer).ok()?; + Some(buffer) + }); + + let wrote_all_input = { + let mut wrote_all = true; + if let Some(mut stdin) = child.stdin.take() { + for path in paths { + if stdin + .write_all(path.as_os_str().as_encoded_bytes()) + .is_err() + { + wrote_all = false; + break; + } + if stdin.write_all(&[0]).is_err() { + wrote_all = false; + break; + } + } + } else { + wrote_all = false; + } + wrote_all + }; + + if !wrote_all_input { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_thread.join(); + return None; + } + + let status = child.wait().ok()?; + let stdout = stdout_thread.join().ok().flatten()?; + match status.code() { + Some(0) | Some(1) => {} + _ => return None, + } + + let mut ignored_paths = HashSet::new(); + for raw in stdout.split(|byte| *byte == 0) { + if raw.is_empty() { + continue; + } + let path = String::from_utf8_lossy(raw); + ignored_paths.insert(PathBuf::from(path.as_ref())); + } + Some(ignored_paths) +} + +pub(super) fn check_ignore_with_git(repo: &Repository, path: &Path) -> Option { + let ignored_paths = collect_ignored_paths_with_git(repo, &[path.to_path_buf()])?; + Some(ignored_paths.contains(path)) +} + +fn is_tracked_path(repo: &Repository, path: &Path) -> bool { + if let Ok(index) = repo.index() { + if index.get_path(path, 0).is_some() { + return true; + } + } + if let Ok(head) = repo.head() { + if let Ok(tree) = head.peel_to_tree() { + if tree.get_path(path).is_ok() { + return true; + } + } + } + false +} + +pub(super) fn should_skip_ignored_path_with_cache( + repo: &Repository, + path: &Path, + ignored_paths: Option<&HashSet>, +) -> bool { + if is_tracked_path(repo, path) { + return false; + } + if let Some(ignored_paths) = ignored_paths { + return ignored_paths.contains(path); + } + if let Some(ignored) = check_ignore_with_git(repo, path) { + return ignored; + } + // Fallback when git check-ignore is unavailable. + repo.status_should_ignore(path).unwrap_or(false) || has_ignored_parent_directory(repo, path) +} + +fn build_combined_diff(repo: &Repository, diff: &git2::Diff) -> String { + let diff_entries: Vec<(usize, PathBuf)> = diff + .deltas() + .enumerate() + .filter_map(|(index, delta)| { + delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|path| (index, path.to_path_buf())) + }) + .collect(); + let diff_paths: Vec = diff_entries.iter().map(|(_, path)| path.clone()).collect(); + let ignored_paths = collect_ignored_paths_with_git(repo, &diff_paths); + + let mut combined_diff = String::new(); + for (index, path) in diff_entries { + if should_skip_ignored_path_with_cache(repo, &path, ignored_paths.as_ref()) { + continue; + } + let patch = match git2::Patch::from_diff(diff, index) { + Ok(patch) => patch, + Err(_) => continue, + }; + let Some(mut patch) = patch else { + continue; + }; + let content = match diff_patch_to_string(&mut patch) { + Ok(content) => content, + Err(_) => continue, + }; + if content.trim().is_empty() { + continue; + } + if !combined_diff.is_empty() { + combined_diff.push_str("\n\n"); + } + combined_diff.push_str(&format!("=== {} ===\n", path.display())); + combined_diff.push_str(&content); + } + combined_diff +} + +pub(super) fn collect_workspace_diff(repo_root: &Path) -> Result { + let repo = Repository::open(repo_root).map_err(|e| e.to_string())?; + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); + + let mut options = DiffOptions::new(); + let index = repo.index().map_err(|e| e.to_string())?; + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_index(Some(tree), Some(&index), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_index(None, Some(&index), Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + let combined_diff = build_combined_diff(&repo, &diff); + if !combined_diff.trim().is_empty() { + return Ok(combined_diff); + } + + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_workdir_with_index(None, Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + Ok(build_combined_diff(&repo, &diff)) +} + +pub(super) async fn get_git_status_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + + let branch_name = repo + .head() + .ok() + .and_then(|head| head.shorthand().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown".to_string()); + + let mut status_options = StatusOptions::new(); + status_options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true) + .include_ignored(false); + + let statuses = repo + .statuses(Some(&mut status_options)) + .map_err(|e| e.to_string())?; + let status_paths: Vec = statuses + .iter() + .filter_map(|entry| entry.path().map(PathBuf::from)) + .filter(|path| !path.as_os_str().is_empty()) + .collect(); + let ignored_paths = collect_ignored_paths_with_git(&repo, &status_paths); + + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); + let index = repo.index().ok(); + + let mut files = Vec::new(); + let mut staged_files = Vec::new(); + let mut unstaged_files = Vec::new(); + let mut total_additions = 0i64; + let mut total_deletions = 0i64; + for entry in statuses.iter() { + let path = entry.path().unwrap_or(""); + if path.is_empty() { + continue; + } + if should_skip_ignored_path_with_cache(&repo, Path::new(path), ignored_paths.as_ref()) { + continue; + } + if let Some(index) = index.as_ref() { + if let Some(entry) = index.get_path(Path::new(path), 0) { + if entry.flags_extended & INDEX_SKIP_WORKTREE_FLAG != 0 { + continue; + } + } + } + let status = entry.status(); + let normalized_path = normalize_git_path(path); + let include_index = status.intersects( + Status::INDEX_NEW + | Status::INDEX_MODIFIED + | Status::INDEX_DELETED + | Status::INDEX_RENAMED + | Status::INDEX_TYPECHANGE, + ); + let include_workdir = status.intersects( + Status::WT_NEW + | Status::WT_MODIFIED + | Status::WT_DELETED + | Status::WT_RENAMED + | Status::WT_TYPECHANGE, + ); + let mut combined_additions = 0i64; + let mut combined_deletions = 0i64; + + if include_index { + let (additions, deletions) = + diff_stats_for_path(&repo, head_tree.as_ref(), path, true, false).unwrap_or((0, 0)); + if let Some(status_str) = status_for_index(status) { + staged_files.push(GitFileStatus { + path: normalized_path.clone(), + status: status_str.to_string(), + additions, + deletions, + }); + } + combined_additions += additions; + combined_deletions += deletions; + total_additions += additions; + total_deletions += deletions; + } + + if include_workdir { + let (additions, deletions) = + diff_stats_for_path(&repo, head_tree.as_ref(), path, false, true).unwrap_or((0, 0)); + if let Some(status_str) = status_for_workdir(status) { + unstaged_files.push(GitFileStatus { + path: normalized_path.clone(), + status: status_str.to_string(), + additions, + deletions, + }); + } + combined_additions += additions; + combined_deletions += deletions; + total_additions += additions; + total_deletions += deletions; + } + + if include_index || include_workdir { + let status_str = status_for_workdir(status) + .or_else(|| status_for_index(status)) + .unwrap_or("--"); + files.push(GitFileStatus { + path: normalized_path, + status: status_str.to_string(), + additions: combined_additions, + deletions: combined_deletions, + }); + } + } + + Ok(json!({ + "branchName": branch_name, + "files": files, + "stagedFiles": staged_files, + "unstagedFiles": unstaged_files, + "totalAdditions": total_additions, + "totalDeletions": total_deletions, + })) +} + +pub(super) async fn get_git_diffs_inner( + workspaces: &Mutex>, + app_settings: &Mutex, + workspace_id: String, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let ignore_whitespace_changes = { + let settings = app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; + + tokio::task::spawn_blocking(move || { + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let head_tree = repo.head().ok().and_then(|head| head.peel_to_tree().ok()); + + let mut options = DiffOptions::new(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .show_untracked_content(true); + options.ignore_whitespace_change(ignore_whitespace_changes); + + let diff = match head_tree.as_ref() { + Some(tree) => repo + .diff_tree_to_workdir_with_index(Some(tree), Some(&mut options)) + .map_err(|e| e.to_string())?, + None => repo + .diff_tree_to_workdir_with_index(None, Some(&mut options)) + .map_err(|e| e.to_string())?, + }; + let diff_paths: Vec = diff + .deltas() + .filter_map(|delta| delta.new_file().path().or_else(|| delta.old_file().path())) + .map(PathBuf::from) + .collect(); + let ignored_paths = collect_ignored_paths_with_git(&repo, &diff_paths); + + let mut results = Vec::new(); + for (index, delta) in diff.deltas().enumerate() { + let old_path = delta.old_file().path(); + let new_path = delta.new_file().path(); + let display_path = new_path.or(old_path); + let Some(display_path) = display_path else { + continue; + }; + if should_skip_ignored_path_with_cache(&repo, display_path, ignored_paths.as_ref()) { + continue; + } + let old_path_str = old_path.map(|path| path.to_string_lossy()); + let new_path_str = new_path.map(|path| path.to_string_lossy()); + let display_path_str = display_path.to_string_lossy(); + let normalized_path = normalize_git_path(&display_path_str); + let old_image_mime = old_path_str.as_deref().and_then(image_mime_type); + let new_image_mime = new_path_str.as_deref().and_then(image_mime_type); + let is_image = old_image_mime.is_some() || new_image_mime.is_some(); + let is_deleted = delta.status() == git2::Delta::Deleted; + let is_added = delta.status() == git2::Delta::Added; + + let old_lines = if !is_added { + head_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_lines) + } else { + None + }; + + let new_lines = if !is_deleted { + match new_path { + Some(path) => { + let full_path = repo_root.join(path); + read_text_lines(&full_path) + } + None => None, + } + } else { + None + }; + + if is_image { + let old_image_data = if !is_added && old_image_mime.is_some() { + head_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_base64) + } else { + None + }; + + let new_image_data = if !is_deleted && new_image_mime.is_some() { + match new_path { + Some(path) => { + let full_path = repo_root.join(path); + read_image_base64(&full_path) + } + None => None, + } + } else { + None + }; + + results.push(GitFileDiff { + path: normalized_path, + diff: String::new(), + old_lines: None, + new_lines: None, + is_binary: true, + is_image: true, + old_image_data, + new_image_data, + old_image_mime: old_image_mime.map(str::to_string), + new_image_mime: new_image_mime.map(str::to_string), + }); + continue; + } + + let patch = match git2::Patch::from_diff(&diff, index) { + Ok(patch) => patch, + Err(_) => continue, + }; + let Some(mut patch) = patch else { + continue; + }; + let content = match diff_patch_to_string(&mut patch) { + Ok(content) => content, + Err(_) => continue, + }; + if content.trim().is_empty() { + continue; + } + results.push(GitFileDiff { + path: normalized_path, + diff: content, + old_lines, + new_lines, + is_binary: false, + is_image: false, + old_image_data: None, + new_image_data: None, + old_image_mime: None, + new_image_mime: None, + }); + } + + Ok(results) + }) + .await + .map_err(|e| e.to_string())? +} + +pub(super) async fn get_git_commit_diff_inner( + workspaces: &Mutex>, + app_settings: &Mutex, + workspace_id: String, + sha: String, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + + let ignore_whitespace_changes = { + let settings = app_settings.lock().await; + settings.git_diff_ignore_whitespace_changes + }; + + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let oid = git2::Oid::from_str(&sha).map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + let commit_tree = commit.tree().map_err(|e| e.to_string())?; + let parent_tree = commit.parent(0).ok().and_then(|parent| parent.tree().ok()); + + let mut options = DiffOptions::new(); + options.ignore_whitespace_change(ignore_whitespace_changes); + let diff = repo + .diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_tree), Some(&mut options)) + .map_err(|e| e.to_string())?; + + let mut results = Vec::new(); + for (index, delta) in diff.deltas().enumerate() { + let old_path = delta.old_file().path(); + let new_path = delta.new_file().path(); + let display_path = new_path.or(old_path); + let Some(display_path) = display_path else { + continue; + }; + let old_path_str = old_path.map(|path| path.to_string_lossy()); + let new_path_str = new_path.map(|path| path.to_string_lossy()); + let display_path_str = display_path.to_string_lossy(); + let normalized_path = normalize_git_path(&display_path_str); + let old_image_mime = old_path_str.as_deref().and_then(image_mime_type); + let new_image_mime = new_path_str.as_deref().and_then(image_mime_type); + let is_image = old_image_mime.is_some() || new_image_mime.is_some(); + let is_deleted = delta.status() == git2::Delta::Deleted; + let is_added = delta.status() == git2::Delta::Added; + + let old_lines = if !is_added { + parent_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_lines) + } else { + None + }; + + let new_lines = if !is_deleted { + new_path + .and_then(|path| commit_tree.get_path(path).ok()) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_lines) + } else { + None + }; + + if is_image { + let old_image_data = if !is_added && old_image_mime.is_some() { + parent_tree + .as_ref() + .and_then(|tree| old_path.and_then(|path| tree.get_path(path).ok())) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_base64) + } else { + None + }; + + let new_image_data = if !is_deleted && new_image_mime.is_some() { + new_path + .and_then(|path| commit_tree.get_path(path).ok()) + .and_then(|entry| repo.find_blob(entry.id()).ok()) + .and_then(blob_to_base64) + } else { + None + }; + + results.push(GitCommitDiff { + path: normalized_path, + status: status_for_delta(delta.status()).to_string(), + diff: String::new(), + old_lines: None, + new_lines: None, + is_binary: true, + is_image: true, + old_image_data, + new_image_data, + old_image_mime: old_image_mime.map(str::to_string), + new_image_mime: new_image_mime.map(str::to_string), + }); + continue; + } + + let patch = match git2::Patch::from_diff(&diff, index) { + Ok(patch) => patch, + Err(_) => continue, + }; + let Some(mut patch) = patch else { + continue; + }; + let content = match diff_patch_to_string(&mut patch) { + Ok(content) => content, + Err(_) => continue, + }; + if content.trim().is_empty() { + continue; + } + results.push(GitCommitDiff { + path: normalized_path, + status: status_for_delta(delta.status()).to_string(), + diff: content, + old_lines, + new_lines, + is_binary: false, + is_image: false, + old_image_data: None, + new_image_data: None, + old_image_mime: None, + new_image_mime: None, + }); + } + + Ok(results) +} diff --git a/src-tauri/src/shared/git_ui_core/github.rs b/src-tauri/src/shared/git_ui_core/github.rs new file mode 100644 index 0000000000..a3ba384b0c --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/github.rs @@ -0,0 +1,356 @@ +use std::collections::HashMap; +use std::path::Path; + +use git2::Repository; +use tokio::sync::Mutex; + +use crate::git_utils::{parse_github_repo, resolve_git_root}; +use crate::shared::process_core::tokio_command; +use crate::types::{ + GitHubIssue, GitHubIssuesResponse, GitHubPullRequest, GitHubPullRequestComment, + GitHubPullRequestDiff, GitHubPullRequestsResponse, WorkspaceEntry, +}; +use crate::utils::normalize_git_path; + +use super::context::workspace_entry_for_id; + +fn github_repo_from_path(path: &Path) -> Result { + let repo = Repository::open(path).map_err(|e| e.to_string())?; + let remotes = repo.remotes().map_err(|e| e.to_string())?; + let name = if remotes.iter().any(|remote| remote == Some("origin")) { + "origin".to_string() + } else { + remotes.iter().flatten().next().unwrap_or("").to_string() + }; + if name.is_empty() { + return Err("No git remote configured.".to_string()); + } + let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; + let remote_url = remote.url().ok_or("Remote has no URL configured.")?; + parse_github_repo(remote_url).ok_or("Remote is not a GitHub repository.".to_string()) +} + +fn parse_pr_diff(diff: &str) -> Vec { + let mut entries = Vec::new(); + let mut current_lines: Vec<&str> = Vec::new(); + let mut current_old_path: Option = None; + let mut current_new_path: Option = None; + let mut current_status: Option = None; + + let finalize = |lines: &Vec<&str>, + old_path: &Option, + new_path: &Option, + status: &Option, + results: &mut Vec| { + if lines.is_empty() { + return; + } + let diff_text = lines.join("\n"); + if diff_text.trim().is_empty() { + return; + } + let status_value = status.clone().unwrap_or_else(|| "M".to_string()); + let path = if status_value == "D" { + old_path.clone().unwrap_or_default() + } else { + new_path + .clone() + .or_else(|| old_path.clone()) + .unwrap_or_default() + }; + if path.is_empty() { + return; + } + results.push(GitHubPullRequestDiff { + path: normalize_git_path(&path), + status: status_value, + diff: diff_text, + }); + }; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + finalize( + ¤t_lines, + ¤t_old_path, + ¤t_new_path, + ¤t_status, + &mut entries, + ); + current_lines = vec![line]; + current_old_path = None; + current_new_path = None; + current_status = None; + + let rest = line.trim_start_matches("diff --git ").trim(); + let mut parts = rest.split_whitespace(); + let old_part = parts.next().unwrap_or("").trim_start_matches("a/"); + let new_part = parts.next().unwrap_or("").trim_start_matches("b/"); + if !old_part.is_empty() { + current_old_path = Some(old_part.to_string()); + } + if !new_part.is_empty() { + current_new_path = Some(new_part.to_string()); + } + continue; + } + if line.starts_with("new file mode ") { + current_status = Some("A".to_string()); + } else if line.starts_with("deleted file mode ") { + current_status = Some("D".to_string()); + } else if line.starts_with("rename from ") { + current_status = Some("R".to_string()); + let path = line.trim_start_matches("rename from ").trim(); + if !path.is_empty() { + current_old_path = Some(path.to_string()); + } + } else if line.starts_with("rename to ") { + current_status = Some("R".to_string()); + let path = line.trim_start_matches("rename to ").trim(); + if !path.is_empty() { + current_new_path = Some(path.to_string()); + } + } + current_lines.push(line); + } + + finalize( + ¤t_lines, + ¤t_old_path, + ¤t_new_path, + ¤t_status, + &mut entries, + ); + + entries +} + +fn command_failure_detail(stdout: &[u8], stderr: &[u8], fallback: &str) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stdout = String::from_utf8_lossy(stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + fallback.to_string() + } else { + detail.to_string() + } +} + +pub(super) async fn checkout_github_pull_request_inner( + workspaces: &Mutex>, + workspace_id: String, + pr_number: u64, +) -> Result<(), String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let pr_number_text = pr_number.to_string(); + + let output = tokio_command("gh") + .args(["pr", "checkout", &pr_number_text]) + .current_dir(&repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + if !output.status.success() { + return Err(command_failure_detail( + &output.stdout, + &output.stderr, + "GitHub CLI command failed.", + )); + } + + Ok(()) +} + +pub(super) async fn get_github_issues_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; + + let output = tokio_command("gh") + .args([ + "issue", + "list", + "--repo", + &repo_name, + "--limit", + "50", + "--json", + "number,title,url,updatedAt", + ]) + .current_dir(&repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + if !output.status.success() { + return Err(command_failure_detail( + &output.stdout, + &output.stderr, + "GitHub CLI command failed.", + )); + } + + let issues: Vec = + serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; + + let search_query = format!("repo:{repo_name} is:issue is:open").replace(' ', "+"); + let total = match tokio_command("gh") + .args([ + "api", + &format!("/search/issues?q={search_query}"), + "--jq", + ".total_count", + ]) + .current_dir(&repo_root) + .output() + .await + { + Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .unwrap_or(issues.len()), + _ => issues.len(), + }; + + Ok(GitHubIssuesResponse { total, issues }) +} + +pub(super) async fn get_github_pull_requests_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; + + let output = tokio_command("gh") + .args([ + "pr", + "list", + "--repo", + &repo_name, + "--state", + "open", + "--limit", + "50", + "--json", + "number,title,url,updatedAt,createdAt,body,headRefName,baseRefName,isDraft,author", + ]) + .current_dir(&repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + if !output.status.success() { + return Err(command_failure_detail( + &output.stdout, + &output.stderr, + "GitHub CLI command failed.", + )); + } + + let pull_requests: Vec = + serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; + + let search_query = format!("repo:{repo_name} is:pr is:open").replace(' ', "+"); + let total = match tokio_command("gh") + .args([ + "api", + &format!("/search/issues?q={search_query}"), + "--jq", + ".total_count", + ]) + .current_dir(&repo_root) + .output() + .await + { + Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .unwrap_or(pull_requests.len()), + _ => pull_requests.len(), + }; + + Ok(GitHubPullRequestsResponse { + total, + pull_requests, + }) +} + +pub(super) async fn get_github_pull_request_diff_inner( + workspaces: &Mutex>, + workspace_id: String, + pr_number: u64, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; + + let output = tokio_command("gh") + .args([ + "pr", + "diff", + &pr_number.to_string(), + "--repo", + &repo_name, + "--color", + "never", + ]) + .current_dir(&repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + if !output.status.success() { + return Err(command_failure_detail( + &output.stdout, + &output.stderr, + "GitHub CLI command failed.", + )); + } + + let diff_text = String::from_utf8_lossy(&output.stdout); + Ok(parse_pr_diff(&diff_text)) +} + +pub(super) async fn get_github_pull_request_comments_inner( + workspaces: &Mutex>, + workspace_id: String, + pr_number: u64, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo_name = github_repo_from_path(&repo_root)?; + + let comments_endpoint = format!("/repos/{repo_name}/issues/{pr_number}/comments?per_page=30"); + let jq_filter = r#"[.[] | {id, body, createdAt: .created_at, url: .html_url, author: (if .user then {login: .user.login} else null end)}]"#; + + let output = tokio_command("gh") + .args(["api", &comments_endpoint, "--jq", jq_filter]) + .current_dir(&repo_root) + .output() + .await + .map_err(|e| format!("Failed to run gh: {e}"))?; + + if !output.status.success() { + return Err(command_failure_detail( + &output.stdout, + &output.stderr, + "GitHub CLI command failed.", + )); + } + + let comments: Vec = + serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?; + + Ok(comments) +} diff --git a/src-tauri/src/shared/git_ui_core/log.rs b/src-tauri/src/shared/git_ui_core/log.rs new file mode 100644 index 0000000000..4d2d48e7c5 --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/log.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use git2::{BranchType, Repository, Sort}; +use tokio::sync::Mutex; + +use crate::git_utils::{commit_to_entry, resolve_git_root}; +use crate::types::{GitLogResponse, WorkspaceEntry}; + +use super::context::workspace_entry_for_id; + +pub(super) async fn get_git_log_inner( + workspaces: &Mutex>, + workspace_id: String, + limit: Option, +) -> Result { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let max_items = limit.unwrap_or(40); + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push_head().map_err(|e| e.to_string())?; + revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; + + let mut total = 0usize; + for oid_result in revwalk { + oid_result.map_err(|e| e.to_string())?; + total += 1; + } + + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push_head().map_err(|e| e.to_string())?; + revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; + + let mut entries = Vec::new(); + for oid_result in revwalk.take(max_items) { + let oid = oid_result.map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + entries.push(commit_to_entry(commit)); + } + + let mut ahead = 0usize; + let mut behind = 0usize; + let mut ahead_entries = Vec::new(); + let mut behind_entries = Vec::new(); + let mut upstream = None; + + if let Ok(head) = repo.head() { + if head.is_branch() { + if let Some(branch_name) = head.shorthand() { + if let Ok(branch) = repo.find_branch(branch_name, BranchType::Local) { + if let Ok(upstream_branch) = branch.upstream() { + let upstream_ref = upstream_branch.get(); + upstream = upstream_ref + .shorthand() + .map(|name| name.to_string()) + .or_else(|| upstream_ref.name().map(|name| name.to_string())); + if let (Some(head_oid), Some(upstream_oid)) = + (head.target(), upstream_ref.target()) + { + let (ahead_count, behind_count) = repo + .graph_ahead_behind(head_oid, upstream_oid) + .map_err(|e| e.to_string())?; + ahead = ahead_count; + behind = behind_count; + + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push(head_oid).map_err(|e| e.to_string())?; + revwalk.hide(upstream_oid).map_err(|e| e.to_string())?; + revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; + for oid_result in revwalk.take(max_items) { + let oid = oid_result.map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + ahead_entries.push(commit_to_entry(commit)); + } + + let mut revwalk = repo.revwalk().map_err(|e| e.to_string())?; + revwalk.push(upstream_oid).map_err(|e| e.to_string())?; + revwalk.hide(head_oid).map_err(|e| e.to_string())?; + revwalk.set_sorting(Sort::TIME).map_err(|e| e.to_string())?; + for oid_result in revwalk.take(max_items) { + let oid = oid_result.map_err(|e| e.to_string())?; + let commit = repo.find_commit(oid).map_err(|e| e.to_string())?; + behind_entries.push(commit_to_entry(commit)); + } + } + } + } + } + } + } + + Ok(GitLogResponse { + total, + entries, + ahead, + behind, + ahead_entries, + behind_entries, + upstream, + }) +} + +pub(super) async fn get_git_remote_inner( + workspaces: &Mutex>, + workspace_id: String, +) -> Result, String> { + let entry = workspace_entry_for_id(workspaces, &workspace_id).await?; + let repo_root = resolve_git_root(&entry)?; + let repo = Repository::open(&repo_root).map_err(|e| e.to_string())?; + let remotes = repo.remotes().map_err(|e| e.to_string())?; + let name = if remotes.iter().any(|remote| remote == Some("origin")) { + "origin".to_string() + } else { + remotes.iter().flatten().next().unwrap_or("").to_string() + }; + if name.is_empty() { + return Ok(None); + } + let remote = repo.find_remote(&name).map_err(|e| e.to_string())?; + Ok(remote.url().map(|url| url.to_string())) +} diff --git a/src-tauri/src/shared/git_ui_core/tests.rs b/src-tauri/src/shared/git_ui_core/tests.rs new file mode 100644 index 0000000000..b3f4ceac14 --- /dev/null +++ b/src-tauri/src/shared/git_ui_core/tests.rs @@ -0,0 +1,395 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use git2::Repository; +use serde_json::Value; +use tokio::runtime::Runtime; +use tokio::sync::Mutex; + +use crate::types::{AppSettings, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; + +use super::commands; +use super::diff; + +fn create_temp_repo() -> (PathBuf, Repository) { + let root = std::env::temp_dir().join(format!("codex-monitor-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("create temp repo root"); + let repo = Repository::init(&root).expect("init repo"); + (root, repo) +} + +#[test] +fn collect_workspace_diff_prefers_staged_changes() { + let (root, repo) = create_temp_repo(); + let file_path = root.join("staged.txt"); + fs::write(&file_path, "staged\n").expect("write staged file"); + let mut index = repo.index().expect("index"); + index.add_path(Path::new("staged.txt")).expect("add path"); + index.write().expect("write index"); + + let diff_output = diff::collect_workspace_diff(&root).expect("collect diff"); + assert!(diff_output.contains("staged.txt")); + assert!(diff_output.contains("staged")); +} + +#[test] +fn collect_workspace_diff_falls_back_to_workdir() { + let (root, _repo) = create_temp_repo(); + let file_path = root.join("unstaged.txt"); + fs::write(&file_path, "unstaged\n").expect("write unstaged file"); + + let diff_output = diff::collect_workspace_diff(&root).expect("collect diff"); + assert!(diff_output.contains("unstaged.txt")); + assert!(diff_output.contains("unstaged")); +} + +#[test] +fn action_paths_for_file_expands_renames() { + let (root, repo) = create_temp_repo(); + fs::write(root.join("a.txt"), "hello\n").expect("write file"); + + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("a.txt")).expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + fs::rename(root.join("a.txt"), root.join("b.txt")).expect("rename file"); + + let mut index = repo.index().expect("repo index"); + index + .remove_path(Path::new("a.txt")) + .expect("remove old path"); + index.add_path(Path::new("b.txt")).expect("add new path"); + index.write().expect("write index"); + + let paths = commands::action_paths_for_file(&root, "b.txt"); + assert_eq!(paths, vec!["a.txt".to_string(), "b.txt".to_string()]); +} + +#[test] +fn get_git_status_omits_global_ignored_paths() { + let (root, repo) = create_temp_repo(); + fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + let ignored_path = root.join("ignored_root/example/foo/bar.txt"); + fs::create_dir_all(ignored_path.parent().expect("parent")).expect("create ignored dir"); + fs::write(&ignored_path, "ignored\n").expect("write ignored file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + + let runtime = Runtime::new().expect("create tokio runtime"); + let status = runtime + .block_on(diff::get_git_status_inner(&workspaces, "w1".to_string())) + .expect("get git status"); + + let has_ignored = status + .get("unstagedFiles") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|entry| entry.get("path").and_then(Value::as_str)) + .any(|path| path.starts_with("ignored_root/example/foo/bar")); + assert!( + !has_ignored, + "ignored files should not appear in unstagedFiles" + ); +} + +#[test] +fn get_git_diffs_omits_global_ignored_paths() { + let (root, repo) = create_temp_repo(); + fs::write(root.join("tracked.txt"), "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index.add_path(Path::new("tracked.txt")).expect("add path"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + let ignored_path = root.join("ignored_root/example/foo/bar.txt"); + fs::create_dir_all(ignored_path.parent().expect("parent")).expect("create ignored dir"); + fs::write(&ignored_path, "ignored\n").expect("write ignored file"); + + let workspace = WorkspaceEntry { + id: "w1".to_string(), + name: "w1".to_string(), + path: root.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let mut entries = HashMap::new(); + entries.insert("w1".to_string(), workspace); + let workspaces = Mutex::new(entries); + let app_settings = Mutex::new(AppSettings::default()); + + let runtime = Runtime::new().expect("create tokio runtime"); + let diffs = runtime + .block_on(diff::get_git_diffs_inner( + &workspaces, + &app_settings, + "w1".to_string(), + )) + .expect("get git diffs"); + + let has_ignored = diffs + .iter() + .any(|diff| diff.path.starts_with("ignored_root/example/foo/bar")); + assert!(!has_ignored, "ignored files should not appear in diff list"); +} + +#[test] +fn check_ignore_with_git_respects_negated_rule_for_specific_file() { + let (root, repo) = create_temp_repo(); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root/*\n!ignored_root/keep.txt\n") + .expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + let kept_path = Path::new("ignored_root/keep.txt"); + assert!( + diff::check_ignore_with_git(&repo, kept_path) == Some(false), + "keep.txt should be visible because of negated rule" + ); +} + +#[test] +fn should_skip_ignored_path_respects_negated_rule_for_specific_file() { + let (root, repo) = create_temp_repo(); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root/*\n!ignored_root/keep.txt\n") + .expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + assert!( + !diff::should_skip_ignored_path_with_cache(&repo, Path::new("ignored_root/keep.txt"), None), + "keep.txt should not be skipped when unignored by negated rule" + ); +} + +#[test] +fn should_skip_ignored_path_skips_paths_with_ignored_parent() { + let (root, repo) = create_temp_repo(); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + assert!( + diff::should_skip_ignored_path_with_cache( + &repo, + Path::new("ignored_root/example/foo/bar.txt"), + None, + ), + "nested path should be skipped when parent directory is ignored" + ); +} + +#[test] +fn should_skip_ignored_path_keeps_tracked_file_under_ignored_parent_pattern() { + let (root, repo) = create_temp_repo(); + let tracked_path = root.join("ignored_root/tracked.txt"); + fs::create_dir_all(tracked_path.parent().expect("parent")).expect("create tracked dir"); + fs::write(&tracked_path, "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index + .add_path(Path::new("ignored_root/tracked.txt")) + .expect("add tracked path"); + index.write().expect("write index"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root/*\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + assert!( + !diff::should_skip_ignored_path_with_cache( + &repo, + Path::new("ignored_root/tracked.txt"), + None, + ), + "tracked file should not be skipped even if ignore pattern matches its path" + ); +} + +#[test] +fn check_ignore_with_git_treats_tracked_file_as_not_ignored() { + let (root, repo) = create_temp_repo(); + let tracked_path = root.join("ignored_root/tracked.txt"); + fs::create_dir_all(tracked_path.parent().expect("parent")).expect("create tracked dir"); + fs::write(&tracked_path, "tracked\n").expect("write tracked file"); + let mut index = repo.index().expect("repo index"); + index + .add_path(Path::new("ignored_root/tracked.txt")) + .expect("add tracked path"); + index.write().expect("write index"); + let tree_id = index.write_tree().expect("write tree"); + let tree = repo.find_tree(tree_id).expect("find tree"); + let sig = git2::Signature::now("Test", "test@example.com").expect("signature"); + repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]) + .expect("commit"); + + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root/*\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + assert_eq!( + diff::check_ignore_with_git(&repo, Path::new("ignored_root/tracked.txt")), + Some(false), + "git check-ignore should treat tracked files as not ignored" + ); +} + +#[test] +fn should_skip_ignored_path_respects_repo_negation_over_global_ignore() { + let (root, repo) = create_temp_repo(); + + fs::write(root.join(".gitignore"), "!keep.log\n").expect("write repo gitignore"); + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "*.log\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + assert_eq!( + diff::check_ignore_with_git(&repo, Path::new("keep.log")), + Some(false), + "repo negation should override global ignore for keep.log" + ); + assert!( + !diff::should_skip_ignored_path_with_cache(&repo, Path::new("keep.log"), None), + "keep.log should remain visible when repo .gitignore negates global ignore" + ); +} + +#[test] +fn collect_ignored_paths_with_git_checks_multiple_paths_in_one_call() { + let (root, repo) = create_temp_repo(); + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + let ignored_path = PathBuf::from("ignored_root/example/foo/bar.txt"); + let visible_path = PathBuf::from("visible.txt"); + let ignored_paths = + diff::collect_ignored_paths_with_git(&repo, &[ignored_path.clone(), visible_path.clone()]) + .expect("collect ignored paths"); + + assert!(ignored_paths.contains(&ignored_path)); + assert!(!ignored_paths.contains(&visible_path)); +} + +#[test] +fn collect_ignored_paths_with_git_handles_large_ignored_output() { + let (root, repo) = create_temp_repo(); + let excludes_path = root.join("global-excludes.txt"); + fs::write(&excludes_path, "ignored_root\n").expect("write excludes file"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "core.excludesfile", + excludes_path.to_string_lossy().as_ref(), + ) + .expect("set core.excludesfile"); + + let total = 6000usize; + let paths: Vec = (0..total) + .map(|i| PathBuf::from(format!("ignored_root/deep/path/file-{i}.txt"))) + .collect(); + let ignored_paths = + diff::collect_ignored_paths_with_git(&repo, &paths).expect("collect ignored paths"); + + assert_eq!(ignored_paths.len(), total); +} diff --git a/src-tauri/src/shared/process_core.rs b/src-tauri/src/shared/process_core.rs index f4684fe1ec..f61111bc3f 100644 --- a/src-tauri/src/shared/process_core.rs +++ b/src-tauri/src/shared/process_core.rs @@ -25,6 +25,12 @@ pub(crate) fn tokio_command(program: impl AsRef) -> Command { command } +pub(crate) fn std_command(program: impl AsRef) -> std::process::Command { + let mut command = std::process::Command::new(program); + hide_console_on_windows(&mut command); + command +} + pub(crate) async fn kill_child_process_tree(child: &mut Child) { #[cfg(windows)] { diff --git a/src-tauri/src/shared/workspaces_core.rs b/src-tauri/src/shared/workspaces_core.rs index 8fefe20378..0b97a68718 100644 --- a/src-tauri/src/shared/workspaces_core.rs +++ b/src-tauri/src/shared/workspaces_core.rs @@ -1,1667 +1,22 @@ -use std::collections::HashMap; -use std::future::Future; -#[cfg(target_os = "windows")] -use std::path::Path; -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::Arc; - -use tokio::io::AsyncWriteExt; -use tokio::sync::Mutex; - -use crate::backend::app_server::WorkspaceSession; -use crate::codex::args::resolve_workspace_codex_args; -use crate::codex::home::resolve_workspace_codex_home; -use crate::git_utils::resolve_git_root; -#[cfg(target_os = "windows")] -use crate::shared::process_core::{build_cmd_c_command, resolve_windows_executable}; -use crate::shared::process_core::{kill_child_process_tree, tokio_command}; -use crate::shared::{git_core, worktree_core}; -use crate::storage::write_workspaces; -use crate::types::{ - AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, - WorktreeSetupStatus, +mod connect; +mod crud_persistence; +mod git_orchestration; +mod helpers; +mod io; +mod worktree; + +pub(crate) use connect::connect_workspace_core; +pub(crate) use crud_persistence::{ + add_clone_core, add_workspace_core, remove_workspace_core, update_workspace_codex_bin_core, + update_workspace_settings_core, +}; +pub(crate) use git_orchestration::{apply_worktree_changes_core, run_git_command_unit}; +pub(crate) use helpers::{is_workspace_path_dir_core, list_workspaces_core}; +pub(crate) use io::{ + get_open_app_icon_core, list_workspace_files_core, open_workspace_in_core, + read_workspace_file_core, +}; +pub(crate) use worktree::{ + add_worktree_core, remove_worktree_core, rename_worktree_core, rename_worktree_upstream_core, + worktree_setup_mark_ran_core, worktree_setup_status_core, }; -use uuid::Uuid; - -pub(crate) const WORKTREE_SETUP_MARKERS_DIR: &str = "worktree-setup"; -pub(crate) const WORKTREE_SETUP_MARKER_EXT: &str = "ran"; -const AGENTS_MD_FILE_NAME: &str = "AGENTS.md"; - -fn copy_agents_md_from_parent_to_worktree( - parent_repo_root: &PathBuf, - worktree_root: &PathBuf, -) -> Result<(), String> { - let source_path = parent_repo_root.join(AGENTS_MD_FILE_NAME); - if !source_path.is_file() { - return Ok(()); - } - - let destination_path = worktree_root.join(AGENTS_MD_FILE_NAME); - if destination_path.is_file() { - return Ok(()); - } - - let temp_path = worktree_root.join(format!("{AGENTS_MD_FILE_NAME}.tmp")); - - std::fs::copy(&source_path, &temp_path).map_err(|err| { - format!( - "Failed to copy {} from {} to {}: {err}", - AGENTS_MD_FILE_NAME, - source_path.display(), - temp_path.display() - ) - })?; - - std::fs::rename(&temp_path, &destination_path).map_err(|err| { - let _ = std::fs::remove_file(&temp_path); - format!( - "Failed to finalize {} copy to {}: {err}", - AGENTS_MD_FILE_NAME, - destination_path.display() - ) - })?; - - Ok(()) -} - -pub(crate) fn normalize_setup_script(script: Option) -> Option { - match script { - Some(value) if value.trim().is_empty() => None, - Some(value) => Some(value), - None => None, - } -} - -pub(crate) fn worktree_setup_marker_path(data_dir: &PathBuf, workspace_id: &str) -> PathBuf { - data_dir - .join(WORKTREE_SETUP_MARKERS_DIR) - .join(format!("{workspace_id}.{WORKTREE_SETUP_MARKER_EXT}")) -} - -pub(crate) fn is_workspace_path_dir_core(path: &str) -> bool { - PathBuf::from(path).is_dir() -} - -pub(crate) async fn list_workspaces_core( - workspaces: &Mutex>, - sessions: &Mutex>>, -) -> Vec { - let workspaces = workspaces.lock().await; - let sessions = sessions.lock().await; - let mut result = Vec::new(); - for entry in workspaces.values() { - result.push(WorkspaceInfo { - id: entry.id.clone(), - name: entry.name.clone(), - path: entry.path.clone(), - codex_bin: entry.codex_bin.clone(), - connected: sessions.contains_key(&entry.id), - kind: entry.kind.clone(), - parent_id: entry.parent_id.clone(), - worktree: entry.worktree.clone(), - settings: entry.settings.clone(), - }); - } - sort_workspaces(&mut result); - result -} - -async fn resolve_entry_and_parent( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result<(WorkspaceEntry, Option), String> { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - let parent_entry = entry - .parent_id - .as_ref() - .and_then(|parent_id| workspaces.get(parent_id)) - .cloned(); - Ok((entry, parent_entry)) -} - -async fn resolve_workspace_root( - workspaces: &Mutex>, - workspace_id: &str, -) -> Result { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - Ok(PathBuf::from(entry.path)) -} - -pub(crate) async fn worktree_setup_status_core( - workspaces: &Mutex>, - workspace_id: &str, - data_dir: &PathBuf, -) -> Result { - let entry = { - let workspaces = workspaces.lock().await; - workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())? - }; - - let script = normalize_setup_script(entry.settings.worktree_setup_script.clone()); - let marker_exists = if entry.kind.is_worktree() { - worktree_setup_marker_path(data_dir, &entry.id).exists() - } else { - false - }; - let should_run = entry.kind.is_worktree() && script.is_some() && !marker_exists; - - Ok(WorktreeSetupStatus { should_run, script }) -} - -pub(crate) async fn worktree_setup_mark_ran_core( - workspaces: &Mutex>, - workspace_id: &str, - data_dir: &PathBuf, -) -> Result<(), String> { - let entry = { - let workspaces = workspaces.lock().await; - workspaces - .get(workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())? - }; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let marker_path = worktree_setup_marker_path(data_dir, &entry.id); - if let Some(parent) = marker_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|err| format!("Failed to prepare worktree marker directory: {err}"))?; - } - let ran_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - std::fs::write(&marker_path, format!("ran_at={ran_at}\n")) - .map_err(|err| format!("Failed to write worktree setup marker: {err}"))?; - Ok(()) -} - -pub(crate) async fn add_workspace_core( - path: String, - codex_bin: Option, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - storage_path: &PathBuf, - spawn_session: F, -) -> Result -where - F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, - Fut: Future, String>>, -{ - if !PathBuf::from(&path).is_dir() { - return Err("Workspace path must be a folder.".to_string()); - } - - let name = PathBuf::from(&path) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("Workspace") - .to_string(); - let entry = WorkspaceEntry { - id: Uuid::new_v4().to_string(), - name: name.clone(), - path: path.clone(), - codex_bin, - kind: WorkspaceKind::Main, - parent_id: None, - worktree: None, - settings: WorkspaceSettings::default(), - }; - - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args(&entry, None, Some(&settings)), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry, None); - let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; - - if let Err(error) = { - let mut workspaces = workspaces.lock().await; - workspaces.insert(entry.id.clone(), entry.clone()); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(storage_path, &list) - } { - { - let mut workspaces = workspaces.lock().await; - workspaces.remove(&entry.id); - } - let mut child = session.child.lock().await; - kill_child_process_tree(&mut child).await; - return Err(error); - } - - sessions.lock().await.insert(entry.id.clone(), session); - - Ok(WorkspaceInfo { - id: entry.id, - name: entry.name, - path: entry.path, - codex_bin: entry.codex_bin, - connected: true, - kind: entry.kind, - parent_id: entry.parent_id, - worktree: entry.worktree, - settings: entry.settings, - }) -} - -pub(crate) async fn add_clone_core( - source_workspace_id: String, - copy_name: String, - copies_folder: String, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - storage_path: &PathBuf, - spawn_session: F, -) -> Result -where - F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, - Fut: Future, String>>, -{ - let copy_name = copy_name.trim().to_string(); - if copy_name.is_empty() { - return Err("Copy name is required.".to_string()); - } - - let copies_folder = copies_folder.trim().to_string(); - if copies_folder.is_empty() { - return Err("Copies folder is required.".to_string()); - } - let copies_folder_path = PathBuf::from(&copies_folder); - std::fs::create_dir_all(&copies_folder_path) - .map_err(|e| format!("Failed to create copies folder: {e}"))?; - if !copies_folder_path.is_dir() { - return Err("Copies folder must be a directory.".to_string()); - } - - let (source_entry, inherited_group_id) = { - let workspaces = workspaces.lock().await; - let source_entry = workspaces - .get(&source_workspace_id) - .cloned() - .ok_or_else(|| "source workspace not found".to_string())?; - let inherited_group_id = if source_entry.kind.is_worktree() { - source_entry - .parent_id - .as_ref() - .and_then(|parent_id| workspaces.get(parent_id)) - .and_then(|parent| parent.settings.group_id.clone()) - } else { - source_entry.settings.group_id.clone() - }; - (source_entry, inherited_group_id) - }; - - let destination_path = - worktree_core::build_clone_destination_path(&copies_folder_path, ©_name); - let destination_path_string = destination_path.to_string_lossy().to_string(); - - if let Err(error) = git_core::run_git_command( - &copies_folder_path, - &["clone", &source_entry.path, &destination_path_string], - ) - .await - { - let _ = tokio::fs::remove_dir_all(&destination_path).await; - return Err(error); - } - - if let Some(origin_url) = git_core::git_get_origin_url(&PathBuf::from(&source_entry.path)).await - { - let _ = git_core::run_git_command( - &destination_path, - &["remote", "set-url", "origin", &origin_url], - ) - .await; - } - - let entry = WorkspaceEntry { - id: Uuid::new_v4().to_string(), - name: copy_name, - path: destination_path_string, - codex_bin: source_entry.codex_bin.clone(), - kind: WorkspaceKind::Main, - parent_id: None, - worktree: None, - settings: WorkspaceSettings { - group_id: inherited_group_id, - ..WorkspaceSettings::default() - }, - }; - - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args(&entry, None, Some(&settings)), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry, None); - let session = match spawn_session(entry.clone(), default_bin, codex_args, codex_home).await { - Ok(session) => session, - Err(error) => { - let _ = tokio::fs::remove_dir_all(&destination_path).await; - return Err(error); - } - }; - - if let Err(error) = { - let mut workspaces = workspaces.lock().await; - workspaces.insert(entry.id.clone(), entry.clone()); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(storage_path, &list) - } { - { - let mut workspaces = workspaces.lock().await; - workspaces.remove(&entry.id); - } - let mut child = session.child.lock().await; - kill_child_process_tree(&mut child).await; - let _ = tokio::fs::remove_dir_all(&destination_path).await; - return Err(error); - } - - sessions.lock().await.insert(entry.id.clone(), session); - - Ok(WorkspaceInfo { - id: entry.id, - name: entry.name, - path: entry.path, - codex_bin: entry.codex_bin, - connected: true, - kind: entry.kind, - parent_id: entry.parent_id, - worktree: entry.worktree, - settings: entry.settings, - }) -} - -pub(crate) async fn apply_worktree_changes_core( - workspaces: &Mutex>, - workspace_id: String, -) -> Result<(), String> { - let (entry, parent) = { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(&workspace_id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let parent_id = entry - .parent_id - .clone() - .ok_or_else(|| "worktree parent not found".to_string())?; - let parent = workspaces - .get(&parent_id) - .cloned() - .ok_or_else(|| "worktree parent not found".to_string())?; - (entry, parent) - }; - - apply_worktree_changes_inner_core(&entry, &parent).await -} - -async fn apply_worktree_changes_inner_core( - entry: &WorkspaceEntry, - parent: &WorkspaceEntry, -) -> Result<(), String> { - let worktree_root = resolve_git_root(entry)?; - let parent_root = resolve_git_root(parent)?; - - let parent_status = - git_core::run_git_command_bytes(&parent_root, &["status", "--porcelain"]).await?; - if !String::from_utf8_lossy(&parent_status).trim().is_empty() { - return Err( - "Your current branch has uncommitted changes. Please commit, stash, or discard them before applying worktree changes." - .to_string(), - ); - } - - let mut patch: Vec = Vec::new(); - let staged_patch = git_core::run_git_diff( - &worktree_root, - &["diff", "--binary", "--no-color", "--cached"], - ) - .await?; - patch.extend_from_slice(&staged_patch); - let unstaged_patch = - git_core::run_git_diff(&worktree_root, &["diff", "--binary", "--no-color"]).await?; - patch.extend_from_slice(&unstaged_patch); - - let untracked_output = git_core::run_git_command_bytes( - &worktree_root, - &["ls-files", "--others", "--exclude-standard", "-z"], - ) - .await?; - for raw_path in untracked_output.split(|byte| *byte == 0) { - if raw_path.is_empty() { - continue; - } - let path = String::from_utf8_lossy(raw_path).to_string(); - let diff = git_core::run_git_diff( - &worktree_root, - &[ - "diff", - "--binary", - "--no-color", - "--no-index", - "--", - worktree_core::null_device_path(), - &path, - ], - ) - .await?; - patch.extend_from_slice(&diff); - } - - if String::from_utf8_lossy(&patch).trim().is_empty() { - return Err("No changes to apply.".to_string()); - } - - let git_bin = - crate::utils::resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; - let mut child = tokio_command(git_bin) - .args(["apply", "--3way", "--whitespace=nowarn", "-"]) - .current_dir(&parent_root) - .env("PATH", crate::utils::git_env_path()) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to run git: {e}"))?; - - if let Some(mut stdin) = child.stdin.take() { - stdin - .write_all(&patch) - .await - .map_err(|e| format!("Failed to write git apply input: {e}"))?; - } - - let output = child - .wait_with_output() - .await - .map_err(|e| format!("Failed to run git: {e}"))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let detail = if stderr.trim().is_empty() { - stdout.trim() - } else { - stderr.trim() - }; - if detail.is_empty() { - return Err("Git apply failed.".to_string()); - } - - if detail.contains("Applied patch to") { - if detail.contains("with conflicts") { - return Err( - "Applied with conflicts. Resolve conflicts in the parent repo before retrying." - .to_string(), - ); - } - return Err( - "Patch applied partially. Resolve changes in the parent repo before retrying." - .to_string(), - ); - } - - Err(detail.to_string()) -} - -pub(crate) async fn open_workspace_in_core( - path: String, - app: Option, - args: Vec, - command: Option, -) -> Result<(), String> { - fn output_snippet(bytes: &[u8]) -> Option { - const MAX_CHARS: usize = 240; - let text = String::from_utf8_lossy(bytes).trim().replace('\n', "\\n"); - if text.is_empty() { - return None; - } - let mut chars = text.chars(); - let snippet: String = chars.by_ref().take(MAX_CHARS).collect(); - if chars.next().is_some() { - Some(format!("{snippet}...")) - } else { - Some(snippet) - } - } - - let target_label = command - .as_ref() - .map(|value| format!("command `{value}`")) - .or_else(|| app.as_ref().map(|value| format!("app `{value}`"))) - .unwrap_or_else(|| "target".to_string()); - - let output = if let Some(command) = command { - let trimmed = command.trim(); - if trimmed.is_empty() { - return Err("Missing app or command".to_string()); - } - - #[cfg(target_os = "windows")] - let mut cmd = { - let resolved = resolve_windows_executable(trimmed, None); - let resolved_path = resolved.as_deref().unwrap_or_else(|| Path::new(trimmed)); - let ext = resolved_path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()); - - if matches!(ext.as_deref(), Some("cmd") | Some("bat")) { - let mut cmd = tokio_command("cmd"); - let mut command_args = args.clone(); - command_args.push(path.clone()); - let command_line = build_cmd_c_command(resolved_path, &command_args)?; - cmd.arg("/D"); - cmd.arg("/S"); - cmd.arg("/C"); - cmd.raw_arg(command_line); - cmd - } else { - let mut cmd = tokio_command(resolved_path); - cmd.args(&args).arg(&path); - cmd - } - }; - - #[cfg(not(target_os = "windows"))] - let mut cmd = { - let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); - cmd - }; - - cmd.output() - .await - .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? - } else if let Some(app) = app { - let trimmed = app.trim(); - if trimmed.is_empty() { - return Err("Missing app or command".to_string()); - } - - #[cfg(target_os = "macos")] - let mut cmd = { - let mut cmd = tokio_command("open"); - cmd.arg("-a").arg(trimmed).arg(&path); - if !args.is_empty() { - cmd.arg("--args").args(&args); - } - cmd - }; - - #[cfg(not(target_os = "macos"))] - let mut cmd = { - let mut cmd = tokio_command(trimmed); - cmd.args(&args).arg(&path); - cmd - }; - - cmd.output() - .await - .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? - } else { - return Err("Missing app or command".to_string()); - }; - - if output.status.success() { - return Ok(()); - } - - let exit_detail = output - .status - .code() - .map(|code| format!("exit code {code}")) - .unwrap_or_else(|| "terminated by signal".to_string()); - let mut details = Vec::new(); - if let Some(stderr) = output_snippet(&output.stderr) { - details.push(format!("stderr: {stderr}")); - } - if let Some(stdout) = output_snippet(&output.stdout) { - details.push(format!("stdout: {stdout}")); - } - - if details.is_empty() { - Err(format!( - "Failed to open app ({target_label} returned {exit_detail})." - )) - } else { - Err(format!( - "Failed to open app ({target_label} returned {exit_detail}; {}).", - details.join("; ") - )) - } -} - -#[cfg(target_os = "macos")] -pub(crate) async fn get_open_app_icon_core( - app_name: String, - icon_loader: F, -) -> Result, String> -where - F: Fn(&str) -> Option + Send + Sync + 'static, -{ - let trimmed = app_name.trim().to_string(); - if trimmed.is_empty() { - return Ok(None); - } - let icon_loader = Arc::new(icon_loader); - tokio::task::spawn_blocking(move || icon_loader(&trimmed)) - .await - .map_err(|err| err.to_string()) -} - -#[cfg(not(target_os = "macos"))] -pub(crate) async fn get_open_app_icon_core( - app_name: String, - icon_loader: F, -) -> Result, String> -where - F: Fn(&str) -> Option + Send + Sync + 'static, -{ - let _ = app_name; - let _ = icon_loader; - Ok(None) -} - -pub(crate) fn run_git_command_unit( - repo_path: &PathBuf, - args: &[&str], - run_git_command: F, -) -> impl Future> -where - F: Fn(PathBuf, Vec) -> Fut, - Fut: Future>, -{ - // Own the inputs so the returned future does not borrow temporary references. - let repo_path = repo_path.clone(); - let args_owned = args - .iter() - .map(|value| value.to_string()) - .collect::>(); - async move { - run_git_command(repo_path, args_owned) - .await - .map(|_output| ()) - } -} - -pub(crate) async fn add_worktree_core< - FSpawn, - FutSpawn, - FSanitize, - FUniquePath, - FBranchExists, - FutBranchExists, - FFindRemoteTracking, - FutFindRemoteTracking, - FRunGit, - FutRunGit, ->( - parent_id: String, - branch: String, - name: Option, - copy_agents_md: bool, - data_dir: &PathBuf, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - storage_path: &PathBuf, - sanitize_worktree_name: FSanitize, - unique_worktree_path: FUniquePath, - git_branch_exists: FBranchExists, - git_find_remote_tracking_branch: Option, - run_git_command: FRunGit, - spawn_session: FSpawn, -) -> Result -where - FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, - FutSpawn: Future, String>>, - FSanitize: Fn(&str) -> String, - FUniquePath: Fn(&PathBuf, &str) -> Result, - FBranchExists: Fn(&PathBuf, &str) -> FutBranchExists, - FutBranchExists: Future>, - FFindRemoteTracking: Fn(&PathBuf, &str) -> FutFindRemoteTracking, - FutFindRemoteTracking: Future, String>>, - FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, - FutRunGit: Future>, -{ - let branch = branch.trim().to_string(); - if branch.is_empty() { - return Err("Branch name is required.".to_string()); - } - let name = name - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - - let parent_entry = { - let workspaces = workspaces.lock().await; - workspaces - .get(&parent_id) - .cloned() - .ok_or_else(|| "parent workspace not found".to_string())? - }; - - if parent_entry.kind.is_worktree() { - return Err("Cannot create a worktree from another worktree.".to_string()); - } - - let worktree_root = data_dir.join("worktrees").join(&parent_entry.id); - std::fs::create_dir_all(&worktree_root) - .map_err(|err| format!("Failed to create worktree directory: {err}"))?; - - let safe_name = sanitize_worktree_name(&branch); - let worktree_path = unique_worktree_path(&worktree_root, &safe_name)?; - let worktree_path_string = worktree_path.to_string_lossy().to_string(); - - let repo_path = PathBuf::from(&parent_entry.path); - let branch_exists = git_branch_exists(&repo_path, &branch).await?; - if branch_exists { - run_git_command( - &repo_path, - &["worktree", "add", &worktree_path_string, &branch], - ) - .await?; - } else if let Some(find_remote_tracking) = git_find_remote_tracking_branch { - if let Some(remote_ref) = find_remote_tracking(&repo_path, &branch).await? { - run_git_command( - &repo_path, - &[ - "worktree", - "add", - "-b", - &branch, - &worktree_path_string, - &remote_ref, - ], - ) - .await?; - } else { - run_git_command( - &repo_path, - &["worktree", "add", "-b", &branch, &worktree_path_string], - ) - .await?; - } - } else { - run_git_command( - &repo_path, - &["worktree", "add", "-b", &branch, &worktree_path_string], - ) - .await?; - } - - if copy_agents_md { - if let Err(error) = copy_agents_md_from_parent_to_worktree(&repo_path, &worktree_path) { - eprintln!( - "add_worktree: optional {} copy failed for {}: {}", - AGENTS_MD_FILE_NAME, - worktree_path.display(), - error - ); - } - } - - let entry = WorkspaceEntry { - id: Uuid::new_v4().to_string(), - name: name.clone().unwrap_or_else(|| branch.clone()), - path: worktree_path_string, - codex_bin: parent_entry.codex_bin.clone(), - kind: WorkspaceKind::Worktree, - parent_id: Some(parent_entry.id.clone()), - worktree: Some(WorktreeInfo { branch }), - settings: WorkspaceSettings { - worktree_setup_script: normalize_setup_script( - parent_entry.settings.worktree_setup_script.clone(), - ), - ..WorkspaceSettings::default() - }, - }; - - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args(&entry, Some(&parent_entry), Some(&settings)), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry, Some(&parent_entry)); - let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; - - { - let mut workspaces = workspaces.lock().await; - workspaces.insert(entry.id.clone(), entry.clone()); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(storage_path, &list)?; - } - - sessions.lock().await.insert(entry.id.clone(), session); - - Ok(WorkspaceInfo { - id: entry.id, - name: entry.name, - path: entry.path, - codex_bin: entry.codex_bin, - connected: true, - kind: entry.kind, - parent_id: entry.parent_id, - worktree: entry.worktree, - settings: entry.settings, - }) -} - -pub(crate) async fn connect_workspace_core( - workspace_id: String, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - spawn_session: F, -) -> Result<(), String> -where - F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, - Fut: Future, String>>, -{ - let (entry, parent_entry) = resolve_entry_and_parent(workspaces, &workspace_id).await?; - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()); - let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; - sessions.lock().await.insert(entry.id, session); - Ok(()) -} - -async fn kill_session_by_id(sessions: &Mutex>>, id: &str) { - if let Some(session) = sessions.lock().await.remove(id) { - let mut child = session.child.lock().await; - kill_child_process_tree(&mut child).await; - } -} - -pub(crate) async fn remove_workspace_core( - id: String, - workspaces: &Mutex>, - sessions: &Mutex>>, - storage_path: &PathBuf, - run_git_command: FRunGit, - is_missing_worktree_error: FIsMissing, - remove_dir_all: FRemoveDirAll, - require_all_children_removed_to_remove_parent: bool, - continue_on_child_error: bool, -) -> Result<(), String> -where - FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, - FutRunGit: Future>, - FIsMissing: Fn(&str) -> bool, - FRemoveDirAll: Fn(&PathBuf) -> Result<(), String>, -{ - let (entry, child_worktrees) = { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - if entry.kind.is_worktree() { - return Err("Use remove_worktree for worktree agents.".to_string()); - } - let children = workspaces - .values() - .filter(|workspace| workspace.parent_id.as_deref() == Some(&id)) - .cloned() - .collect::>(); - (entry, children) - }; - - let repo_path = PathBuf::from(&entry.path); - let mut removed_child_ids = Vec::new(); - let mut failures: Vec<(String, String)> = Vec::new(); - - for child in &child_worktrees { - kill_session_by_id(sessions, &child.id).await; - - let child_path = PathBuf::from(&child.path); - if child_path.exists() { - if let Err(error) = - run_git_command(&repo_path, &["worktree", "remove", "--force", &child.path]).await - { - if is_missing_worktree_error(&error) { - if child_path.exists() { - if let Err(fs_error) = remove_dir_all(&child_path) { - if continue_on_child_error { - failures.push((child.id.clone(), fs_error)); - continue; - } - return Err(fs_error); - } - } - } else { - if continue_on_child_error { - failures.push((child.id.clone(), error)); - continue; - } - return Err(error); - } - } - } - removed_child_ids.push(child.id.clone()); - } - - let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await; - - let mut ids_to_remove = removed_child_ids; - if failures.is_empty() || !require_all_children_removed_to_remove_parent { - kill_session_by_id(sessions, &id).await; - ids_to_remove.push(id.clone()); - } - - { - let mut workspaces = workspaces.lock().await; - for workspace_id in ids_to_remove { - workspaces.remove(&workspace_id); - } - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(storage_path, &list)?; - } - - if failures.is_empty() { - return Ok(()); - } - - if require_all_children_removed_to_remove_parent { - let mut message = - "Failed to remove one or more worktrees; parent workspace was not removed.".to_string(); - for (child_id, error) in failures { - message.push_str(&format!("\n- {child_id}: {error}")); - } - return Err(message); - } - - Ok(()) -} - -pub(crate) async fn remove_worktree_core( - id: String, - workspaces: &Mutex>, - sessions: &Mutex>>, - storage_path: &PathBuf, - run_git_command: FRunGit, - is_missing_worktree_error: FIsMissing, - remove_dir_all: FRemoveDirAll, -) -> Result<(), String> -where - FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, - FutRunGit: Future>, - FIsMissing: Fn(&str) -> bool, - FRemoveDirAll: Fn(&PathBuf) -> Result<(), String>, -{ - let (entry, parent) = { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let parent_id = entry - .parent_id - .clone() - .ok_or_else(|| "worktree parent not found".to_string())?; - let parent = workspaces - .get(&parent_id) - .cloned() - .ok_or_else(|| "worktree parent not found".to_string())?; - (entry, parent) - }; - - let parent_path = PathBuf::from(&parent.path); - let entry_path = PathBuf::from(&entry.path); - kill_session_by_id(sessions, &entry.id).await; - - if entry_path.exists() { - if let Err(error) = run_git_command( - &parent_path, - &["worktree", "remove", "--force", &entry.path], - ) - .await - { - if is_missing_worktree_error(&error) { - if entry_path.exists() { - remove_dir_all(&entry_path)?; - } - } else { - return Err(error); - } - } - } - let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; - - { - let mut workspaces = workspaces.lock().await; - workspaces.remove(&entry.id); - let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(storage_path, &list)?; - } - - Ok(()) -} - -pub(crate) async fn rename_worktree_core< - FSpawn, - FutSpawn, - FResolveGitRoot, - FUniqueBranch, - FutUniqueBranch, - FSanitize, - FUniqueRenamePath, - FRunGit, - FutRunGit, ->( - id: String, - branch: String, - data_dir: &PathBuf, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - storage_path: &PathBuf, - resolve_git_root: FResolveGitRoot, - unique_branch_name: FUniqueBranch, - sanitize_worktree_name: FSanitize, - unique_worktree_path_for_rename: FUniqueRenamePath, - run_git_command: FRunGit, - spawn_session: FSpawn, -) -> Result -where - FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, - FutSpawn: Future, String>>, - FResolveGitRoot: Fn(&WorkspaceEntry) -> Result, - FUniqueBranch: Fn(&PathBuf, &str) -> FutUniqueBranch, - FutUniqueBranch: Future>, - FSanitize: Fn(&str) -> String, - FUniqueRenamePath: Fn(&PathBuf, &str, &PathBuf) -> Result, - FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, - FutRunGit: Future>, -{ - let trimmed = branch.trim(); - if trimmed.is_empty() { - return Err("Branch name is required.".to_string()); - } - - let (entry, parent) = { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let parent_id = entry - .parent_id - .clone() - .ok_or_else(|| "worktree parent not found".to_string())?; - let parent = workspaces - .get(&parent_id) - .cloned() - .ok_or_else(|| "worktree parent not found".to_string())?; - (entry, parent) - }; - - let old_branch = entry - .worktree - .as_ref() - .map(|worktree| worktree.branch.clone()) - .ok_or_else(|| "worktree metadata missing".to_string())?; - if old_branch == trimmed { - return Err("Branch name is unchanged.".to_string()); - } - - let parent_root = resolve_git_root(&parent)?; - let final_branch = unique_branch_name(&parent_root, trimmed).await?; - if final_branch == old_branch { - return Err("Branch name is unchanged.".to_string()); - } - - run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?; - - let worktree_root = data_dir.join("worktrees").join(&parent.id); - std::fs::create_dir_all(&worktree_root) - .map_err(|err| format!("Failed to create worktree directory: {err}"))?; - - let safe_name = sanitize_worktree_name(&final_branch); - let current_path = PathBuf::from(&entry.path); - let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_path)?; - let next_path_string = next_path.to_string_lossy().to_string(); - if next_path_string != entry.path { - if let Err(error) = run_git_command( - &parent_root, - &["worktree", "move", &entry.path, &next_path_string], - ) - .await - { - let _ = - run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await; - return Err(error); - } - } - - let (entry_snapshot, list) = { - let mut workspaces = workspaces.lock().await; - let entry = match workspaces.get_mut(&id) { - Some(entry) => entry, - None => return Err("workspace not found".to_string()), - }; - if entry.name.trim() == old_branch { - entry.name = final_branch.clone(); - } - entry.path = next_path_string.clone(); - match entry.worktree.as_mut() { - Some(worktree) => { - worktree.branch = final_branch.clone(); - } - None => { - entry.worktree = Some(WorktreeInfo { - branch: final_branch.clone(), - }); - } - } - let snapshot = entry.clone(); - let list: Vec<_> = workspaces.values().cloned().collect(); - (snapshot, list) - }; - write_workspaces(storage_path, &list)?; - - let was_connected = sessions.lock().await.contains_key(&entry_snapshot.id); - if was_connected { - kill_session_by_id(sessions, &entry_snapshot.id).await; - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args(&entry_snapshot, Some(&parent), Some(&settings)), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry_snapshot, Some(&parent)); - match spawn_session(entry_snapshot.clone(), default_bin, codex_args, codex_home).await { - Ok(session) => { - sessions - .lock() - .await - .insert(entry_snapshot.id.clone(), session); - } - Err(error) => { - eprintln!( - "rename_worktree: respawn failed for {} after rename: {error}", - entry_snapshot.id - ); - } - } - } - - let connected = sessions.lock().await.contains_key(&entry_snapshot.id); - Ok(WorkspaceInfo { - id: entry_snapshot.id, - name: entry_snapshot.name, - path: entry_snapshot.path, - codex_bin: entry_snapshot.codex_bin, - connected, - kind: entry_snapshot.kind, - parent_id: entry_snapshot.parent_id, - worktree: entry_snapshot.worktree, - settings: entry_snapshot.settings, - }) -} - -pub(crate) async fn rename_worktree_upstream_core< - FResolveGitRoot, - FBranchExists, - FutBranchExists, - FFindRemote, - FutFindRemote, - FRemoteExists, - FutRemoteExists, - FRemoteBranchExists, - FutRemoteBranchExists, - FRunGit, - FutRunGit, ->( - id: String, - old_branch: String, - new_branch: String, - workspaces: &Mutex>, - resolve_git_root: FResolveGitRoot, - git_branch_exists: FBranchExists, - git_find_remote_for_branch: FFindRemote, - git_remote_exists: FRemoteExists, - git_remote_branch_exists: FRemoteBranchExists, - run_git_command: FRunGit, -) -> Result<(), String> -where - FResolveGitRoot: Fn(&WorkspaceEntry) -> Result, - FBranchExists: Fn(&PathBuf, &str) -> FutBranchExists, - FutBranchExists: Future>, - FFindRemote: Fn(&PathBuf, &str) -> FutFindRemote, - FutFindRemote: Future, String>>, - FRemoteExists: Fn(&PathBuf, &str) -> FutRemoteExists, - FutRemoteExists: Future>, - FRemoteBranchExists: Fn(&PathBuf, &str, &str) -> FutRemoteBranchExists, - FutRemoteBranchExists: Future>, - FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, - FutRunGit: Future>, -{ - let old_branch = old_branch.trim().to_string(); - let new_branch = new_branch.trim().to_string(); - if old_branch.is_empty() || new_branch.is_empty() { - return Err("Branch name is required.".to_string()); - } - if old_branch == new_branch { - return Err("Branch name is unchanged.".to_string()); - } - - let (_entry, parent) = { - let workspaces = workspaces.lock().await; - let entry = workspaces - .get(&id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - if !entry.kind.is_worktree() { - return Err("Not a worktree workspace.".to_string()); - } - let parent_id = entry - .parent_id - .clone() - .ok_or_else(|| "worktree parent not found".to_string())?; - let parent = workspaces - .get(&parent_id) - .cloned() - .ok_or_else(|| "worktree parent not found".to_string())?; - (entry, parent) - }; - - let parent_root = resolve_git_root(&parent)?; - if !git_branch_exists(&parent_root, &new_branch).await? { - return Err("Local branch not found.".to_string()); - } - - let remote_for_old = git_find_remote_for_branch(&parent_root, &old_branch).await?; - let remote_name = match remote_for_old.as_ref() { - Some(remote) => remote.clone(), - None => { - if git_remote_exists(&parent_root, "origin").await? { - "origin".to_string() - } else { - return Err("No git remote configured for this worktree.".to_string()); - } - } - }; - - if git_remote_branch_exists(&parent_root, &remote_name, &new_branch).await? { - return Err("Remote branch already exists.".to_string()); - } - - if remote_for_old.is_some() { - run_git_command( - &parent_root, - &["push", &remote_name, &format!("{new_branch}:{new_branch}")], - ) - .await?; - run_git_command( - &parent_root, - &["push", &remote_name, &format!(":{old_branch}")], - ) - .await?; - } else { - run_git_command(&parent_root, &["push", &remote_name, &new_branch]).await?; - } - - run_git_command( - &parent_root, - &[ - "branch", - "--set-upstream-to", - &format!("{remote_name}/{new_branch}"), - &new_branch, - ], - ) - .await?; - - Ok(()) -} - -pub(crate) async fn update_workspace_settings_core( - id: String, - mut settings: WorkspaceSettings, - workspaces: &Mutex>, - sessions: &Mutex>>, - app_settings: &Mutex, - storage_path: &PathBuf, - apply_settings_update: FApplySettings, - spawn_session: FSpawn, -) -> Result -where - FApplySettings: Fn( - &mut HashMap, - &str, - WorkspaceSettings, - ) -> Result, - FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, - FutSpawn: Future, String>>, -{ - settings.worktree_setup_script = normalize_setup_script(settings.worktree_setup_script); - - let ( - previous_entry, - entry_snapshot, - parent_entry, - previous_codex_home, - previous_codex_args, - previous_worktree_setup_script, - child_entries, - ) = { - let mut workspaces = workspaces.lock().await; - let previous_entry = workspaces - .get(&id) - .cloned() - .ok_or_else(|| "workspace not found".to_string())?; - let previous_codex_home = previous_entry.settings.codex_home.clone(); - let previous_codex_args = previous_entry.settings.codex_args.clone(); - let previous_worktree_setup_script = previous_entry.settings.worktree_setup_script.clone(); - let entry_snapshot = apply_settings_update(&mut workspaces, &id, settings)?; - let parent_entry = entry_snapshot - .parent_id - .as_ref() - .and_then(|parent_id| workspaces.get(parent_id)) - .cloned(); - let child_entries = workspaces - .values() - .filter(|entry| entry.parent_id.as_deref() == Some(&id)) - .cloned() - .collect::>(); - ( - previous_entry, - entry_snapshot, - parent_entry, - previous_codex_home, - previous_codex_args, - previous_worktree_setup_script, - child_entries, - ) - }; - - let codex_home_changed = previous_codex_home != entry_snapshot.settings.codex_home; - let codex_args_changed = previous_codex_args != entry_snapshot.settings.codex_args; - let worktree_setup_script_changed = - previous_worktree_setup_script != entry_snapshot.settings.worktree_setup_script; - let connected = sessions.lock().await.contains_key(&id); - if connected && (codex_home_changed || codex_args_changed) { - let rollback_entry = previous_entry.clone(); - let (default_bin, codex_args) = { - let settings = app_settings.lock().await; - ( - settings.codex_bin.clone(), - resolve_workspace_codex_args( - &entry_snapshot, - parent_entry.as_ref(), - Some(&settings), - ), - ) - }; - let codex_home = resolve_workspace_codex_home(&entry_snapshot, parent_entry.as_ref()); - let new_session = match spawn_session( - entry_snapshot.clone(), - default_bin, - codex_args, - codex_home, - ) - .await - { - Ok(session) => session, - Err(error) => { - let mut workspaces = workspaces.lock().await; - workspaces.insert(rollback_entry.id.clone(), rollback_entry); - return Err(error); - } - }; - if let Some(old_session) = sessions - .lock() - .await - .insert(entry_snapshot.id.clone(), new_session) - { - let mut child = old_session.child.lock().await; - kill_child_process_tree(&mut child).await; - } - } - if codex_home_changed || codex_args_changed { - let app_settings_snapshot = app_settings.lock().await.clone(); - let default_bin = app_settings_snapshot.codex_bin.clone(); - for child in &child_entries { - let connected = sessions.lock().await.contains_key(&child.id); - if !connected { - continue; - } - let previous_child_home = resolve_workspace_codex_home(child, Some(&previous_entry)); - let next_child_home = resolve_workspace_codex_home(child, Some(&entry_snapshot)); - let previous_child_args = resolve_workspace_codex_args( - child, - Some(&previous_entry), - Some(&app_settings_snapshot), - ); - let next_child_args = resolve_workspace_codex_args( - child, - Some(&entry_snapshot), - Some(&app_settings_snapshot), - ); - if previous_child_home == next_child_home && previous_child_args == next_child_args { - continue; - } - let new_session = match spawn_session( - child.clone(), - default_bin.clone(), - next_child_args, - next_child_home, - ) - .await - { - Ok(session) => session, - Err(error) => { - eprintln!( - "update_workspace_settings: respawn failed for worktree {} after parent override change: {error}", - child.id - ); - continue; - } - }; - if let Some(old_session) = sessions.lock().await.insert(child.id.clone(), new_session) { - let mut child = old_session.child.lock().await; - kill_child_process_tree(&mut child).await; - } - } - } - if worktree_setup_script_changed && !entry_snapshot.kind.is_worktree() { - let child_ids = child_entries - .iter() - .map(|child| child.id.clone()) - .collect::>(); - if !child_ids.is_empty() { - let mut workspaces = workspaces.lock().await; - for child_id in child_ids { - if let Some(child) = workspaces.get_mut(&child_id) { - child.settings.worktree_setup_script = - entry_snapshot.settings.worktree_setup_script.clone(); - } - } - } - } - let list: Vec<_> = { - let workspaces = workspaces.lock().await; - workspaces.values().cloned().collect() - }; - write_workspaces(storage_path, &list)?; - Ok(WorkspaceInfo { - id: entry_snapshot.id, - name: entry_snapshot.name, - path: entry_snapshot.path, - codex_bin: entry_snapshot.codex_bin, - connected, - kind: entry_snapshot.kind, - parent_id: entry_snapshot.parent_id, - worktree: entry_snapshot.worktree, - settings: entry_snapshot.settings, - }) -} - -pub(crate) async fn update_workspace_codex_bin_core( - id: String, - codex_bin: Option, - workspaces: &Mutex>, - sessions: &Mutex>>, - storage_path: &PathBuf, -) -> Result { - let (entry_snapshot, list) = { - let mut workspaces = workspaces.lock().await; - let entry_snapshot = match workspaces.get_mut(&id) { - Some(entry) => { - entry.codex_bin = codex_bin.clone(); - entry.clone() - } - None => return Err("workspace not found".to_string()), - }; - let list: Vec<_> = workspaces.values().cloned().collect(); - (entry_snapshot, list) - }; - write_workspaces(storage_path, &list)?; - - let connected = sessions.lock().await.contains_key(&id); - Ok(WorkspaceInfo { - id: entry_snapshot.id, - name: entry_snapshot.name, - path: entry_snapshot.path, - codex_bin: entry_snapshot.codex_bin, - connected, - kind: entry_snapshot.kind, - parent_id: entry_snapshot.parent_id, - worktree: entry_snapshot.worktree, - settings: entry_snapshot.settings, - }) -} - -pub(crate) async fn list_workspace_files_core( - workspaces: &Mutex>, - workspace_id: &str, - list_files: F, -) -> Result, String> -where - F: Fn(&PathBuf) -> Vec, -{ - let root = resolve_workspace_root(workspaces, workspace_id).await?; - Ok(list_files(&root)) -} - -pub(crate) async fn read_workspace_file_core( - workspaces: &Mutex>, - workspace_id: &str, - path: &str, - read_file: F, -) -> Result -where - F: Fn(&PathBuf, &str) -> Result, -{ - let root = resolve_workspace_root(workspaces, workspace_id).await?; - read_file(&root, path) -} - -fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { - workspaces.sort_by(|a, b| { - let a_order = a.settings.sort_order.unwrap_or(u32::MAX); - let b_order = b.settings.sort_order.unwrap_or(u32::MAX); - if a_order != b_order { - return a_order.cmp(&b_order); - } - a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)) - }); -} - -#[cfg(test)] -mod tests { - use super::copy_agents_md_from_parent_to_worktree; - use super::AGENTS_MD_FILE_NAME; - use uuid::Uuid; - - fn make_temp_dir() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("codex-monitor-{}", Uuid::new_v4())); - std::fs::create_dir_all(&dir).expect("failed to create temp dir"); - dir - } - - #[test] - fn copies_agents_md_when_missing_in_worktree() { - let parent = make_temp_dir(); - let worktree = make_temp_dir(); - let parent_agents = parent.join(AGENTS_MD_FILE_NAME); - let worktree_agents = worktree.join(AGENTS_MD_FILE_NAME); - - std::fs::write(&parent_agents, "parent").expect("failed to write parent AGENTS.md"); - - copy_agents_md_from_parent_to_worktree(&parent, &worktree).expect("copy should succeed"); - - let copied = std::fs::read_to_string(&worktree_agents) - .expect("worktree AGENTS.md should exist after copy"); - assert_eq!(copied, "parent"); - - let _ = std::fs::remove_dir_all(parent); - let _ = std::fs::remove_dir_all(worktree); - } - - #[test] - fn does_not_overwrite_existing_worktree_agents_md() { - let parent = make_temp_dir(); - let worktree = make_temp_dir(); - let parent_agents = parent.join(AGENTS_MD_FILE_NAME); - let worktree_agents = worktree.join(AGENTS_MD_FILE_NAME); - - std::fs::write(&parent_agents, "parent").expect("failed to write parent AGENTS.md"); - std::fs::write(&worktree_agents, "branch-specific") - .expect("failed to write worktree AGENTS.md"); - - copy_agents_md_from_parent_to_worktree(&parent, &worktree).expect("copy should succeed"); - - let retained = std::fs::read_to_string(&worktree_agents) - .expect("worktree AGENTS.md should still exist"); - assert_eq!(retained, "branch-specific"); - - let _ = std::fs::remove_dir_all(parent); - let _ = std::fs::remove_dir_all(worktree); - } -} diff --git a/src-tauri/src/shared/workspaces_core/connect.rs b/src-tauri/src/shared/workspaces_core/connect.rs new file mode 100644 index 0000000000..e59c5d1223 --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/connect.rs @@ -0,0 +1,49 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::backend::app_server::WorkspaceSession; +use crate::codex::args::resolve_workspace_codex_args; +use crate::codex::home::resolve_workspace_codex_home; +use crate::shared::process_core::kill_child_process_tree; +use crate::types::{AppSettings, WorkspaceEntry}; + +use super::helpers::resolve_entry_and_parent; + +pub(crate) async fn connect_workspace_core( + workspace_id: String, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + spawn_session: F, +) -> Result<(), String> +where + F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, + Fut: Future, String>>, +{ + let (entry, parent_entry) = resolve_entry_and_parent(workspaces, &workspace_id).await?; + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()); + let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; + sessions.lock().await.insert(entry.id, session); + Ok(()) +} + +pub(super) async fn kill_session_by_id( + sessions: &Mutex>>, + id: &str, +) { + if let Some(session) = sessions.lock().await.remove(id) { + let mut child = session.child.lock().await; + kill_child_process_tree(&mut child).await; + } +} diff --git a/src-tauri/src/shared/workspaces_core/crud_persistence.rs b/src-tauri/src/shared/workspaces_core/crud_persistence.rs new file mode 100644 index 0000000000..19a868b649 --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/crud_persistence.rs @@ -0,0 +1,541 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::backend::app_server::WorkspaceSession; +use crate::codex::args::resolve_workspace_codex_args; +use crate::codex::home::resolve_workspace_codex_home; +use crate::shared::process_core::kill_child_process_tree; +use crate::shared::{git_core, worktree_core}; +use crate::storage::write_workspaces; +use crate::types::{AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; + +use super::connect::kill_session_by_id; +use super::helpers::normalize_setup_script; + +pub(crate) async fn add_workspace_core( + path: String, + codex_bin: Option, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + spawn_session: F, +) -> Result +where + F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, + Fut: Future, String>>, +{ + if !PathBuf::from(&path).is_dir() { + return Err("Workspace path must be a folder.".to_string()); + } + + let name = PathBuf::from(&path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("Workspace") + .to_string(); + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: name.clone(), + path: path.clone(), + codex_bin, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry, None, Some(&settings)), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry, None); + let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; + + if let Err(error) = { + let mut workspaces = workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(storage_path, &list) + } { + { + let mut workspaces = workspaces.lock().await; + workspaces.remove(&entry.id); + } + let mut child = session.child.lock().await; + kill_child_process_tree(&mut child).await; + return Err(error); + } + + sessions.lock().await.insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +pub(crate) async fn add_clone_core( + source_workspace_id: String, + copy_name: String, + copies_folder: String, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + spawn_session: F, +) -> Result +where + F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, + Fut: Future, String>>, +{ + let copy_name = copy_name.trim().to_string(); + if copy_name.is_empty() { + return Err("Copy name is required.".to_string()); + } + + let copies_folder = copies_folder.trim().to_string(); + if copies_folder.is_empty() { + return Err("Copies folder is required.".to_string()); + } + let copies_folder_path = PathBuf::from(&copies_folder); + std::fs::create_dir_all(&copies_folder_path) + .map_err(|e| format!("Failed to create copies folder: {e}"))?; + if !copies_folder_path.is_dir() { + return Err("Copies folder must be a directory.".to_string()); + } + + let (source_entry, inherited_group_id) = { + let workspaces = workspaces.lock().await; + let source_entry = workspaces + .get(&source_workspace_id) + .cloned() + .ok_or_else(|| "source workspace not found".to_string())?; + let inherited_group_id = if source_entry.kind.is_worktree() { + source_entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .and_then(|parent| parent.settings.group_id.clone()) + } else { + source_entry.settings.group_id.clone() + }; + (source_entry, inherited_group_id) + }; + + let destination_path = + worktree_core::build_clone_destination_path(&copies_folder_path, ©_name); + let destination_path_string = destination_path.to_string_lossy().to_string(); + + if let Err(error) = git_core::run_git_command( + &copies_folder_path, + &["clone", &source_entry.path, &destination_path_string], + ) + .await + { + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); + } + + if let Some(origin_url) = git_core::git_get_origin_url(&PathBuf::from(&source_entry.path)).await + { + let _ = git_core::run_git_command( + &destination_path, + &["remote", "set-url", "origin", &origin_url], + ) + .await; + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: copy_name, + path: destination_path_string, + codex_bin: source_entry.codex_bin.clone(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings { + group_id: inherited_group_id, + ..WorkspaceSettings::default() + }, + }; + + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry, None, Some(&settings)), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry, None); + let session = match spawn_session(entry.clone(), default_bin, codex_args, codex_home).await { + Ok(session) => session, + Err(error) => { + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); + } + }; + + if let Err(error) = { + let mut workspaces = workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(storage_path, &list) + } { + { + let mut workspaces = workspaces.lock().await; + workspaces.remove(&entry.id); + } + let mut child = session.child.lock().await; + kill_child_process_tree(&mut child).await; + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); + } + + sessions.lock().await.insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +pub(crate) async fn remove_workspace_core( + id: String, + workspaces: &Mutex>, + sessions: &Mutex>>, + storage_path: &PathBuf, + run_git_command: FRunGit, + is_missing_worktree_error: FIsMissing, + remove_dir_all: FRemoveDirAll, + require_all_children_removed_to_remove_parent: bool, + continue_on_child_error: bool, +) -> Result<(), String> +where + FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, + FutRunGit: Future>, + FIsMissing: Fn(&str) -> bool, + FRemoveDirAll: Fn(&PathBuf) -> Result<(), String>, +{ + let (entry, child_worktrees) = { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + if entry.kind.is_worktree() { + return Err("Use remove_worktree for worktree agents.".to_string()); + } + let children = workspaces + .values() + .filter(|workspace| workspace.parent_id.as_deref() == Some(&id)) + .cloned() + .collect::>(); + (entry, children) + }; + + let repo_path = PathBuf::from(&entry.path); + let mut removed_child_ids = Vec::new(); + let mut failures: Vec<(String, String)> = Vec::new(); + + for child in &child_worktrees { + kill_session_by_id(sessions, &child.id).await; + + let child_path = PathBuf::from(&child.path); + if child_path.exists() { + if let Err(error) = + run_git_command(&repo_path, &["worktree", "remove", "--force", &child.path]).await + { + if is_missing_worktree_error(&error) { + if child_path.exists() { + if let Err(fs_error) = remove_dir_all(&child_path) { + if continue_on_child_error { + failures.push((child.id.clone(), fs_error)); + continue; + } + return Err(fs_error); + } + } + } else { + if continue_on_child_error { + failures.push((child.id.clone(), error)); + continue; + } + return Err(error); + } + } + } + removed_child_ids.push(child.id.clone()); + } + + let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await; + + let mut ids_to_remove = removed_child_ids; + if failures.is_empty() || !require_all_children_removed_to_remove_parent { + kill_session_by_id(sessions, &id).await; + ids_to_remove.push(id.clone()); + } + + { + let mut workspaces = workspaces.lock().await; + for workspace_id in ids_to_remove { + workspaces.remove(&workspace_id); + } + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(storage_path, &list)?; + } + + if failures.is_empty() { + return Ok(()); + } + + if require_all_children_removed_to_remove_parent { + let mut message = + "Failed to remove one or more worktrees; parent workspace was not removed.".to_string(); + for (child_id, error) in failures { + message.push_str(&format!("\n- {child_id}: {error}")); + } + return Err(message); + } + + Ok(()) +} + +pub(crate) async fn update_workspace_settings_core( + id: String, + mut settings: WorkspaceSettings, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + apply_settings_update: FApplySettings, + spawn_session: FSpawn, +) -> Result +where + FApplySettings: Fn( + &mut HashMap, + &str, + WorkspaceSettings, + ) -> Result, + FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, + FutSpawn: Future, String>>, +{ + settings.worktree_setup_script = normalize_setup_script(settings.worktree_setup_script); + + let ( + previous_entry, + entry_snapshot, + parent_entry, + previous_codex_home, + previous_codex_args, + previous_worktree_setup_script, + child_entries, + ) = { + let mut workspaces = workspaces.lock().await; + let previous_entry = workspaces + .get(&id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + let previous_codex_home = previous_entry.settings.codex_home.clone(); + let previous_codex_args = previous_entry.settings.codex_args.clone(); + let previous_worktree_setup_script = previous_entry.settings.worktree_setup_script.clone(); + let entry_snapshot = apply_settings_update(&mut workspaces, &id, settings)?; + let parent_entry = entry_snapshot + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + let child_entries = workspaces + .values() + .filter(|entry| entry.parent_id.as_deref() == Some(&id)) + .cloned() + .collect::>(); + ( + previous_entry, + entry_snapshot, + parent_entry, + previous_codex_home, + previous_codex_args, + previous_worktree_setup_script, + child_entries, + ) + }; + + let codex_home_changed = previous_codex_home != entry_snapshot.settings.codex_home; + let codex_args_changed = previous_codex_args != entry_snapshot.settings.codex_args; + let worktree_setup_script_changed = + previous_worktree_setup_script != entry_snapshot.settings.worktree_setup_script; + let connected = sessions.lock().await.contains_key(&id); + if connected && (codex_home_changed || codex_args_changed) { + let rollback_entry = previous_entry.clone(); + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args( + &entry_snapshot, + parent_entry.as_ref(), + Some(&settings), + ), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry_snapshot, parent_entry.as_ref()); + let new_session = match spawn_session( + entry_snapshot.clone(), + default_bin, + codex_args, + codex_home, + ) + .await + { + Ok(session) => session, + Err(error) => { + let mut workspaces = workspaces.lock().await; + workspaces.insert(rollback_entry.id.clone(), rollback_entry); + return Err(error); + } + }; + if let Some(old_session) = sessions + .lock() + .await + .insert(entry_snapshot.id.clone(), new_session) + { + let mut child = old_session.child.lock().await; + kill_child_process_tree(&mut child).await; + } + } + if codex_home_changed || codex_args_changed { + let app_settings_snapshot = app_settings.lock().await.clone(); + let default_bin = app_settings_snapshot.codex_bin.clone(); + for child in &child_entries { + let connected = sessions.lock().await.contains_key(&child.id); + if !connected { + continue; + } + let previous_child_home = resolve_workspace_codex_home(child, Some(&previous_entry)); + let next_child_home = resolve_workspace_codex_home(child, Some(&entry_snapshot)); + let previous_child_args = resolve_workspace_codex_args( + child, + Some(&previous_entry), + Some(&app_settings_snapshot), + ); + let next_child_args = resolve_workspace_codex_args( + child, + Some(&entry_snapshot), + Some(&app_settings_snapshot), + ); + if previous_child_home == next_child_home && previous_child_args == next_child_args { + continue; + } + let new_session = match spawn_session( + child.clone(), + default_bin.clone(), + next_child_args, + next_child_home, + ) + .await + { + Ok(session) => session, + Err(error) => { + eprintln!( + "update_workspace_settings: respawn failed for worktree {} after parent override change: {error}", + child.id + ); + continue; + } + }; + if let Some(old_session) = sessions.lock().await.insert(child.id.clone(), new_session) { + let mut child = old_session.child.lock().await; + kill_child_process_tree(&mut child).await; + } + } + } + if worktree_setup_script_changed && !entry_snapshot.kind.is_worktree() { + let child_ids = child_entries + .iter() + .map(|child| child.id.clone()) + .collect::>(); + if !child_ids.is_empty() { + let mut workspaces = workspaces.lock().await; + for child_id in child_ids { + if let Some(child) = workspaces.get_mut(&child_id) { + child.settings.worktree_setup_script = + entry_snapshot.settings.worktree_setup_script.clone(); + } + } + } + } + let list: Vec<_> = { + let workspaces = workspaces.lock().await; + workspaces.values().cloned().collect() + }; + write_workspaces(storage_path, &list)?; + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} + +pub(crate) async fn update_workspace_codex_bin_core( + id: String, + codex_bin: Option, + workspaces: &Mutex>, + sessions: &Mutex>>, + storage_path: &PathBuf, +) -> Result { + let (entry_snapshot, list) = { + let mut workspaces = workspaces.lock().await; + let entry_snapshot = match workspaces.get_mut(&id) { + Some(entry) => { + entry.codex_bin = codex_bin.clone(); + entry.clone() + } + None => return Err("workspace not found".to_string()), + }; + let list: Vec<_> = workspaces.values().cloned().collect(); + (entry_snapshot, list) + }; + write_workspaces(storage_path, &list)?; + + let connected = sessions.lock().await.contains_key(&id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} diff --git a/src-tauri/src/shared/workspaces_core/git_orchestration.rs b/src-tauri/src/shared/workspaces_core/git_orchestration.rs new file mode 100644 index 0000000000..6ee25e29bc --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/git_orchestration.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::process::Stdio; + +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +use crate::git_utils::resolve_git_root; +use crate::shared::process_core::tokio_command; +use crate::shared::{git_core, worktree_core}; +use crate::types::WorkspaceEntry; + +pub(crate) fn run_git_command_unit( + repo_path: &PathBuf, + args: &[&str], + run_git_command: F, +) -> impl Future> +where + F: Fn(PathBuf, Vec) -> Fut, + Fut: Future>, +{ + let repo_path = repo_path.clone(); + let args_owned = args + .iter() + .map(|value| value.to_string()) + .collect::>(); + async move { + run_git_command(repo_path, args_owned) + .await + .map(|_output| ()) + } +} + +pub(crate) async fn apply_worktree_changes_core( + workspaces: &Mutex>, + workspace_id: String, +) -> Result<(), String> { + let (entry, parent) = { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(&workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or_else(|| "worktree parent not found".to_string())?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or_else(|| "worktree parent not found".to_string())?; + (entry, parent) + }; + + apply_worktree_changes_inner_core(&entry, &parent).await +} + +pub(super) async fn apply_worktree_changes_inner_core( + entry: &WorkspaceEntry, + parent: &WorkspaceEntry, +) -> Result<(), String> { + let worktree_root = resolve_git_root(entry)?; + let parent_root = resolve_git_root(parent)?; + + let parent_status = + git_core::run_git_command_bytes(&parent_root, &["status", "--porcelain"]).await?; + if !String::from_utf8_lossy(&parent_status).trim().is_empty() { + return Err( + "Your current branch has uncommitted changes. Please commit, stash, or discard them before applying worktree changes." + .to_string(), + ); + } + + let mut patch: Vec = Vec::new(); + let staged_patch = git_core::run_git_diff( + &worktree_root, + &["diff", "--binary", "--no-color", "--cached"], + ) + .await?; + patch.extend_from_slice(&staged_patch); + let unstaged_patch = + git_core::run_git_diff(&worktree_root, &["diff", "--binary", "--no-color"]).await?; + patch.extend_from_slice(&unstaged_patch); + + let untracked_output = git_core::run_git_command_bytes( + &worktree_root, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await?; + for raw_path in untracked_output.split(|byte| *byte == 0) { + if raw_path.is_empty() { + continue; + } + let path = String::from_utf8_lossy(raw_path).to_string(); + let diff = git_core::run_git_diff( + &worktree_root, + &[ + "diff", + "--binary", + "--no-color", + "--no-index", + "--", + worktree_core::null_device_path(), + &path, + ], + ) + .await?; + patch.extend_from_slice(&diff); + } + + if String::from_utf8_lossy(&patch).trim().is_empty() { + return Err("No changes to apply.".to_string()); + } + + let git_bin = + crate::utils::resolve_git_binary().map_err(|e| format!("Failed to run git: {e}"))?; + let mut child = tokio_command(git_bin) + .args(["apply", "--3way", "--whitespace=nowarn", "-"]) + .current_dir(&parent_root) + .env("PATH", crate::utils::git_env_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to run git: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(&patch) + .await + .map_err(|e| format!("Failed to write git apply input: {e}"))?; + } + + let output = child + .wait_with_output() + .await + .map_err(|e| format!("Failed to run git: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + return Err("Git apply failed.".to_string()); + } + + if detail.contains("Applied patch to") { + if detail.contains("with conflicts") { + return Err( + "Applied with conflicts. Resolve conflicts in the parent repo before retrying." + .to_string(), + ); + } + return Err( + "Patch applied partially. Resolve changes in the parent repo before retrying." + .to_string(), + ); + } + + Err(detail.to_string()) +} diff --git a/src-tauri/src/shared/workspaces_core/helpers.rs b/src-tauri/src/shared/workspaces_core/helpers.rs new file mode 100644 index 0000000000..bcf3525a43 --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/helpers.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::backend::app_server::WorkspaceSession; +use crate::types::{WorkspaceEntry, WorkspaceInfo}; + +pub(crate) const WORKTREE_SETUP_MARKERS_DIR: &str = "worktree-setup"; +pub(crate) const WORKTREE_SETUP_MARKER_EXT: &str = "ran"; +pub(super) const AGENTS_MD_FILE_NAME: &str = "AGENTS.md"; + +pub(super) fn copy_agents_md_from_parent_to_worktree( + parent_repo_root: &PathBuf, + worktree_root: &PathBuf, +) -> Result<(), String> { + let source_path = parent_repo_root.join(AGENTS_MD_FILE_NAME); + if !source_path.is_file() { + return Ok(()); + } + + let destination_path = worktree_root.join(AGENTS_MD_FILE_NAME); + if destination_path.is_file() { + return Ok(()); + } + + let temp_path = worktree_root.join(format!("{AGENTS_MD_FILE_NAME}.tmp")); + + std::fs::copy(&source_path, &temp_path).map_err(|err| { + format!( + "Failed to copy {} from {} to {}: {err}", + AGENTS_MD_FILE_NAME, + source_path.display(), + temp_path.display() + ) + })?; + + std::fs::rename(&temp_path, &destination_path).map_err(|err| { + let _ = std::fs::remove_file(&temp_path); + format!( + "Failed to finalize {} copy to {}: {err}", + AGENTS_MD_FILE_NAME, + destination_path.display() + ) + })?; + + Ok(()) +} + +pub(crate) fn normalize_setup_script(script: Option) -> Option { + match script { + Some(value) if value.trim().is_empty() => None, + Some(value) => Some(value), + None => None, + } +} + +pub(crate) fn worktree_setup_marker_path(data_dir: &PathBuf, workspace_id: &str) -> PathBuf { + data_dir + .join(WORKTREE_SETUP_MARKERS_DIR) + .join(format!("{workspace_id}.{WORKTREE_SETUP_MARKER_EXT}")) +} + +pub(crate) fn is_workspace_path_dir_core(path: &str) -> bool { + PathBuf::from(path).is_dir() +} + +pub(crate) async fn list_workspaces_core( + workspaces: &Mutex>, + sessions: &Mutex>>, +) -> Vec { + let workspaces = workspaces.lock().await; + let sessions = sessions.lock().await; + let mut result = Vec::new(); + for entry in workspaces.values() { + result.push(WorkspaceInfo { + id: entry.id.clone(), + name: entry.name.clone(), + path: entry.path.clone(), + codex_bin: entry.codex_bin.clone(), + connected: sessions.contains_key(&entry.id), + kind: entry.kind.clone(), + parent_id: entry.parent_id.clone(), + worktree: entry.worktree.clone(), + settings: entry.settings.clone(), + }); + } + sort_workspaces(&mut result); + result +} + +pub(super) async fn resolve_entry_and_parent( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result<(WorkspaceEntry, Option), String> { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + let parent_entry = entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .cloned(); + Ok((entry, parent_entry)) +} + +pub(super) async fn resolve_workspace_root( + workspaces: &Mutex>, + workspace_id: &str, +) -> Result { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + Ok(PathBuf::from(entry.path)) +} + +pub(super) fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { + workspaces.sort_by(|a, b| { + let a_order = a.settings.sort_order.unwrap_or(u32::MAX); + let b_order = b.settings.sort_order.unwrap_or(u32::MAX); + if a_order != b_order { + return a_order.cmp(&b_order); + } + a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)) + }); +} + +#[cfg(test)] +mod tests { + use super::{copy_agents_md_from_parent_to_worktree, AGENTS_MD_FILE_NAME}; + use uuid::Uuid; + + fn make_temp_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("codex-monitor-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("failed to create temp dir"); + dir + } + + #[test] + fn copies_agents_md_when_missing_in_worktree() { + let parent = make_temp_dir(); + let worktree = make_temp_dir(); + let parent_agents = parent.join(AGENTS_MD_FILE_NAME); + let worktree_agents = worktree.join(AGENTS_MD_FILE_NAME); + + std::fs::write(&parent_agents, "parent").expect("failed to write parent AGENTS.md"); + + copy_agents_md_from_parent_to_worktree(&parent, &worktree).expect("copy should succeed"); + + let copied = std::fs::read_to_string(&worktree_agents) + .expect("worktree AGENTS.md should exist after copy"); + assert_eq!(copied, "parent"); + + let _ = std::fs::remove_dir_all(parent); + let _ = std::fs::remove_dir_all(worktree); + } + + #[test] + fn does_not_overwrite_existing_worktree_agents_md() { + let parent = make_temp_dir(); + let worktree = make_temp_dir(); + let parent_agents = parent.join(AGENTS_MD_FILE_NAME); + let worktree_agents = worktree.join(AGENTS_MD_FILE_NAME); + + std::fs::write(&parent_agents, "parent").expect("failed to write parent AGENTS.md"); + std::fs::write(&worktree_agents, "branch-specific") + .expect("failed to write worktree AGENTS.md"); + + copy_agents_md_from_parent_to_worktree(&parent, &worktree).expect("copy should succeed"); + + let retained = std::fs::read_to_string(&worktree_agents) + .expect("worktree AGENTS.md should still exist"); + assert_eq!(retained, "branch-specific"); + + let _ = std::fs::remove_dir_all(parent); + let _ = std::fs::remove_dir_all(worktree); + } +} diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs new file mode 100644 index 0000000000..4445007c81 --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; +#[cfg(target_os = "windows")] +use std::path::Path; +use std::path::PathBuf; + +use tokio::sync::Mutex; + +use crate::shared::process_core::tokio_command; +#[cfg(target_os = "windows")] +use crate::shared::process_core::{build_cmd_c_command, resolve_windows_executable}; +use crate::types::WorkspaceEntry; + +use super::helpers::resolve_workspace_root; + +pub(crate) async fn open_workspace_in_core( + path: String, + app: Option, + args: Vec, + command: Option, +) -> Result<(), String> { + fn output_snippet(bytes: &[u8]) -> Option { + const MAX_CHARS: usize = 240; + let text = String::from_utf8_lossy(bytes).trim().replace('\n', "\\n"); + if text.is_empty() { + return None; + } + let mut chars = text.chars(); + let snippet: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { + Some(format!("{snippet}...")) + } else { + Some(snippet) + } + } + + let target_label = command + .as_ref() + .map(|value| format!("command `{value}`")) + .or_else(|| app.as_ref().map(|value| format!("app `{value}`"))) + .unwrap_or_else(|| "target".to_string()); + + let output = if let Some(command) = command { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("Missing app or command".to_string()); + } + + #[cfg(target_os = "windows")] + let mut cmd = { + let resolved = resolve_windows_executable(trimmed, None); + let resolved_path = resolved.as_deref().unwrap_or_else(|| Path::new(trimmed)); + let ext = resolved_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + + if matches!(ext.as_deref(), Some("cmd") | Some("bat")) { + let mut cmd = tokio_command("cmd"); + let mut command_args = args.clone(); + command_args.push(path.clone()); + let command_line = build_cmd_c_command(resolved_path, &command_args)?; + cmd.arg("/D"); + cmd.arg("/S"); + cmd.arg("/C"); + cmd.raw_arg(command_line); + cmd + } else { + let mut cmd = tokio_command(resolved_path); + cmd.args(&args).arg(&path); + cmd + } + }; + + #[cfg(not(target_os = "windows"))] + let mut cmd = { + let mut cmd = tokio_command(trimmed); + cmd.args(&args).arg(&path); + cmd + }; + + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } else if let Some(app) = app { + let trimmed = app.trim(); + if trimmed.is_empty() { + return Err("Missing app or command".to_string()); + } + + #[cfg(target_os = "macos")] + let mut cmd = { + let mut cmd = tokio_command("open"); + cmd.arg("-a").arg(trimmed).arg(&path); + if !args.is_empty() { + cmd.arg("--args").args(&args); + } + cmd + }; + + #[cfg(not(target_os = "macos"))] + let mut cmd = { + let mut cmd = tokio_command(trimmed); + cmd.args(&args).arg(&path); + cmd + }; + + cmd.output() + .await + .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? + } else { + return Err("Missing app or command".to_string()); + }; + + if output.status.success() { + return Ok(()); + } + + let exit_detail = output + .status + .code() + .map(|code| format!("exit code {code}")) + .unwrap_or_else(|| "terminated by signal".to_string()); + let mut details = Vec::new(); + if let Some(stderr) = output_snippet(&output.stderr) { + details.push(format!("stderr: {stderr}")); + } + if let Some(stdout) = output_snippet(&output.stdout) { + details.push(format!("stdout: {stdout}")); + } + + if details.is_empty() { + Err(format!( + "Failed to open app ({target_label} returned {exit_detail})." + )) + } else { + Err(format!( + "Failed to open app ({target_label} returned {exit_detail}; {}).", + details.join("; ") + )) + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn get_open_app_icon_core( + app_name: String, + icon_loader: F, +) -> Result, String> +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + let trimmed = app_name.trim().to_string(); + if trimmed.is_empty() { + return Ok(None); + } + let icon_loader = std::sync::Arc::new(icon_loader); + tokio::task::spawn_blocking(move || icon_loader(&trimmed)) + .await + .map_err(|err| err.to_string()) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) async fn get_open_app_icon_core( + app_name: String, + icon_loader: F, +) -> Result, String> +where + F: Fn(&str) -> Option + Send + Sync + 'static, +{ + let _ = app_name; + let _ = icon_loader; + Ok(None) +} + +pub(crate) async fn list_workspace_files_core( + workspaces: &Mutex>, + workspace_id: &str, + list_files: F, +) -> Result, String> +where + F: Fn(&PathBuf) -> Vec, +{ + let root = resolve_workspace_root(workspaces, workspace_id).await?; + Ok(list_files(&root)) +} + +pub(crate) async fn read_workspace_file_core( + workspaces: &Mutex>, + workspace_id: &str, + path: &str, + read_file: F, +) -> Result +where + F: Fn(&PathBuf, &str) -> Result, +{ + let root = resolve_workspace_root(workspaces, workspace_id).await?; + read_file(&root, path) +} diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs new file mode 100644 index 0000000000..c954abb104 --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -0,0 +1,588 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::backend::app_server::WorkspaceSession; +use crate::codex::args::resolve_workspace_codex_args; +use crate::codex::home::resolve_workspace_codex_home; +use crate::storage::write_workspaces; +use crate::types::{ + AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, + WorktreeSetupStatus, +}; + +use super::connect::kill_session_by_id; +use super::helpers::{ + copy_agents_md_from_parent_to_worktree, normalize_setup_script, worktree_setup_marker_path, + AGENTS_MD_FILE_NAME, +}; + +pub(crate) async fn worktree_setup_status_core( + workspaces: &Mutex>, + workspace_id: &str, + data_dir: &PathBuf, +) -> Result { + let entry = { + let workspaces = workspaces.lock().await; + workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + + let script = normalize_setup_script(entry.settings.worktree_setup_script.clone()); + let marker_exists = if entry.kind.is_worktree() { + worktree_setup_marker_path(data_dir, &entry.id).exists() + } else { + false + }; + let should_run = entry.kind.is_worktree() && script.is_some() && !marker_exists; + + Ok(WorktreeSetupStatus { should_run, script }) +} + +pub(crate) async fn worktree_setup_mark_ran_core( + workspaces: &Mutex>, + workspace_id: &str, + data_dir: &PathBuf, +) -> Result<(), String> { + let entry = { + let workspaces = workspaces.lock().await; + workspaces + .get(workspace_id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())? + }; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let marker_path = worktree_setup_marker_path(data_dir, &entry.id); + if let Some(parent) = marker_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("Failed to prepare worktree marker directory: {err}"))?; + } + let ran_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + std::fs::write(&marker_path, format!("ran_at={ran_at}\n")) + .map_err(|err| format!("Failed to write worktree setup marker: {err}"))?; + Ok(()) +} + +pub(crate) async fn add_worktree_core< + FSpawn, + FutSpawn, + FSanitize, + FUniquePath, + FBranchExists, + FutBranchExists, + FFindRemoteTracking, + FutFindRemoteTracking, + FRunGit, + FutRunGit, +>( + parent_id: String, + branch: String, + name: Option, + copy_agents_md: bool, + data_dir: &PathBuf, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + sanitize_worktree_name: FSanitize, + unique_worktree_path: FUniquePath, + git_branch_exists: FBranchExists, + git_find_remote_tracking_branch: Option, + run_git_command: FRunGit, + spawn_session: FSpawn, +) -> Result +where + FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, + FutSpawn: Future, String>>, + FSanitize: Fn(&str) -> String, + FUniquePath: Fn(&PathBuf, &str) -> Result, + FBranchExists: Fn(&PathBuf, &str) -> FutBranchExists, + FutBranchExists: Future>, + FFindRemoteTracking: Fn(&PathBuf, &str) -> FutFindRemoteTracking, + FutFindRemoteTracking: Future, String>>, + FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, + FutRunGit: Future>, +{ + let branch = branch.trim().to_string(); + if branch.is_empty() { + return Err("Branch name is required.".to_string()); + } + let name = name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let parent_entry = { + let workspaces = workspaces.lock().await; + workspaces + .get(&parent_id) + .cloned() + .ok_or_else(|| "parent workspace not found".to_string())? + }; + + if parent_entry.kind.is_worktree() { + return Err("Cannot create a worktree from another worktree.".to_string()); + } + + let worktree_root = data_dir.join("worktrees").join(&parent_entry.id); + std::fs::create_dir_all(&worktree_root) + .map_err(|err| format!("Failed to create worktree directory: {err}"))?; + + let safe_name = sanitize_worktree_name(&branch); + let worktree_path = unique_worktree_path(&worktree_root, &safe_name)?; + let worktree_path_string = worktree_path.to_string_lossy().to_string(); + + let repo_path = PathBuf::from(&parent_entry.path); + let branch_exists = git_branch_exists(&repo_path, &branch).await?; + if branch_exists { + run_git_command( + &repo_path, + &["worktree", "add", &worktree_path_string, &branch], + ) + .await?; + } else if let Some(find_remote_tracking) = git_find_remote_tracking_branch { + if let Some(remote_ref) = find_remote_tracking(&repo_path, &branch).await? { + run_git_command( + &repo_path, + &[ + "worktree", + "add", + "-b", + &branch, + &worktree_path_string, + &remote_ref, + ], + ) + .await?; + } else { + run_git_command( + &repo_path, + &["worktree", "add", "-b", &branch, &worktree_path_string], + ) + .await?; + } + } else { + run_git_command( + &repo_path, + &["worktree", "add", "-b", &branch, &worktree_path_string], + ) + .await?; + } + + if copy_agents_md { + if let Err(error) = copy_agents_md_from_parent_to_worktree(&repo_path, &worktree_path) { + eprintln!( + "add_worktree: optional {} copy failed for {}: {}", + AGENTS_MD_FILE_NAME, + worktree_path.display(), + error + ); + } + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: name.clone().unwrap_or_else(|| branch.clone()), + path: worktree_path_string, + codex_bin: parent_entry.codex_bin.clone(), + kind: WorkspaceKind::Worktree, + parent_id: Some(parent_entry.id.clone()), + worktree: Some(WorktreeInfo { branch }), + settings: WorkspaceSettings { + worktree_setup_script: normalize_setup_script( + parent_entry.settings.worktree_setup_script.clone(), + ), + ..WorkspaceSettings::default() + }, + }; + + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry, Some(&parent_entry), Some(&settings)), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry, Some(&parent_entry)); + let session = spawn_session(entry.clone(), default_bin, codex_args, codex_home).await?; + + { + let mut workspaces = workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(storage_path, &list)?; + } + + sessions.lock().await.insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +pub(crate) async fn remove_worktree_core( + id: String, + workspaces: &Mutex>, + sessions: &Mutex>>, + storage_path: &PathBuf, + run_git_command: FRunGit, + is_missing_worktree_error: FIsMissing, + remove_dir_all: FRemoveDirAll, +) -> Result<(), String> +where + FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, + FutRunGit: Future>, + FIsMissing: Fn(&str) -> bool, + FRemoveDirAll: Fn(&PathBuf) -> Result<(), String>, +{ + let (entry, parent) = { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or_else(|| "worktree parent not found".to_string())?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or_else(|| "worktree parent not found".to_string())?; + (entry, parent) + }; + + let parent_path = PathBuf::from(&parent.path); + let entry_path = PathBuf::from(&entry.path); + kill_session_by_id(sessions, &entry.id).await; + + if entry_path.exists() { + if let Err(error) = run_git_command( + &parent_path, + &["worktree", "remove", "--force", &entry.path], + ) + .await + { + if is_missing_worktree_error(&error) { + if entry_path.exists() { + remove_dir_all(&entry_path)?; + } + } else { + return Err(error); + } + } + } + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + + { + let mut workspaces = workspaces.lock().await; + workspaces.remove(&entry.id); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(storage_path, &list)?; + } + + Ok(()) +} + +pub(crate) async fn rename_worktree_core< + FSpawn, + FutSpawn, + FResolveGitRoot, + FUniqueBranch, + FutUniqueBranch, + FSanitize, + FUniqueRenamePath, + FRunGit, + FutRunGit, +>( + id: String, + branch: String, + data_dir: &PathBuf, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + storage_path: &PathBuf, + resolve_git_root: FResolveGitRoot, + unique_branch_name: FUniqueBranch, + sanitize_worktree_name: FSanitize, + unique_worktree_path_for_rename: FUniqueRenamePath, + run_git_command: FRunGit, + spawn_session: FSpawn, +) -> Result +where + FSpawn: Fn(WorkspaceEntry, Option, Option, Option) -> FutSpawn, + FutSpawn: Future, String>>, + FResolveGitRoot: Fn(&WorkspaceEntry) -> Result, + FUniqueBranch: Fn(&PathBuf, &str) -> FutUniqueBranch, + FutUniqueBranch: Future>, + FSanitize: Fn(&str) -> String, + FUniqueRenamePath: Fn(&PathBuf, &str, &PathBuf) -> Result, + FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, + FutRunGit: Future>, +{ + let trimmed = branch.trim(); + if trimmed.is_empty() { + return Err("Branch name is required.".to_string()); + } + + let (entry, parent) = { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or_else(|| "worktree parent not found".to_string())?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or_else(|| "worktree parent not found".to_string())?; + (entry, parent) + }; + + let old_branch = entry + .worktree + .as_ref() + .map(|worktree| worktree.branch.clone()) + .ok_or_else(|| "worktree metadata missing".to_string())?; + if old_branch == trimmed { + return Err("Branch name is unchanged.".to_string()); + } + + let parent_root = resolve_git_root(&parent)?; + let final_branch = unique_branch_name(&parent_root, trimmed).await?; + if final_branch == old_branch { + return Err("Branch name is unchanged.".to_string()); + } + + run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?; + + let worktree_root = data_dir.join("worktrees").join(&parent.id); + std::fs::create_dir_all(&worktree_root) + .map_err(|err| format!("Failed to create worktree directory: {err}"))?; + + let safe_name = sanitize_worktree_name(&final_branch); + let current_path = PathBuf::from(&entry.path); + let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_path)?; + let next_path_string = next_path.to_string_lossy().to_string(); + if next_path_string != entry.path { + if let Err(error) = run_git_command( + &parent_root, + &["worktree", "move", &entry.path, &next_path_string], + ) + .await + { + let _ = + run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await; + return Err(error); + } + } + + let (entry_snapshot, list) = { + let mut workspaces = workspaces.lock().await; + let entry = match workspaces.get_mut(&id) { + Some(entry) => entry, + None => return Err("workspace not found".to_string()), + }; + if entry.name.trim() == old_branch { + entry.name = final_branch.clone(); + } + entry.path = next_path_string.clone(); + match entry.worktree.as_mut() { + Some(worktree) => { + worktree.branch = final_branch.clone(); + } + None => { + entry.worktree = Some(WorktreeInfo { + branch: final_branch.clone(), + }); + } + } + let snapshot = entry.clone(); + let list: Vec<_> = workspaces.values().cloned().collect(); + (snapshot, list) + }; + write_workspaces(storage_path, &list)?; + + let was_connected = sessions.lock().await.contains_key(&entry_snapshot.id); + if was_connected { + kill_session_by_id(sessions, &entry_snapshot.id).await; + let (default_bin, codex_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry_snapshot, Some(&parent), Some(&settings)), + ) + }; + let codex_home = resolve_workspace_codex_home(&entry_snapshot, Some(&parent)); + match spawn_session(entry_snapshot.clone(), default_bin, codex_args, codex_home).await { + Ok(session) => { + sessions + .lock() + .await + .insert(entry_snapshot.id.clone(), session); + } + Err(error) => { + eprintln!( + "rename_worktree: respawn failed for {} after rename: {error}", + entry_snapshot.id + ); + } + } + } + + let connected = sessions.lock().await.contains_key(&entry_snapshot.id); + Ok(WorkspaceInfo { + id: entry_snapshot.id, + name: entry_snapshot.name, + path: entry_snapshot.path, + codex_bin: entry_snapshot.codex_bin, + connected, + kind: entry_snapshot.kind, + parent_id: entry_snapshot.parent_id, + worktree: entry_snapshot.worktree, + settings: entry_snapshot.settings, + }) +} + +pub(crate) async fn rename_worktree_upstream_core< + FResolveGitRoot, + FBranchExists, + FutBranchExists, + FFindRemote, + FutFindRemote, + FRemoteExists, + FutRemoteExists, + FRemoteBranchExists, + FutRemoteBranchExists, + FRunGit, + FutRunGit, +>( + id: String, + old_branch: String, + new_branch: String, + workspaces: &Mutex>, + resolve_git_root: FResolveGitRoot, + git_branch_exists: FBranchExists, + git_find_remote_for_branch: FFindRemote, + git_remote_exists: FRemoteExists, + git_remote_branch_exists: FRemoteBranchExists, + run_git_command: FRunGit, +) -> Result<(), String> +where + FResolveGitRoot: Fn(&WorkspaceEntry) -> Result, + FBranchExists: Fn(&PathBuf, &str) -> FutBranchExists, + FutBranchExists: Future>, + FFindRemote: Fn(&PathBuf, &str) -> FutFindRemote, + FutFindRemote: Future, String>>, + FRemoteExists: Fn(&PathBuf, &str) -> FutRemoteExists, + FutRemoteExists: Future>, + FRemoteBranchExists: Fn(&PathBuf, &str, &str) -> FutRemoteBranchExists, + FutRemoteBranchExists: Future>, + FRunGit: Fn(&PathBuf, &[&str]) -> FutRunGit, + FutRunGit: Future>, +{ + let old_branch = old_branch.trim().to_string(); + let new_branch = new_branch.trim().to_string(); + if old_branch.is_empty() || new_branch.is_empty() { + return Err("Branch name is required.".to_string()); + } + if old_branch == new_branch { + return Err("Branch name is unchanged.".to_string()); + } + + let (_entry, parent) = { + let workspaces = workspaces.lock().await; + let entry = workspaces + .get(&id) + .cloned() + .ok_or_else(|| "workspace not found".to_string())?; + if !entry.kind.is_worktree() { + return Err("Not a worktree workspace.".to_string()); + } + let parent_id = entry + .parent_id + .clone() + .ok_or_else(|| "worktree parent not found".to_string())?; + let parent = workspaces + .get(&parent_id) + .cloned() + .ok_or_else(|| "worktree parent not found".to_string())?; + (entry, parent) + }; + + let parent_root = resolve_git_root(&parent)?; + if !git_branch_exists(&parent_root, &new_branch).await? { + return Err("Local branch not found.".to_string()); + } + + let remote_for_old = git_find_remote_for_branch(&parent_root, &old_branch).await?; + let remote_name = match remote_for_old.as_ref() { + Some(remote) => remote.clone(), + None => { + if git_remote_exists(&parent_root, "origin").await? { + "origin".to_string() + } else { + return Err("No git remote configured for this worktree.".to_string()); + } + } + }; + + if git_remote_branch_exists(&parent_root, &remote_name, &new_branch).await? { + return Err("Remote branch already exists.".to_string()); + } + + if remote_for_old.is_some() { + run_git_command( + &parent_root, + &["push", &remote_name, &format!("{new_branch}:{new_branch}")], + ) + .await?; + run_git_command( + &parent_root, + &["push", &remote_name, &format!(":{old_branch}")], + ) + .await?; + } else { + run_git_command(&parent_root, &["push", &remote_name, &new_branch]).await?; + } + + run_git_command( + &parent_root, + &[ + "branch", + "--set-upstream-to", + &format!("{remote_name}/{new_branch}"), + &new_branch, + ], + ) + .await?; + + Ok(()) +} diff --git a/src-tauri/src/shared/worktree_core.rs b/src-tauri/src/shared/worktree_core.rs index 0de075c128..ad0b7b6c8c 100644 --- a/src-tauri/src/shared/worktree_core.rs +++ b/src-tauri/src/shared/worktree_core.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::path::PathBuf; fn sanitize_name(value: &str, fallback: &str) -> String { @@ -42,6 +40,8 @@ pub(crate) fn unique_worktree_path_best_effort(base_dir: &PathBuf, name: &str) - candidate } +// Used by daemon-only worktree creation paths. +#[allow(dead_code)] pub(crate) fn unique_worktree_path_strict( base_dir: &PathBuf, name: &str, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8d00abaae0..beee510503 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,6 +16,7 @@ use crate::types::{ pub(crate) struct OrbitRunnerRuntime { pub(crate) child: Option, pub(crate) status: OrbitRunnerStatus, + pub(crate) managed_version: Option, } impl Default for OrbitRunnerRuntime { @@ -29,6 +30,7 @@ impl Default for OrbitRunnerRuntime { last_error: None, orbit_url: None, }, + managed_version: None, } } } diff --git a/src-tauri/src/tailscale/daemon_commands.rs b/src-tauri/src/tailscale/daemon_commands.rs index b81cb8c70a..33f208954d 100644 --- a/src-tauri/src/tailscale/daemon_commands.rs +++ b/src-tauri/src/tailscale/daemon_commands.rs @@ -1,8 +1,58 @@ use super::rpc_client::{ - probe_daemon, request_daemon_shutdown, wait_for_daemon_shutdown, DaemonProbe, + probe_daemon, request_daemon_shutdown, wait_for_daemon_shutdown, DaemonInfo, DaemonProbe, }; use super::*; +const EXPECTED_DAEMON_NAME: &str = "codex-monitor-daemon"; +const EXPECTED_DAEMON_MODE: &str = "tcp"; +const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn is_managed_daemon(info: &DaemonInfo) -> bool { + info.name == EXPECTED_DAEMON_NAME +} + +fn can_force_stop_daemon(auth_ok: bool, info: Option<&DaemonInfo>) -> bool { + auth_ok && info.is_some_and(is_managed_daemon) +} + +fn should_restart_daemon(info: Option<&DaemonInfo>) -> bool { + let Some(info) = info else { + return true; + }; + !is_managed_daemon(info) + || info.version != CURRENT_APP_VERSION + || info.mode != EXPECTED_DAEMON_MODE +} + +fn daemon_restart_reason(info: Option<&DaemonInfo>) -> String { + let Some(info) = info else { + return "Daemon is running but did not report identity/version metadata".to_string(); + }; + if !is_managed_daemon(info) { + return format!("Daemon identity mismatch (`{}`)", info.name); + } + if info.version != CURRENT_APP_VERSION { + return format!( + "Daemon version {} is different from app version {}", + info.version, CURRENT_APP_VERSION + ); + } + if info.mode != EXPECTED_DAEMON_MODE { + return format!( + "Daemon mode `{}` does not match expected `{}`", + info.mode, EXPECTED_DAEMON_MODE + ); + } + "Daemon restart required".to_string() +} + +async fn resolve_daemon_pid(listen_port: u16, info: Option<&DaemonInfo>) -> Option { + match info.and_then(|entry| entry.pid) { + Some(pid) => Some(pid), + None => find_listener_pid(listen_port).await, + } +} + pub(super) async fn tailscale_daemon_command_preview( state: State<'_, AppState>, ) -> Result { @@ -61,16 +111,21 @@ pub(super) async fn tailscale_daemon_start( let mut runtime = state.tcp_daemon.lock().await; refresh_tcp_daemon_runtime(&mut runtime).await; - if matches!(runtime.status.state, TcpDaemonState::Running) { - return Ok(runtime.status.clone()); - } match probe_daemon(&listen_addr, Some(token)).await { DaemonProbe::Running { auth_ok, auth_error, + info, } => { - let pid = find_listener_pid(listen_port).await; + let pid = resolve_daemon_pid(listen_port, info.as_ref()).await; + let restart_required = should_restart_daemon(info.as_ref()); + let restart_reason = if restart_required { + Some(daemon_restart_reason(info.as_ref())) + } else { + None + }; + runtime.child = None; runtime.status = TcpDaemonStatus { state: TcpDaemonState::Running, @@ -84,7 +139,68 @@ pub(super) async fn tailscale_daemon_start( "Daemon is already running but authentication failed.".to_string() })); } - return Ok(runtime.status.clone()); + if !restart_required { + return Ok(runtime.status.clone()); + } + + let force_kill_allowed = can_force_stop_daemon(auth_ok, info.as_ref()); + let pid_for_control = pid; + if let Err(shutdown_error) = request_daemon_shutdown(&listen_addr, Some(token)).await { + if !force_kill_allowed { + return Err(format!( + "{}; automatic restart aborted because daemon ownership could not be verified: {}", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()), + shutdown_error + )); + } + if let Some(pid) = pid_for_control { + kill_pid_gracefully(pid).await.map_err(|err| { + format!( + "{}; graceful shutdown failed ({shutdown_error}) and forced stop failed: {err}", + restart_reason + .clone() + .unwrap_or_else(|| "Daemon restart required".to_string()) + ) + })?; + } else { + return Err(format!( + "{}; daemon did not stop and no PID could be resolved for safe forced stop ({shutdown_error})", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + } + + if !wait_for_daemon_shutdown(&listen_addr, Some(token)).await { + if !force_kill_allowed { + return Err(format!( + "{}; daemon acknowledged shutdown but is still reachable", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + if let Some(pid) = resolve_daemon_pid(listen_port, info.as_ref()).await { + kill_pid_gracefully(pid).await.map_err(|err| { + format!( + "{}; daemon remained reachable and forced stop failed: {err}", + restart_reason + .clone() + .unwrap_or_else(|| "Daemon restart required".to_string()) + ) + })?; + } else { + return Err(format!( + "{}; daemon remained reachable and no PID could be resolved for safe forced stop", + restart_reason.unwrap_or_else(|| "Daemon restart required".to_string()) + )); + } + } + + runtime.status = TcpDaemonStatus { + state: TcpDaemonState::Stopped, + pid: None, + started_at_ms: None, + last_error: None, + listen_addr: Some(listen_addr.clone()), + }; } DaemonProbe::NotDaemon => { return Err(format!( @@ -140,19 +256,26 @@ pub(super) async fn tailscale_daemon_stop( ) .await { - DaemonProbe::Running { .. } => { + DaemonProbe::Running { auth_ok, info, .. } => { + let force_kill_allowed = can_force_stop_daemon(auth_ok, info.as_ref()); if let Err(shutdown_error) = request_daemon_shutdown( &configured_listen_addr, settings.remote_backend_token.as_deref(), ) .await { - let pid = find_listener_pid(port).await; + let pid = resolve_daemon_pid(port, info.as_ref()).await; if let Some(pid) = pid { - if let Err(err) = kill_pid_gracefully(pid).await { - stop_error = Some(format!("{shutdown_error}; {err}")); + if force_kill_allowed { + if let Err(err) = kill_pid_gracefully(pid).await { + stop_error = Some(format!("{shutdown_error}; {err}")); + } else { + stop_error = None; + } } else { - stop_error = None; + stop_error = Some(format!( + "{shutdown_error}; refusing forced stop because daemon ownership could not be verified" + )); } } else { stop_error = Some(shutdown_error); @@ -163,8 +286,28 @@ pub(super) async fn tailscale_daemon_stop( ) .await { - stop_error = - Some("Daemon acknowledged shutdown but is still reachable.".to_string()); + if force_kill_allowed { + let pid = resolve_daemon_pid(port, info.as_ref()).await; + if let Some(pid) = pid { + if let Err(err) = kill_pid_gracefully(pid).await { + stop_error = Some(format!( + "Daemon acknowledged shutdown but remained reachable; {err}" + )); + } else { + stop_error = None; + } + } else { + stop_error = Some( + "Daemon acknowledged shutdown but remained reachable and PID could not be resolved." + .to_string(), + ); + } + } else { + stop_error = Some( + "Daemon acknowledged shutdown but is still reachable; refusing forced stop because daemon ownership could not be verified." + .to_string(), + ); + } } } DaemonProbe::NotDaemon => { @@ -243,6 +386,7 @@ pub(super) async fn tailscale_daemon_status( DaemonProbe::Running { auth_ok: _, auth_error, + info: _, } => TcpDaemonStatus { state: TcpDaemonState::Running, pid, @@ -273,3 +417,42 @@ pub(super) async fn tailscale_daemon_status( Ok(runtime.status.clone()) } + +#[cfg(test)] +mod tests { + use super::{ + can_force_stop_daemon, should_restart_daemon, DaemonInfo, CURRENT_APP_VERSION, + EXPECTED_DAEMON_MODE, EXPECTED_DAEMON_NAME, + }; + + fn daemon_info(version: &str) -> DaemonInfo { + DaemonInfo { + name: EXPECTED_DAEMON_NAME.to_string(), + version: version.to_string(), + pid: Some(42), + mode: EXPECTED_DAEMON_MODE.to_string(), + binary_path: Some("/tmp/codex-monitor-daemon".to_string()), + } + } + + #[test] + fn restart_required_for_old_version() { + let info = daemon_info("0.1.0"); + assert!(should_restart_daemon(Some(&info))); + } + + #[test] + fn no_restart_for_same_version_and_mode() { + let info = daemon_info(CURRENT_APP_VERSION); + assert!(!should_restart_daemon(Some(&info))); + } + + #[test] + fn force_stop_requires_verified_daemon_identity() { + let mut info = daemon_info(CURRENT_APP_VERSION); + info.name = "unknown-daemon".to_string(); + assert!(!can_force_stop_daemon(true, Some(&info))); + assert!(!can_force_stop_daemon(false, Some(&info))); + assert!(!can_force_stop_daemon(true, None)); + } +} diff --git a/src-tauri/src/tailscale/mod.rs b/src-tauri/src/tailscale/mod.rs index 766f9e95d6..7324b37460 100644 --- a/src-tauri/src/tailscale/mod.rs +++ b/src-tauri/src/tailscale/mod.rs @@ -26,6 +26,19 @@ use self::core as tailscale_core; #[cfg(any(target_os = "android", target_os = "ios"))] const UNSUPPORTED_MESSAGE: &str = "Tailscale integration is only available on desktop."; +#[cfg(target_os = "macos")] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + let mut command = tokio_command("/bin/launchctl"); + let uid = unsafe { libc::geteuid() }; + command.arg("asuser").arg(uid.to_string()).arg(binary); + command +} + +#[cfg(not(target_os = "macos"))] +fn tailscale_command(binary: &OsStr) -> tokio::process::Command { + tokio_command(binary) +} + fn trim_to_non_empty(value: Option<&str>) -> Option { value .map(str::trim) @@ -79,9 +92,28 @@ fn missing_tailscale_message() -> String { async fn resolve_tailscale_binary() -> Result, String> { let mut failures: Vec = Vec::new(); for binary in tailscale_binary_candidates() { - let output = tokio_command(&binary).arg("version").output().await; + let output = tailscale_command(binary.as_os_str()) + .arg("version") + .output() + .await; match output { - Ok(version_output) => return Ok(Some((binary, version_output))), + Ok(version_output) => { + if version_output.status.success() { + return Ok(Some((binary, version_output))); + } + let stdout = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()); + let stderr = trim_to_non_empty(std::str::from_utf8(&version_output.stderr).ok()); + let detail = match (stdout, stderr) { + (Some(out), Some(err)) => format!("stdout: {out}; stderr: {err}"), + (Some(out), None) => format!("stdout: {out}"), + (None, Some(err)) => format!("stderr: {err}"), + (None, None) => "no output".to_string(), + }; + failures.push(format!( + "{}: tailscale version failed ({detail})", + OsStr::new(&binary).to_string_lossy() + )); + } Err(err) if err.kind() == ErrorKind::NotFound => continue, Err(err) => failures.push(format!("{}: {err}", OsStr::new(&binary).to_string_lossy())), } @@ -311,7 +343,7 @@ pub(crate) async fn tailscale_status() -> Result { let version = trim_to_non_empty(std::str::from_utf8(&version_output.stdout).ok()) .and_then(|raw| raw.lines().next().map(str::trim).map(str::to_string)); - let status_output = tokio_command(&tailscale_binary) + let status_output = tailscale_command(tailscale_binary.as_os_str()) .arg("status") .arg("--json") .output() @@ -337,7 +369,41 @@ pub(crate) async fn tailscale_status() -> Result { let payload = std::str::from_utf8(&status_output.stdout) .map_err(|err| format!("Invalid UTF-8 from tailscale status: {err}"))?; - tailscale_core::status_from_json(version, payload) + let stderr_text = trim_to_non_empty(std::str::from_utf8(&status_output.stderr).ok()); + if payload.trim().is_empty() { + let suffix = stderr_text + .as_deref() + .map(|value| format!(" stderr: {value}")) + .unwrap_or_default(); + return Err(format!( + "tailscale status --json returned empty output.{suffix}" + )); + } + match tailscale_core::status_from_json(version, payload) { + Ok(status) => Ok(status), + Err(err) => { + let trimmed_payload = payload.trim(); + let payload_preview = if trimmed_payload.is_empty() { + None + } else if trimmed_payload.len() > 200 { + Some(format!("{}…", &trimmed_payload[..200])) + } else { + Some(trimmed_payload.to_string()) + }; + let mut details = Vec::new(); + if let Some(stderr) = stderr_text { + details.push(format!("stderr: {stderr}")); + } + if let Some(preview) = payload_preview { + details.push(format!("stdout: {preview}")); + } + if details.is_empty() { + Err(err) + } else { + Err(format!("{err} ({})", details.join("; "))) + } + } + } } #[cfg(test)] diff --git a/src-tauri/src/tailscale/rpc_client.rs b/src-tauri/src/tailscale/rpc_client.rs index 0f48b024d8..88965d7d4e 100644 --- a/src-tauri/src/tailscale/rpc_client.rs +++ b/src-tauri/src/tailscale/rpc_client.rs @@ -2,12 +2,22 @@ use super::*; const DAEMON_RPC_TIMEOUT: Duration = Duration::from_millis(700); +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DaemonInfo { + pub(super) name: String, + pub(super) version: String, + pub(super) pid: Option, + pub(super) mode: String, + pub(super) binary_path: Option, +} + #[derive(Debug, Clone)] pub(super) enum DaemonProbe { NotReachable, Running { auth_ok: bool, auth_error: Option, + info: Option, }, NotDaemon, } @@ -27,6 +37,48 @@ fn is_auth_error_message(message: &str) -> bool { lower.contains("unauthorized") || lower.contains("invalid token") } +fn parse_daemon_info(value: &Value) -> Result { + let name = value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `name`".to_string())? + .to_string(); + let version = value + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `version`".to_string())? + .to_string(); + let mode = value + .get("mode") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "daemon_info missing `mode`".to_string())? + .to_string(); + let pid = value + .get("pid") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()); + let binary_path = value + .get("binaryPath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + Ok(DaemonInfo { + name, + version, + pid, + mode, + binary_path, + }) +} + async fn send_rpc_request( writer: &mut OwnedWriteHalf, id: u64, @@ -90,6 +142,15 @@ async fn send_and_expect_result( .ok_or_else(|| "daemon response missing result".to_string()) } +async fn request_daemon_info( + writer: &mut OwnedWriteHalf, + lines: &mut DaemonLines, + id: u64, +) -> Result { + let result = send_and_expect_result(writer, lines, id, "daemon_info", json!({})).await?; + parse_daemon_info(&result) +} + pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> DaemonProbe { let Some(connect_addr) = daemon_connect_addr(listen_addr) else { return DaemonProbe::NotReachable; @@ -107,6 +168,7 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem Ok(_) => DaemonProbe::Running { auth_ok: true, auth_error: None, + info: request_daemon_info(&mut writer, &mut lines, 2).await.ok(), }, Err(message) => { if !is_auth_error_message(&message) { @@ -120,31 +182,34 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem auth_error: Some( "Daemon is running but requires a remote backend token.".to_string(), ), + info: None, }; }; match send_and_expect_result( &mut writer, &mut lines, - 2, + 10, "auth", json!({ "token": auth_token }), ) .await { Ok(_) => { - match send_and_expect_result(&mut writer, &mut lines, 3, "ping", json!({})) + match send_and_expect_result(&mut writer, &mut lines, 11, "ping", json!({})) .await { Ok(_) => DaemonProbe::Running { auth_ok: true, auth_error: None, + info: request_daemon_info(&mut writer, &mut lines, 12).await.ok(), }, Err(ping_error) => DaemonProbe::Running { auth_ok: false, auth_error: Some(format!( "Daemon is running but ping failed after auth: {ping_error}" )), + info: None, }, } } @@ -155,6 +220,7 @@ pub(super) async fn probe_daemon(listen_addr: &str, token: Option<&str>) -> Daem auth_error: Some(format!( "Daemon is running but token authentication failed: {auth_error}" )), + info: None, } } else { DaemonProbe::NotDaemon diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index 162c8bb763..53c94300be 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use serde::Serialize; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager, State}; use tokio::sync::Mutex; use crate::backend::events::{EventSink, TerminalExit, TerminalOutput}; @@ -66,6 +66,8 @@ fn resolve_locale() -> String { fn spawn_terminal_reader( event_sink: impl EventSink, + app: AppHandle, + session: Arc, workspace_id: String, terminal_id: String, mut reader: Box, @@ -125,10 +127,24 @@ fn spawn_terminal_reader( Err(_) => break, } } + let cleanup_workspace_id = workspace_id.clone(); + let cleanup_terminal_id = terminal_id.clone(); + let cleanup_session = Arc::clone(&session); event_sink.emit_terminal_exit(TerminalExit { workspace_id, terminal_id, }); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + let mut sessions = state.terminal_sessions.lock().await; + let key = terminal_key(&cleanup_workspace_id, &cleanup_terminal_id); + let should_remove = sessions + .get(&key) + .is_some_and(|current| Arc::ptr_eq(current, &cleanup_session)); + if should_remove { + sessions.remove(&key); + } + }); }); } @@ -219,10 +235,17 @@ pub(crate) async fn terminal_open( .await; return Ok(TerminalSessionInfo { id }); } - sessions.insert(key, session); + sessions.insert(key, Arc::clone(&session)); } - let event_sink = TauriEventSink::new(app); - spawn_terminal_reader(event_sink, workspace_id, terminal_id, reader); + let event_sink = TauriEventSink::new(app.clone()); + spawn_terminal_reader( + event_sink, + app, + Arc::clone(&session), + workspace_id, + terminal_id, + reader, + ); Ok(TerminalSessionInfo { id: session_id }) } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5c40336598..96d0088ea5 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -461,6 +461,8 @@ pub(crate) struct AppSettings { pub(crate) orbit_runner_name: Option, #[serde(default, rename = "orbitAutoStartRunner")] pub(crate) orbit_auto_start_runner: bool, + #[serde(default, rename = "keepDaemonRunningAfterAppClose")] + pub(crate) keep_daemon_running_after_app_close: bool, #[serde(default, rename = "orbitUseAccess")] pub(crate) orbit_use_access: bool, #[serde(default, rename = "orbitAccessClientId")] @@ -571,6 +573,8 @@ pub(crate) struct AppSettings { rename = "showMessageFilePath" )] pub(crate) show_message_file_path: bool, + #[serde(default, rename = "threadTitleAutogenerationEnabled")] + pub(crate) thread_title_autogeneration_enabled: bool, #[serde(default = "default_ui_font_family", rename = "uiFontFamily")] pub(crate) ui_font_family: String, #[serde(default = "default_code_font_family", rename = "codeFontFamily")] @@ -582,6 +586,11 @@ pub(crate) struct AppSettings { rename = "notificationSoundsEnabled" )] pub(crate) notification_sounds_enabled: bool, + #[serde( + default = "default_split_chat_diff_view", + rename = "splitChatDiffView" + )] + pub(crate) split_chat_diff_view: bool, #[serde(default = "default_preload_git_diffs", rename = "preloadGitDiffs")] pub(crate) preload_git_diffs: bool, #[serde( @@ -589,6 +598,11 @@ pub(crate) struct AppSettings { rename = "gitDiffIgnoreWhitespaceChanges" )] pub(crate) git_diff_ignore_whitespace_changes: bool, + #[serde( + default = "default_commit_message_prompt", + rename = "commitMessagePrompt" + )] + pub(crate) commit_message_prompt: String, #[serde( default = "default_system_notifications_enabled", rename = "systemNotificationsEnabled" @@ -914,6 +928,10 @@ fn default_system_notifications_enabled() -> bool { true } +fn default_split_chat_diff_view() -> bool { + false +} + fn default_preload_git_diffs() -> bool { true } @@ -922,6 +940,15 @@ fn default_git_diff_ignore_whitespace_changes() -> bool { false } +fn default_commit_message_prompt() -> String { + "Generate a concise git commit message for the following changes. \ +Follow conventional commit format (e.g., feat:, fix:, refactor:, docs:, etc.). \ +Keep the summary line under 72 characters. \ +Only output the commit message, nothing else.\n\n\ +Changes:\n{diff}" + .to_string() +} + fn default_experimental_collab_enabled() -> bool { false } @@ -1131,6 +1158,7 @@ impl Default for AppSettings { orbit_auth_url: None, orbit_runner_name: None, orbit_auto_start_runner: false, + keep_daemon_running_after_app_close: false, orbit_use_access: false, orbit_access_client_id: None, orbit_access_client_secret_ref: None, @@ -1159,13 +1187,16 @@ impl Default for AppSettings { theme: default_theme(), usage_show_remaining: default_usage_show_remaining(), show_message_file_path: default_show_message_file_path(), + thread_title_autogeneration_enabled: false, ui_font_family: default_ui_font_family(), code_font_family: default_code_font_family(), code_font_size: default_code_font_size(), notification_sounds_enabled: true, system_notifications_enabled: true, + split_chat_diff_view: default_split_chat_diff_view(), preload_git_diffs: default_preload_git_diffs(), git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), + commit_message_prompt: default_commit_message_prompt(), experimental_collab_enabled: false, collaboration_modes_enabled: true, steer_enabled: true, @@ -1224,6 +1255,7 @@ mod tests { assert!(settings.orbit_auth_url.is_none()); assert!(settings.orbit_runner_name.is_none()); assert!(!settings.orbit_auto_start_runner); + assert!(!settings.keep_daemon_running_after_app_close); assert!(!settings.orbit_use_access); assert!(settings.orbit_access_client_id.is_none()); assert!(settings.orbit_access_client_secret_ref.is_none()); @@ -1318,13 +1350,16 @@ mod tests { assert_eq!(settings.theme, "system"); assert!(!settings.usage_show_remaining); assert!(settings.show_message_file_path); + assert!(!settings.thread_title_autogeneration_enabled); assert!(settings.ui_font_family.contains("system-ui")); assert!(settings.code_font_family.contains("ui-monospace")); assert_eq!(settings.code_font_size, 11); assert!(settings.notification_sounds_enabled); assert!(settings.system_notifications_enabled); + assert!(!settings.split_chat_diff_view); assert!(settings.preload_git_diffs); assert!(!settings.git_diff_ignore_whitespace_changes); + assert!(settings.commit_message_prompt.contains("{diff}")); assert!(settings.collaboration_modes_enabled); assert!(settings.steer_enabled); assert!(settings.unified_exec_enabled); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 649b33a033..6c9ff7fa7b 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -2,7 +2,6 @@ use std::env; use std::ffi::OsString; use std::path::PathBuf; -#[allow(dead_code)] pub(crate) fn normalize_git_path(path: &str) -> String { path.replace('\\', "/") } diff --git a/src-tauri/src/workspaces/git.rs b/src-tauri/src/workspaces/git.rs index 0bbe307357..cc1671c513 100644 --- a/src-tauri/src/workspaces/git.rs +++ b/src-tauri/src/workspaces/git.rs @@ -29,11 +29,6 @@ pub(crate) async fn git_remote_branch_exists( git_core::git_remote_branch_exists_live(repo_path, remote, branch).await } -#[allow(dead_code)] -pub(crate) async fn git_list_remotes(repo_path: &PathBuf) -> Result, String> { - git_core::git_list_remotes(repo_path).await -} - pub(crate) async fn git_find_remote_for_branch( repo_path: &PathBuf, branch: &str, diff --git a/src-tauri/src/workspaces/worktree.rs b/src-tauri/src/workspaces/worktree.rs index afe8d26a45..4e5433f397 100644 --- a/src-tauri/src/workspaces/worktree.rs +++ b/src-tauri/src/workspaces/worktree.rs @@ -6,7 +6,7 @@ pub(crate) fn sanitize_worktree_name(branch: &str) -> String { worktree_core::sanitize_worktree_name(branch) } -#[allow(dead_code)] +#[cfg(test)] pub(crate) fn sanitize_clone_dir_name(name: &str) -> String { worktree_core::sanitize_clone_dir_name(name) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f7f7d9acc1..5438d36df2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Codex Monitor", - "version": "0.7.47", + "version": "0.7.51", "identifier": "com.dimillian.codexmonitor", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000000..8306c9b23e --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "com.dimillian.codexmonitor.ios", + "bundle": { + "iOS": { + "developmentTeam": "Z6P74P6T99" + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 91c85ce4d2..36afda81eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,114 +37,118 @@ import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; import "./styles/compact-tablet.css"; -import successSoundUrl from "./assets/success-notification.mp3"; -import errorSoundUrl from "./assets/error-notification.mp3"; -import { AppLayout } from "./features/app/components/AppLayout"; -import { AppModals } from "./features/app/components/AppModals"; -import { MainHeaderActions } from "./features/app/components/MainHeaderActions"; -import { useLayoutNodes } from "./features/layout/hooks/useLayoutNodes"; -import { useWorkspaceDropZone } from "./features/workspaces/hooks/useWorkspaceDropZone"; -import { useThreads } from "./features/threads/hooks/useThreads"; -import { useWindowDrag } from "./features/layout/hooks/useWindowDrag"; -import { useGitPanelController } from "./features/app/hooks/useGitPanelController"; -import { useGitRemote } from "./features/git/hooks/useGitRemote"; -import { useGitRepoScan } from "./features/git/hooks/useGitRepoScan"; -import { usePullRequestComposer } from "./features/git/hooks/usePullRequestComposer"; -import { useGitActions } from "./features/git/hooks/useGitActions"; -import { useAutoExitEmptyDiff } from "./features/git/hooks/useAutoExitEmptyDiff"; -import { useModels } from "./features/models/hooks/useModels"; -import { useCollaborationModes } from "./features/collaboration/hooks/useCollaborationModes"; -import { useCollaborationModeSelection } from "./features/collaboration/hooks/useCollaborationModeSelection"; -import { useSkills } from "./features/skills/hooks/useSkills"; -import { useApps } from "./features/apps/hooks/useApps"; -import { useCustomPrompts } from "./features/prompts/hooks/useCustomPrompts"; -import { useWorkspaceFileListing } from "./features/app/hooks/useWorkspaceFileListing"; -import { useGitBranches } from "./features/git/hooks/useGitBranches"; -import { useBranchSwitcher } from "./features/git/hooks/useBranchSwitcher"; -import { useBranchSwitcherShortcut } from "./features/git/hooks/useBranchSwitcherShortcut"; -import { useDebugLog } from "./features/debug/hooks/useDebugLog"; -import { useWorkspaceRefreshOnFocus } from "./features/workspaces/hooks/useWorkspaceRefreshOnFocus"; -import { useWorkspaceRestore } from "./features/workspaces/hooks/useWorkspaceRestore"; -import { useRenameWorktreePrompt } from "./features/workspaces/hooks/useRenameWorktreePrompt"; -import { useLayoutController } from "./features/app/hooks/useLayoutController"; -import { useWindowLabel } from "./features/layout/hooks/useWindowLabel"; +import successSoundUrl from "@/assets/success-notification.mp3"; +import errorSoundUrl from "@/assets/error-notification.mp3"; +import { AppLayout } from "@app/components/AppLayout"; +import { AppModals } from "@app/components/AppModals"; +import { MainHeaderActions } from "@app/components/MainHeaderActions"; +import { useLayoutNodes } from "@/features/layout/hooks/useLayoutNodes"; +import { useWorkspaceDropZone } from "@/features/workspaces/hooks/useWorkspaceDropZone"; +import { useThreads } from "@threads/hooks/useThreads"; +import { useWindowDrag } from "@/features/layout/hooks/useWindowDrag"; +import { useGitPanelController } from "@app/hooks/useGitPanelController"; +import { useGitRemote } from "@/features/git/hooks/useGitRemote"; +import { useGitRepoScan } from "@/features/git/hooks/useGitRepoScan"; +import { usePullRequestComposer } from "@/features/git/hooks/usePullRequestComposer"; +import { usePullRequestReviewActions } from "@/features/git/hooks/usePullRequestReviewActions"; +import { useGitActions } from "@/features/git/hooks/useGitActions"; +import { useAutoExitEmptyDiff } from "@/features/git/hooks/useAutoExitEmptyDiff"; +import { useModels } from "@/features/models/hooks/useModels"; +import { useCollaborationModes } from "@/features/collaboration/hooks/useCollaborationModes"; +import { useCollaborationModeSelection } from "@/features/collaboration/hooks/useCollaborationModeSelection"; +import { useSkills } from "@/features/skills/hooks/useSkills"; +import { useApps } from "@/features/apps/hooks/useApps"; +import { useCustomPrompts } from "@/features/prompts/hooks/useCustomPrompts"; +import { useWorkspaceFileListing } from "@app/hooks/useWorkspaceFileListing"; +import { useGitBranches } from "@/features/git/hooks/useGitBranches"; +import { useBranchSwitcher } from "@/features/git/hooks/useBranchSwitcher"; +import { useBranchSwitcherShortcut } from "@/features/git/hooks/useBranchSwitcherShortcut"; +import { useWorkspaceRefreshOnFocus } from "@/features/workspaces/hooks/useWorkspaceRefreshOnFocus"; +import { useWorkspaceRestore } from "@/features/workspaces/hooks/useWorkspaceRestore"; +import { useRenameWorktreePrompt } from "@/features/workspaces/hooks/useRenameWorktreePrompt"; +import { useLayoutController } from "@app/hooks/useLayoutController"; +import { useWindowLabel } from "@/features/layout/hooks/useWindowLabel"; import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { SidebarCollapseButton, TitlebarExpandControls, -} from "./features/layout/components/SidebarToggleControls"; -import { useAppSettingsController } from "./features/app/hooks/useAppSettingsController"; -import { useUpdaterController } from "./features/app/hooks/useUpdaterController"; -import { useResponseRequiredNotificationsController } from "./features/app/hooks/useResponseRequiredNotificationsController"; -import { useErrorToasts } from "./features/notifications/hooks/useErrorToasts"; -import { useComposerShortcuts } from "./features/composer/hooks/useComposerShortcuts"; -import { useComposerMenuActions } from "./features/composer/hooks/useComposerMenuActions"; -import { useComposerEditorState } from "./features/composer/hooks/useComposerEditorState"; -import { useDictationController } from "./features/app/hooks/useDictationController"; -import { useComposerController } from "./features/app/hooks/useComposerController"; -import { useComposerInsert } from "./features/app/hooks/useComposerInsert"; -import { useRenameThreadPrompt } from "./features/threads/hooks/useRenameThreadPrompt"; -import { useWorktreePrompt } from "./features/workspaces/hooks/useWorktreePrompt"; -import { useClonePrompt } from "./features/workspaces/hooks/useClonePrompt"; -import { useWorkspaceController } from "./features/app/hooks/useWorkspaceController"; -import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceSelection"; -import { useLocalUsage } from "./features/home/hooks/useLocalUsage"; -import { useGitHubPanelController } from "./features/app/hooks/useGitHubPanelController"; -import { useSettingsModalState } from "./features/app/hooks/useSettingsModalState"; -import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings"; -import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath"; -import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController"; -import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents"; -import { usePlanReadyActions } from "./features/app/hooks/usePlanReadyActions"; -import { useWorkspaceActions } from "./features/app/hooks/useWorkspaceActions"; -import { useWorkspaceCycling } from "./features/app/hooks/useWorkspaceCycling"; -import { useThreadRows } from "./features/app/hooks/useThreadRows"; -import { useInterruptShortcut } from "./features/app/hooks/useInterruptShortcut"; -import { useArchiveShortcut } from "./features/app/hooks/useArchiveShortcut"; -import { useLiquidGlassEffect } from "./features/app/hooks/useLiquidGlassEffect"; -import { useCopyThread } from "./features/threads/hooks/useCopyThread"; -import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; -import { useWorkspaceLaunchScript } from "./features/app/hooks/useWorkspaceLaunchScript"; -import { useWorkspaceLaunchScripts } from "./features/app/hooks/useWorkspaceLaunchScripts"; -import { useWorktreeSetupScript } from "./features/app/hooks/useWorktreeSetupScript"; -import { useGitCommitController } from "./features/app/hooks/useGitCommitController"; -import { WorkspaceHome } from "./features/workspaces/components/WorkspaceHome"; -import { MobileServerSetupWizard } from "./features/mobile/components/MobileServerSetupWizard"; -import { useMobileServerSetup } from "./features/mobile/hooks/useMobileServerSetup"; -import { useWorkspaceHome } from "./features/workspaces/hooks/useWorkspaceHome"; -import { useWorkspaceAgentMd } from "./features/workspaces/hooks/useWorkspaceAgentMd"; -import { isMobilePlatform } from "./utils/platformPaths"; +} from "@/features/layout/components/SidebarToggleControls"; +import { useUpdaterController } from "@app/hooks/useUpdaterController"; +import { useResponseRequiredNotificationsController } from "@app/hooks/useResponseRequiredNotificationsController"; +import { useErrorToasts } from "@/features/notifications/hooks/useErrorToasts"; +import { useComposerShortcuts } from "@/features/composer/hooks/useComposerShortcuts"; +import { useComposerMenuActions } from "@/features/composer/hooks/useComposerMenuActions"; +import { useComposerEditorState } from "@/features/composer/hooks/useComposerEditorState"; +import { useComposerController } from "@app/hooks/useComposerController"; +import { useComposerInsert } from "@app/hooks/useComposerInsert"; +import { useRenameThreadPrompt } from "@threads/hooks/useRenameThreadPrompt"; +import { useWorktreePrompt } from "@/features/workspaces/hooks/useWorktreePrompt"; +import { useClonePrompt } from "@/features/workspaces/hooks/useClonePrompt"; +import { useWorkspaceController } from "@app/hooks/useWorkspaceController"; +import { useWorkspaceSelection } from "@/features/workspaces/hooks/useWorkspaceSelection"; +import { useGitHubPanelController } from "@app/hooks/useGitHubPanelController"; +import { useSettingsModalState } from "@app/hooks/useSettingsModalState"; +import { useSyncSelectedDiffPath } from "@app/hooks/useSyncSelectedDiffPath"; +import { useMenuAcceleratorController } from "@app/hooks/useMenuAcceleratorController"; +import { useAppMenuEvents } from "@app/hooks/useAppMenuEvents"; +import { usePlanReadyActions } from "@app/hooks/usePlanReadyActions"; +import { useWorkspaceActions } from "@app/hooks/useWorkspaceActions"; +import { useWorkspaceCycling } from "@app/hooks/useWorkspaceCycling"; +import { useThreadRows } from "@app/hooks/useThreadRows"; +import { useInterruptShortcut } from "@app/hooks/useInterruptShortcut"; +import { useArchiveShortcut } from "@app/hooks/useArchiveShortcut"; +import { useCopyThread } from "@threads/hooks/useCopyThread"; +import { useTerminalController } from "@/features/terminal/hooks/useTerminalController"; +import { useWorkspaceLaunchScript } from "@app/hooks/useWorkspaceLaunchScript"; +import { useWorkspaceLaunchScripts } from "@app/hooks/useWorkspaceLaunchScripts"; +import { useWorktreeSetupScript } from "@app/hooks/useWorktreeSetupScript"; +import { useGitCommitController } from "@app/hooks/useGitCommitController"; +import { WorkspaceHome } from "@/features/workspaces/components/WorkspaceHome"; +import { MobileServerSetupWizard } from "@/features/mobile/components/MobileServerSetupWizard"; +import { useMobileServerSetup } from "@/features/mobile/hooks/useMobileServerSetup"; +import { useWorkspaceHome } from "@/features/workspaces/hooks/useWorkspaceHome"; +import { useWorkspaceAgentMd } from "@/features/workspaces/hooks/useWorkspaceAgentMd"; import type { - AccessMode, ComposerEditorSettings, WorkspaceInfo, -} from "./types"; -import { OPEN_APP_STORAGE_KEY } from "./features/app/constants"; -import { useOpenAppIcons } from "./features/app/hooks/useOpenAppIcons"; -import { useCodeCssVars } from "./features/app/hooks/useCodeCssVars"; -import { useAccountSwitching } from "./features/app/hooks/useAccountSwitching"; -import { useNewAgentDraft } from "./features/app/hooks/useNewAgentDraft"; -import { useSystemNotificationThreadLinks } from "./features/app/hooks/useSystemNotificationThreadLinks"; -import { useThreadListSortKey } from "./features/app/hooks/useThreadListSortKey"; -import { useThreadListActions } from "./features/app/hooks/useThreadListActions"; -import { useGitRootSelection } from "./features/app/hooks/useGitRootSelection"; -import { useTabActivationGuard } from "./features/app/hooks/useTabActivationGuard"; -import { useRemoteThreadRefreshOnFocus } from "./features/app/hooks/useRemoteThreadRefreshOnFocus"; +} from "@/types"; +import { OPEN_APP_STORAGE_KEY } from "@app/constants"; +import { useOpenAppIcons } from "@app/hooks/useOpenAppIcons"; +import { useAccountSwitching } from "@app/hooks/useAccountSwitching"; +import { useNewAgentDraft } from "@app/hooks/useNewAgentDraft"; +import { useSystemNotificationThreadLinks } from "@app/hooks/useSystemNotificationThreadLinks"; +import { useThreadListSortKey } from "@app/hooks/useThreadListSortKey"; +import { useThreadListActions } from "@app/hooks/useThreadListActions"; +import { useGitRootSelection } from "@app/hooks/useGitRootSelection"; +import { useTabActivationGuard } from "@app/hooks/useTabActivationGuard"; +import { useRemoteThreadRefreshOnFocus } from "@app/hooks/useRemoteThreadRefreshOnFocus"; +import { useAppBootstrapOrchestration } from "@app/bootstrap/useAppBootstrapOrchestration"; +import { + useThreadCodexBootstrapOrchestration, + useThreadCodexSyncOrchestration, + useThreadSelectionHandlersOrchestration, + useThreadUiOrchestration, +} from "@app/orchestration/useThreadOrchestration"; +import { + useWorkspaceInsightsOrchestration, + useWorkspaceOrderingOrchestration, +} from "@app/orchestration/useWorkspaceOrchestration"; +import { useAppShellOrchestration } from "@app/orchestration/useLayoutOrchestration"; const AboutView = lazy(() => - import("./features/about/components/AboutView").then((module) => ({ + import("@/features/about/components/AboutView").then((module) => ({ default: module.AboutView, })), ); const SettingsView = lazy(() => - import("./features/settings/components/SettingsView").then((module) => ({ + import("@settings/components/SettingsView").then((module) => ({ default: module.SettingsView, })), ); const GitHubPanelData = lazy(() => - import("./features/git/components/GitHubPanelData").then((module) => ({ + import("@/features/git/components/GitHubPanelData").then((module) => ({ default: module.GitHubPanelData, })), ); @@ -161,9 +165,6 @@ function MainApp() { scaleShortcutTitle, scaleShortcutText, queueSaveSettings, - } = useAppSettingsController(); - useCodeCssVars(appSettings); - const { dictationModel, dictationState, dictationLevel, @@ -175,8 +176,6 @@ function MainApp() { clearDictationTranscript, clearDictationError, clearDictationHint, - } = useDictationController(appSettings); - const { debugOpen, setDebugOpen, debugEntries, @@ -184,10 +183,8 @@ function MainApp() { addDebugEntry, handleCopyDebug, clearDebugEntries, - } = useDebugLog(); - const shouldReduceTransparency = reduceTransparency || isMobilePlatform(); - useLiquidGlassEffect({ reduceTransparency: shouldReduceTransparency, onDebug: addDebugEntry }); - const [accessMode, setAccessMode] = useState("current"); + shouldReduceTransparency, + } = useAppBootstrapOrchestration(); const { threadListSortKey, setThreadListSortKey } = useThreadListSortKey(); const [activeTab, setActiveTab] = useState< "home" | "projects" | "codex" | "git" | "log" @@ -205,6 +202,7 @@ function MainApp() { setActiveWorkspaceId, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, addCloneAgent, addWorktreeAgent, connectWorkspace, @@ -245,6 +243,26 @@ function MainApp() { () => new Map(workspaces.map((workspace) => [workspace.id, workspace])), [workspaces], ); + const { + threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + accessMode, + setAccessMode, + preferredModelId, + setPreferredModelId, + preferredEffort, + setPreferredEffort, + preferredCollabModeId, + setPreferredCollabModeId, + threadCodexSelectionKey, + setThreadCodexSelectionKey, + activeThreadIdRef, + pendingNewThreadSeedRef, + persistThreadCodexParams, + } = useThreadCodexBootstrapOrchestration({ + activeWorkspaceId, + }); const { sidebarWidth, rightPanelWidth, @@ -324,11 +342,7 @@ function MainApp() { const { errorToasts, dismissErrorToast } = useErrorToasts(); - useEffect(() => { - setAccessMode((prev) => - prev === "current" ? appSettings.defaultAccessMode : prev - ); - }, [appSettings.defaultAccessMode]); + // Access mode is thread-scoped (best-effort persisted) and falls back to the app default. const { gitIssues, @@ -399,6 +413,7 @@ function MainApp() { activeWorkspace, gitDiffPreloadEnabled: appSettings.preloadGitDiffs, gitDiffIgnoreWhitespaceChanges: appSettings.gitDiffIgnoreWhitespaceChanges, + splitChatDiffView: appSettings.splitChatDiffView, isCompact, isTablet, activeTab, @@ -440,8 +455,9 @@ function MainApp() { } = useModels({ activeWorkspace, onDebug: addDebugEntry, - preferredModelId: appSettings.lastComposerModelId, - preferredEffort: appSettings.lastComposerReasoningEffort, + preferredModelId, + preferredEffort, + selectionKey: threadCodexSelectionKey, }); const { @@ -452,9 +468,28 @@ function MainApp() { } = useCollaborationModes({ activeWorkspace, enabled: appSettings.collaborationModesEnabled, + preferredModeId: preferredCollabModeId, + selectionKey: threadCodexSelectionKey, onDebug: addDebugEntry, }); + const { + handleSelectModel, + handleSelectEffort, + handleSelectCollaborationMode, + handleSelectAccessMode, + } = useThreadSelectionHandlersOrchestration({ + appSettingsLoading, + setAppSettings, + queueSaveSettings, + activeThreadIdRef, + setSelectedModelId, + setSelectedEffort, + setSelectedCollaborationModeId, + setAccessMode, + persistThreadCodexParams, + }); + const composerShortcuts = { modelShortcut: appSettings.composerModelShortcut, accessShortcut: appSettings.composerAccessShortcut, @@ -465,14 +500,14 @@ function MainApp() { models, collaborationModes, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, }; @@ -489,24 +524,19 @@ function MainApp() { useComposerMenuActions({ models, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, collaborationModes, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, onFocusComposer: () => composerInputRef.current?.focus(), }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); - const { apps } = useApps({ - activeWorkspace, - enabled: appSettings.experimentalAppsEnabled, - onDebug: addDebugEntry, - }); const { prompts, createPrompt, @@ -516,7 +546,7 @@ function MainApp() { getWorkspacePromptsDir, getGlobalPromptsDir, } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); - const { branches, checkoutBranch, createBranch } = useGitBranches({ + const { branches, checkoutBranch, checkoutPullRequest, createBranch } = useGitBranches({ activeWorkspace, onDebug: addDebugEntry }); @@ -524,6 +554,15 @@ function MainApp() { await checkoutBranch(name); refreshGitStatus(); }; + const handleCheckoutPullRequest = async (prNumber: number) => { + try { + await checkoutPullRequest(prNumber); + await Promise.resolve(refreshGitStatus()); + await Promise.resolve(refreshGitLog()); + } catch (error) { + alertError(error); + } + }; const handleCreateBranch = async (name: string) => { await createBranch(name); refreshGitStatus(); @@ -583,14 +622,6 @@ function MainApp() { } changed` : "Working tree clean"; - usePersistComposerSettings({ - appSettingsLoading, - selectedModelId, - selectedEffort, - setAppSettings, - queueSaveSettings, - }); - const { isExpanded: composerEditorExpanded, toggleExpanded: toggleComposerEditorExpanded } = useComposerEditorState(); @@ -708,10 +739,41 @@ function MainApp() { accessMode, reviewDeliveryMode: appSettings.reviewDeliveryMode, steerEnabled: appSettings.steerEnabled, + threadTitleAutogenerationEnabled: appSettings.threadTitleAutogenerationEnabled, customPrompts: prompts, onMessageActivity: queueGitStatusRefresh, threadSortKey: threadListSortKey, }); + const { apps } = useApps({ + activeWorkspace, + activeThreadId, + enabled: appSettings.experimentalAppsEnabled, + onDebug: addDebugEntry, + }); + + useThreadCodexSyncOrchestration({ + activeWorkspaceId, + activeThreadId, + appSettings: { + defaultAccessMode: appSettings.defaultAccessMode, + lastComposerModelId: appSettings.lastComposerModelId, + lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort, + }, + threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + setThreadCodexSelectionKey, + setAccessMode, + setPreferredModelId, + setPreferredEffort, + setPreferredCollabModeId, + activeThreadIdRef, + pendingNewThreadSeedRef, + selectedModelId, + resolvedEffort, + accessMode, + selectedCollaborationModeId, + }); const { handleSetThreadListSortKey, handleRefreshAllWorkspaceThreads } = useThreadListActions({ @@ -755,11 +817,7 @@ function MainApp() { activeWorkspaceId, activeThreadId, }); - const activeThreadIdRef = useRef(activeThreadId ?? null); const { getThreadRows } = useThreadRows(threadParentById); - useEffect(() => { - activeThreadIdRef.current = activeThreadId ?? null; - }, [activeThreadId]); const { recordPendingThreadLink } = useSystemNotificationThreadLinks({ hasLoadedWorkspaces: hasLoaded, @@ -1020,50 +1078,30 @@ function MainApp() { }, }); - const latestAgentRuns = useMemo(() => { - const entries: Array<{ - threadId: string; - message: string; - timestamp: number; - projectName: string; - groupName?: string | null; - workspaceId: string; - isProcessing: boolean; - }> = []; - workspaces.forEach((workspace) => { - const threads = threadsByWorkspace[workspace.id] ?? []; - threads.forEach((thread) => { - const entry = lastAgentMessageByThread[thread.id]; - if (!entry) { - return; - } - entries.push({ - threadId: thread.id, - message: entry.text, - timestamp: entry.timestamp, - projectName: workspace.name, - groupName: getWorkspaceGroupName(workspace.id), - workspaceId: workspace.id, - isProcessing: threadStatusById[thread.id]?.isProcessing ?? false - }); - }); - }); - return entries.sort((a, b) => b.timestamp - a.timestamp).slice(0, 3); - }, [ + const showHome = !activeWorkspace; + const { + latestAgentRuns, + isLoadingLatestAgents, + usageMetric, + setUsageMetric, + usageWorkspaceId, + setUsageWorkspaceId, + usageWorkspaceOptions, + localUsageSnapshot, + isLoadingLocalUsage, + localUsageError, + refreshLocalUsage, + } = useWorkspaceInsightsOrchestration({ + workspaces, + workspacesById, + hasLoaded, + showHome, + threadsByWorkspace, lastAgentMessageByThread, - getWorkspaceGroupName, threadStatusById, - threadsByWorkspace, - workspaces - ]); - const isLoadingLatestAgents = useMemo( - () => - !hasLoaded || - workspaces.some( - (workspace) => threadListLoadingByWorkspace[workspace.id] ?? false - ), - [hasLoaded, threadListLoadingByWorkspace, workspaces] - ); + threadListLoadingByWorkspace, + getWorkspaceGroupName, + }); const activeRateLimits = activeWorkspaceId ? rateLimitsByWorkspace[activeWorkspaceId] ?? null @@ -1077,7 +1115,6 @@ function MainApp() { const hasActivePlan = Boolean( activePlan && (activePlan.steps.length > 0 || activePlan.explanation) ); - const showHome = !activeWorkspace; const showWorkspaceHome = Boolean(activeWorkspace && !activeThreadId && !isNewAgentDraftMode); const showComposer = (!isCompact ? centerMode === "chat" || centerMode === "diff" @@ -1095,40 +1132,6 @@ function MainApp() { hasComposerSurface: showComposer || showWorkspaceHome, onDebug: addDebugEntry, }); - const [usageMetric, setUsageMetric] = useState<"tokens" | "time">("tokens"); - const [usageWorkspaceId, setUsageWorkspaceId] = useState(null); - const usageWorkspaceOptions = useMemo( - () => - workspaces.map((workspace) => { - const groupName = getWorkspaceGroupName(workspace.id); - const label = groupName - ? `${groupName} / ${workspace.name}` - : workspace.name; - return { id: workspace.id, label }; - }), - [getWorkspaceGroupName, workspaces], - ); - const usageWorkspacePath = useMemo(() => { - if (!usageWorkspaceId) { - return null; - } - return workspacesById.get(usageWorkspaceId)?.path ?? null; - }, [usageWorkspaceId, workspacesById]); - useEffect(() => { - if (!usageWorkspaceId) { - return; - } - if (workspaces.some((workspace) => workspace.id === usageWorkspaceId)) { - return; - } - setUsageWorkspaceId(null); - }, [usageWorkspaceId, workspaces]); - const { - snapshot: localUsageSnapshot, - isLoading: isLoadingLocalUsage, - error: localUsageError, - refresh: refreshLocalUsage, - } = useLocalUsage(showHome, usageWorkspacePath); const canInterrupt = activeThreadId ? threadStatusById[activeThreadId]?.isProcessing ?? false : false; @@ -1439,8 +1442,6 @@ function MainApp() { useTabActivationGuard({ activeTab, - activeWorkspace, - isPhone, isTablet, setActiveTab, }); @@ -1467,7 +1468,7 @@ function MainApp() { const { handleAddWorkspace, - handleAddWorkspaceFromPath, + handleAddWorkspacesFromPaths, handleAddAgent, handleAddWorktreeAgent, handleAddCloneAgent, @@ -1475,6 +1476,7 @@ function MainApp() { isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, setActiveThreadId, setActiveTab, exitDiffView, @@ -1494,11 +1496,9 @@ function MainApp() { if (uniquePaths.length === 0) { return; } - uniquePaths.forEach((path) => { - void handleAddWorkspaceFromPath(path); - }); + void handleAddWorkspacesFromPaths(uniquePaths); }, - [handleAddWorkspaceFromPath], + [handleAddWorkspacesFromPaths], ); const { @@ -1512,21 +1512,6 @@ function MainApp() { onDropPaths: handleDropWorkspacePaths, }); - const handleArchiveActiveThread = useCallback(() => { - if (!activeWorkspaceId || !activeThreadId) { - return; - } - removeThread(activeWorkspaceId, activeThreadId); - clearDraftForThread(activeThreadId); - removeImagesForThread(activeThreadId); - }, [ - activeThreadId, - activeWorkspaceId, - clearDraftForThread, - removeImagesForThread, - removeThread, - ]); - useInterruptShortcut({ isEnabled: canInterrupt, shortcut: appSettings.interruptShortcut, @@ -1535,16 +1520,31 @@ function MainApp() { }, }); + const { + isLaunchingReview: isLaunchingPullRequestReview, + lastReviewThreadId: lastPullRequestReviewThreadId, + reviewActions: pullRequestReviewActions, + runPullRequestReview, + } = usePullRequestReviewActions({ + activeWorkspace, + pullRequest: selectedPullRequest, + pullRequestDiffs: gitPullRequestDiffs, + pullRequestComments: gitPullRequestComments, + connectWorkspace, + startThreadForWorkspace, + sendUserMessageToThread, + }); + const { handleSelectPullRequest, resetPullRequestSelection, + composerContextActions, composerSendLabel, handleComposerSend, handleComposerQueue, } = usePullRequestComposer({ activeWorkspace, selectedPullRequest, - gitPullRequestDiffs, filePanelMode, gitPanelMode, centerMode, @@ -1556,69 +1556,40 @@ function MainApp() { setGitPanelMode, setPrefillDraft, setActiveTab, - connectWorkspace, - startThreadForWorkspace, - sendUserMessageToThread, + pullRequestReviewActions, + pullRequestReviewLaunching: isLaunchingPullRequestReview, + runPullRequestReview, clearActiveImages, handleSend, queueMessage, }); - const handleComposerSendWithDraftStart = useCallback( - (text: string, images: string[]) => - runWithDraftStart(() => handleComposerSend(text, images)), - [handleComposerSend, runWithDraftStart], - ); - const handleComposerQueueWithDraftStart = useCallback( - (text: string, images: string[]) => { - // Queueing without an active thread would no-op; bootstrap through send so user input is not lost. - const runner = activeThreadId - ? () => handleComposerQueue(text, images) - : () => handleComposerSend(text, images); - return runWithDraftStart(runner); - }, - [activeThreadId, handleComposerQueue, handleComposerSend, runWithDraftStart], - ); - const handleSelectWorkspaceInstance = useCallback( - (workspaceId: string, threadId: string) => { - exitDiffView(); - resetPullRequestSelection(); - clearDraftState(); - selectWorkspace(workspaceId); - setActiveThreadId(threadId, workspaceId); - if (isCompact) { - setActiveTab("codex"); - } - }, - [ - clearDraftState, - exitDiffView, - isCompact, - resetPullRequestSelection, - selectWorkspace, - setActiveTab, - setActiveThreadId, - ], - ); - - const handleOpenThreadLink = useCallback( - (threadId: string) => { - if (!activeWorkspaceId) { - return; - } - exitDiffView(); - resetPullRequestSelection(); - clearDraftState(); - setActiveThreadId(threadId, activeWorkspaceId); - }, - [ - activeWorkspaceId, - clearDraftState, - exitDiffView, - resetPullRequestSelection, - setActiveThreadId, - ], - ); + const { + handleComposerSendWithDraftStart, + handleComposerQueueWithDraftStart, + handleSelectWorkspaceInstance, + handleOpenThreadLink, + handleArchiveActiveThread, + } = useThreadUiOrchestration({ + activeWorkspaceId, + activeThreadId, + accessMode, + selectedCollaborationModeId, + pendingNewThreadSeedRef, + runWithDraftStart, + handleComposerSend, + handleComposerQueue, + clearDraftState, + exitDiffView, + resetPullRequestSelection, + selectWorkspace, + setActiveThreadId, + setActiveTab, + isCompact, + removeThread, + clearDraftForThread, + removeImagesForThread, + }); const { handlePlanAccept, handlePlanExport, handlePlanSubmitChanges } = usePlanReadyActions({ activeWorkspace, @@ -1631,58 +1602,38 @@ function MainApp() { setSelectedCollaborationModeId, }); - const orderValue = (entry: WorkspaceInfo) => - typeof entry.settings.sortOrder === "number" - ? entry.settings.sortOrder - : Number.MAX_SAFE_INTEGER; - - const handleMoveWorkspace = async ( - workspaceId: string, - direction: "up" | "down" - ) => { - const target = workspacesById.get(workspaceId); - if (!target || (target.kind ?? "main") === "worktree") { - return; - } - const targetGroupId = target.settings.groupId ?? null; - const ordered = workspaces - .filter( - (entry) => - (entry.kind ?? "main") !== "worktree" && - (entry.settings.groupId ?? null) === targetGroupId, - ) - .slice() - .sort((a, b) => { - const orderDiff = orderValue(a) - orderValue(b); - if (orderDiff !== 0) { - return orderDiff; - } - return a.name.localeCompare(b.name); - }); - const index = ordered.findIndex((entry) => entry.id === workspaceId); - if (index === -1) { - return; - } - const nextIndex = direction === "up" ? index - 1 : index + 1; - if (nextIndex < 0 || nextIndex >= ordered.length) { - return; - } - const next = ordered.slice(); - const temp = next[index]; - next[index] = next[nextIndex]; - next[nextIndex] = temp; - await Promise.all( - next.map((entry, idx) => - updateWorkspaceSettings(entry.id, { - sortOrder: idx - }) - ) - ); - }; + const { handleMoveWorkspace } = useWorkspaceOrderingOrchestration({ + workspaces, + workspacesById, + updateWorkspaceSettings, + }); - const showGitDetail = - Boolean(selectedDiffPath) && isPhone && centerMode === "diff"; - const isThreadOpen = Boolean(activeThreadId && showComposer); + const { + showGitDetail, + isThreadOpen, + dropOverlayActive, + dropOverlayText, + appClassName, + appStyle, + } = useAppShellOrchestration({ + isCompact, + isPhone, + isTablet, + sidebarCollapsed, + rightPanelCollapsed, + shouldReduceTransparency, + isWorkspaceDropActive, + centerMode, + selectedDiffPath, + showComposer, + activeThreadId, + sidebarWidth, + rightPanelWidth, + planPanelHeight, + terminalPanelHeight, + debugPanelHeight, + appSettings, + }); useArchiveShortcut({ isEnabled: isThreadOpen, @@ -1733,15 +1684,6 @@ function MainApp() { }); useMenuAcceleratorController({ appSettings, onDebug: addDebugEntry }); - const dropOverlayActive = isWorkspaceDropActive; - const dropOverlayText = "Drop Project Here"; - const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ - isPhone ? " layout-phone" : "" - }${isTablet ? " layout-tablet" : ""}${ - shouldReduceTransparency ? " reduced-transparency" : "" - }${!isCompact && sidebarCollapsed ? " sidebar-collapsed" : ""}${ - !isCompact && rightPanelCollapsed ? " right-panel-collapsed" : "" - }`; const { sidebarNode, messagesNode, @@ -1917,6 +1859,8 @@ function MainApp() { branchName: gitStatus.branchName || "unknown", branches, onCheckoutBranch: handleCheckoutBranch, + onCheckoutPullRequest: (pullRequest) => + handleCheckoutPullRequest(pullRequest.number), onCreateBranch: handleCreateBranch, onCopyThread: handleCopyThread, onToggleTerminal: handleToggleTerminal, @@ -2007,6 +1951,10 @@ function MainApp() { selectedPullRequestComments: diffSource === "pr" ? gitPullRequestComments : [], selectedPullRequestCommentsLoading: gitPullRequestCommentsLoading, selectedPullRequestCommentsError: gitPullRequestCommentsError, + pullRequestReviewActions, + onRunPullRequestReview: runPullRequestReview, + pullRequestReviewLaunching: isLaunchingPullRequestReview, + pullRequestReviewThreadId: lastPullRequestReviewThreadId, onSelectPullRequest: (pullRequest) => { setSelectedCommitSha(null); handleSelectPullRequest(pullRequest); @@ -2122,16 +2070,16 @@ function MainApp() { onDeleteQueued: handleDeleteQueued, collaborationModes, selectedCollaborationModeId, - onSelectCollaborationMode: setSelectedCollaborationModeId, + onSelectCollaborationMode: handleSelectCollaborationMode, models, selectedModelId, - onSelectModel: setSelectedModelId, + onSelectModel: handleSelectModel, reasoningOptions, selectedEffort, - onSelectEffort: setSelectedEffort, + onSelectEffort: handleSelectEffort, reasoningSupported, accessMode, - onSelectAccessMode: setAccessMode, + onSelectAccessMode: handleSelectAccessMode, skills, appsEnabled: appSettings.experimentalAppsEnabled, apps, @@ -2155,6 +2103,7 @@ function MainApp() { onDismissDictationError: clearDictationError, dictationHint, onDismissDictationHint: clearDictationHint, + composerContextActions, composerSendLabel, showComposer, plan: activePlan, @@ -2270,25 +2219,7 @@ function MainApp() { ); return ( -
+
{shouldLoadGitHubPanelData ? ( @@ -2315,6 +2246,7 @@ function MainApp() { tabletTab={tabletTab} centerMode={centerMode} preloadGitDiffs={appSettings.preloadGitDiffs} + splitChatDiffView={appSettings.splitChatDiffView} hasActivePlan={hasActivePlan} activeWorkspace={Boolean(activeWorkspace)} sidebarNode={sidebarNode} diff --git a/src/features/app/bootstrap/useAppBootstrap.ts b/src/features/app/bootstrap/useAppBootstrap.ts new file mode 100644 index 0000000000..bda739e21a --- /dev/null +++ b/src/features/app/bootstrap/useAppBootstrap.ts @@ -0,0 +1,29 @@ +import { isMobilePlatform } from "@utils/platformPaths"; +import { useDebugLog } from "@/features/debug/hooks/useDebugLog"; +import { useAppSettingsController } from "@app/hooks/useAppSettingsController"; +import { useCodeCssVars } from "@app/hooks/useCodeCssVars"; +import { useDictationController } from "@app/hooks/useDictationController"; +import { useLiquidGlassEffect } from "@app/hooks/useLiquidGlassEffect"; + +export function useAppBootstrap() { + const appSettingsState = useAppSettingsController(); + useCodeCssVars(appSettingsState.appSettings); + + const dictationState = useDictationController(appSettingsState.appSettings); + const debugState = useDebugLog(); + + const shouldReduceTransparency = + appSettingsState.reduceTransparency || isMobilePlatform(); + + useLiquidGlassEffect({ + reduceTransparency: shouldReduceTransparency, + onDebug: debugState.addDebugEntry, + }); + + return { + ...appSettingsState, + ...dictationState, + ...debugState, + shouldReduceTransparency, + }; +} diff --git a/src/features/app/bootstrap/useAppBootstrapOrchestration.ts b/src/features/app/bootstrap/useAppBootstrapOrchestration.ts new file mode 100644 index 0000000000..7ce996b06e --- /dev/null +++ b/src/features/app/bootstrap/useAppBootstrapOrchestration.ts @@ -0,0 +1 @@ +export { useAppBootstrap as useAppBootstrapOrchestration } from "./useAppBootstrap"; diff --git a/src/features/app/components/AppLayout.tsx b/src/features/app/components/AppLayout.tsx index 16d2ebdf13..dc887555cc 100644 --- a/src/features/app/components/AppLayout.tsx +++ b/src/features/app/components/AppLayout.tsx @@ -12,6 +12,7 @@ type AppLayoutProps = { tabletTab: "codex" | "git" | "log"; centerMode: "chat" | "diff"; preloadGitDiffs: boolean; + splitChatDiffView: boolean; hasActivePlan: boolean; activeWorkspace: boolean; sidebarNode: ReactNode; @@ -48,6 +49,7 @@ export const AppLayout = memo(function AppLayout({ tabletTab, centerMode, preloadGitDiffs, + splitChatDiffView, hasActivePlan, activeWorkspace, sidebarNode, @@ -134,6 +136,7 @@ export const AppLayout = memo(function AppLayout({ topbarLeftNode={desktopTopbarLeftNode} centerMode={centerMode} preloadGitDiffs={preloadGitDiffs} + splitChatDiffView={splitChatDiffView} messagesNode={messagesNode} gitDiffViewerNode={gitDiffViewerNode} gitDiffPanelNode={gitDiffPanelNode} diff --git a/src/features/app/components/PinnedThreadList.test.tsx b/src/features/app/components/PinnedThreadList.test.tsx index e227091033..704f91bfaa 100644 --- a/src/features/app/components/PinnedThreadList.test.tsx +++ b/src/features/app/components/PinnedThreadList.test.tsx @@ -101,4 +101,24 @@ describe("PinnedThreadList", () => { true, ); }); + + it("shows blue unread-style status when a pinned thread is waiting for user input", () => { + const { container } = render( + , + ); + + const row = container.querySelector(".thread-row"); + expect(row).toBeTruthy(); + expect(row?.querySelector(".thread-name")?.textContent).toBe("Pinned Beta"); + expect(row?.querySelector(".thread-status")?.className).toContain("unread"); + expect(row?.querySelector(".thread-status")?.className).not.toContain("processing"); + }); }); diff --git a/src/features/app/components/PinnedThreadList.tsx b/src/features/app/components/PinnedThreadList.tsx index 19eddb0a08..e108ba7fc7 100644 --- a/src/features/app/components/PinnedThreadList.tsx +++ b/src/features/app/components/PinnedThreadList.tsx @@ -18,6 +18,7 @@ type PinnedThreadListProps = { activeWorkspaceId: string | null; activeThreadId: string | null; threadStatusById: ThreadStatusMap; + pendingUserInputKeys?: Set; getThreadTime: (thread: ThreadSummary) => string | null; isThreadPinned: (workspaceId: string, threadId: string) => boolean; onSelectThread: (workspaceId: string, threadId: string) => void; @@ -34,6 +35,7 @@ export function PinnedThreadList({ activeWorkspaceId, activeThreadId, threadStatusById, + pendingUserInputKeys, getThreadTime, isThreadPinned, onSelectThread, @@ -48,7 +50,12 @@ export function PinnedThreadList({ ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) : undefined; const status = threadStatusById[thread.id]; - const statusClass = status?.isReviewing + const hasPendingUserInput = Boolean( + pendingUserInputKeys?.has(`${workspaceId}:${thread.id}`), + ); + const statusClass = hasPendingUserInput + ? "unread" + : status?.isReviewing ? "reviewing" : status?.isProcessing ? "processing" diff --git a/src/features/app/components/Sidebar.tsx b/src/features/app/components/Sidebar.tsx index 677425893e..4174eacff5 100644 --- a/src/features/app/components/Sidebar.tsx +++ b/src/features/app/components/Sidebar.tsx @@ -1,6 +1,6 @@ import type { AccountSnapshot, - CollaborationModeOption, + RequestUserInputRequest, RateLimitSnapshot, ThreadListSortKey, ThreadSummary, @@ -27,7 +27,6 @@ import { WorktreeSection } from "./WorktreeSection"; import { PinnedThreadList } from "./PinnedThreadList"; import { WorkspaceCard } from "./WorkspaceCard"; import { WorkspaceGroup } from "./WorkspaceGroup"; -import { Forge } from "../../forge/components/Forge"; import { useCollapsedGroups } from "../hooks/useCollapsedGroups"; import { useSidebarMenus } from "../hooks/useSidebarMenus"; import { useSidebarScrollFade } from "../hooks/useSidebarScrollFade"; @@ -68,18 +67,7 @@ type SidebarProps = { onRefreshAllThreads: () => void; activeWorkspaceId: string | null; activeThreadId: string | null; - activeWorkspace: WorkspaceInfo | null; - sendUserMessageToThread: ( - workspace: WorkspaceInfo, - threadId: string, - message: string, - imageIds: string[], - options?: { - collaborationMode?: Record | null; - }, - ) => Promise; - collaborationModes: CollaborationModeOption[]; - onSelectCollaborationMode: (id: string | null) => void; + userInputRequests?: RequestUserInputRequest[]; accountRateLimits: RateLimitSnapshot | null; usageShowRemaining: boolean; accountInfo: AccountSnapshot | null; @@ -136,10 +124,7 @@ export const Sidebar = memo(function Sidebar({ onRefreshAllThreads, activeWorkspaceId, activeThreadId, - activeWorkspace, - sendUserMessageToThread, - collaborationModes, - onSelectCollaborationMode, + userInputRequests = [], accountRateLimits, usageShowRemaining, accountInfo, @@ -182,7 +167,6 @@ export const Sidebar = memo(function Sidebar({ ); const [searchQuery, setSearchQuery] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); - const [isForgeOpen, setIsForgeOpen] = useState(false); const [addMenuAnchor, setAddMenuAnchor] = useState<{ workspaceId: string; top: number; @@ -216,6 +200,19 @@ export const Sidebar = memo(function Sidebar({ } = getUsageLabels(accountRateLimits, usageShowRemaining); const debouncedQuery = useDebouncedValue(searchQuery, 150); const normalizedQuery = debouncedQuery.trim().toLowerCase(); + const pendingUserInputKeys = useMemo( + () => + new Set( + userInputRequests + .map((request) => { + const workspaceId = request.workspace_id.trim(); + const threadId = request.params.thread_id.trim(); + return workspaceId && threadId ? `${workspaceId}:${threadId}` : ""; + }) + .filter(Boolean), + ), + [userInputRequests], + ); const isWorkspaceMatch = useCallback( (workspace: WorkspaceInfo) => { @@ -436,21 +433,7 @@ export const Sidebar = memo(function Sidebar({ { - setIsForgeOpen((prev) => { - const next = !prev; - if (next) { - setIsSearchOpen(false); - setSearchQuery(""); - } - return next; - }); - }} - isForgeOpen={isForgeOpen} - onToggleSearch={() => { - setIsForgeOpen(false); - setIsSearchOpen((prev) => !prev); - }} + onToggleSearch={() => setIsSearchOpen((prev) => !prev)} isSearchOpen={isSearchOpen} threadListSortKey={threadListSortKey} onSetThreadListSortKey={onSetThreadListSortKey} @@ -506,228 +489,220 @@ export const Sidebar = memo(function Sidebar({ onScroll={updateScrollFade} ref={sidebarBodyRef} > - {isForgeOpen ? ( - - ) : ( -
- {pinnedThreadRows.length > 0 && ( -
-
-
Pinned
-
- +
+ {pinnedThreadRows.length > 0 && ( +
+
+
Pinned
- )} - {filteredGroupedWorkspaces.map((group) => { - const groupId = group.id; - const showGroupHeader = Boolean(groupId) || hasWorkspaceGroups; - const toggleId = groupId ?? (showGroupHeader ? UNGROUPED_COLLAPSE_ID : null); - const isGroupCollapsed = Boolean( - toggleId && collapsedGroups.has(toggleId), - ); + +
+ )} + {filteredGroupedWorkspaces.map((group) => { + const groupId = group.id; + const showGroupHeader = Boolean(groupId) || hasWorkspaceGroups; + const toggleId = groupId ?? (showGroupHeader ? UNGROUPED_COLLAPSE_ID : null); + const isGroupCollapsed = Boolean( + toggleId && collapsedGroups.has(toggleId), + ); - return ( - - {group.workspaces.map((entry) => { - const threads = threadsByWorkspace[entry.id] ?? []; - const isCollapsed = entry.settings.sidebarCollapsed; - const isExpanded = expandedWorkspaces.has(entry.id); - const { - unpinnedRows, - totalRoots: totalThreadRoots, - } = getThreadRows( - threads, - isExpanded, - entry.id, - getPinTimestamp, - ); - const nextCursor = - threadListCursorByWorkspace[entry.id] ?? null; - const showThreadList = - threads.length > 0 || Boolean(nextCursor); - const isLoadingThreads = - threadListLoadingByWorkspace[entry.id] ?? false; - const showThreadLoader = - isLoadingThreads && threads.length === 0; - const isPaging = threadListPagingByWorkspace[entry.id] ?? false; - const worktrees = worktreesByParent.get(entry.id) ?? []; - const addMenuOpen = addMenuAnchor?.workspaceId === entry.id; - const isDraftNewAgent = newAgentDraftWorkspaceId === entry.id; - const isDraftRowActive = - isDraftNewAgent && - entry.id === activeWorkspaceId && - !activeThreadId; - const draftStatusClass = - startingDraftThreadWorkspaceId === entry.id - ? "processing" - : "ready"; + return ( + + {group.workspaces.map((entry) => { + const threads = threadsByWorkspace[entry.id] ?? []; + const isCollapsed = entry.settings.sidebarCollapsed; + const isExpanded = expandedWorkspaces.has(entry.id); + const { + unpinnedRows, + totalRoots: totalThreadRoots, + } = getThreadRows( + threads, + isExpanded, + entry.id, + getPinTimestamp, + ); + const nextCursor = + threadListCursorByWorkspace[entry.id] ?? null; + const showThreadList = + threads.length > 0 || Boolean(nextCursor); + const isLoadingThreads = + threadListLoadingByWorkspace[entry.id] ?? false; + const showThreadLoader = + isLoadingThreads && threads.length === 0; + const isPaging = threadListPagingByWorkspace[entry.id] ?? false; + const worktrees = worktreesByParent.get(entry.id) ?? []; + const addMenuOpen = addMenuAnchor?.workspaceId === entry.id; + const isDraftNewAgent = newAgentDraftWorkspaceId === entry.id; + const isDraftRowActive = + isDraftNewAgent && + entry.id === activeWorkspaceId && + !activeThreadId; + const draftStatusClass = + startingDraftThreadWorkspaceId === entry.id + ? "processing" + : "ready"; - return ( - - {addMenuOpen && addMenuAnchor && - createPortal( - - { - event.stopPropagation(); - setAddMenuAnchor(null); - onAddAgent(entry); - }} - icon={} - > - New agent - - { - event.stopPropagation(); - setAddMenuAnchor(null); - onAddWorktreeAgent(entry); - }} - icon={} - > - New worktree agent - - { - event.stopPropagation(); - setAddMenuAnchor(null); - onAddCloneAgent(entry); - }} - icon={} - > - New clone agent - - , - document.body, - )} - {isDraftNewAgent && ( -
onSelectWorkspace(entry.id)} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelectWorkspace(entry.id); - } + return ( + + {addMenuOpen && addMenuAnchor && + createPortal( + - - New Agent -
- )} - {worktrees.length > 0 && ( - - )} - {showThreadList && ( - + { + event.stopPropagation(); + setAddMenuAnchor(null); + onAddAgent(entry); + }} + icon={} + > + New agent + + { + event.stopPropagation(); + setAddMenuAnchor(null); + onAddWorktreeAgent(entry); + }} + icon={} + > + New worktree agent + + { + event.stopPropagation(); + setAddMenuAnchor(null); + onAddCloneAgent(entry); + }} + icon={} + > + New clone agent + + , + document.body, )} - {showThreadLoader && } -
- ); - })} -
- ); - })} - {!filteredGroupedWorkspaces.length && ( -
- {isSearchActive - ? "No projects match your search." - : "Add a workspace to start."} -
- )} -
- )} + {isDraftNewAgent && ( +
onSelectWorkspace(entry.id)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectWorkspace(entry.id); + } + }} + > + + New Agent +
+ )} + {worktrees.length > 0 && ( + + )} + {showThreadList && ( + + )} + {showThreadLoader && } + + ); + })} + + ); + })} + {!filteredGroupedWorkspaces.length && ( +
+ {isSearchActive + ? "No projects match your search." + : "Add a workspace to start."} +
+ )} +
diff --git a/src/features/app/components/ThreadList.test.tsx b/src/features/app/components/ThreadList.test.tsx index f2145979e0..0451cab038 100644 --- a/src/features/app/components/ThreadList.test.tsx +++ b/src/features/app/components/ThreadList.test.tsx @@ -133,4 +133,23 @@ describe("ThreadList", () => { false, ); }); + + it("shows blue unread-style status when a thread is waiting for user input", () => { + const { container } = render( + , + ); + + const row = container.querySelector(".thread-row"); + expect(row).toBeTruthy(); + expect(row?.querySelector(".thread-name")?.textContent).toBe("Alpha"); + expect(row?.querySelector(".thread-status")?.className).toContain("unread"); + expect(row?.querySelector(".thread-status")?.className).not.toContain("processing"); + }); }); diff --git a/src/features/app/components/ThreadList.tsx b/src/features/app/components/ThreadList.tsx index 50ce7f7d8d..147b7709a6 100644 --- a/src/features/app/components/ThreadList.tsx +++ b/src/features/app/components/ThreadList.tsx @@ -25,6 +25,7 @@ type ThreadListProps = { activeWorkspaceId: string | null; activeThreadId: string | null; threadStatusById: ThreadStatusMap; + pendingUserInputKeys?: Set; getThreadTime: (thread: ThreadSummary) => string | null; isThreadPinned: (workspaceId: string, threadId: string) => boolean; onToggleExpanded: (workspaceId: string) => void; @@ -51,6 +52,7 @@ export function ThreadList({ activeWorkspaceId, activeThreadId, threadStatusById, + pendingUserInputKeys, getThreadTime, isThreadPinned, onToggleExpanded, @@ -66,7 +68,12 @@ export function ThreadList({ ? ({ "--thread-indent": `${depth * indentUnit}px` } as CSSProperties) : undefined; const status = threadStatusById[thread.id]; - const statusClass = status?.isReviewing + const hasPendingUserInput = Boolean( + pendingUserInputKeys?.has(`${workspaceId}:${thread.id}`), + ); + const statusClass = hasPendingUserInput + ? "unread" + : status?.isReviewing ? "reviewing" : status?.isProcessing ? "processing" diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx index 51695e6f39..1fb6c79195 100644 --- a/src/features/app/components/WorktreeSection.tsx +++ b/src/features/app/components/WorktreeSection.tsx @@ -29,6 +29,7 @@ type WorktreeSectionProps = { expandedWorkspaces: Set; activeWorkspaceId: string | null; activeThreadId: string | null; + pendingUserInputKeys?: Set; getThreadRows: ( threads: ThreadSummary[], isExpanded: boolean, @@ -64,6 +65,7 @@ export function WorktreeSection({ expandedWorkspaces, activeWorkspaceId, activeThreadId, + pendingUserInputKeys, getThreadRows, getThreadTime, isThreadPinned, @@ -136,6 +138,7 @@ export function WorktreeSection({ activeWorkspaceId={activeWorkspaceId} activeThreadId={activeThreadId} threadStatusById={threadStatusById} + pendingUserInputKeys={pendingUserInputKeys} getThreadTime={getThreadTime} isThreadPinned={isThreadPinned} onToggleExpanded={onToggleExpanded} diff --git a/src/features/app/hooks/useAppServerEvents.ts b/src/features/app/hooks/useAppServerEvents.ts index 45dd3dffbc..7d3c8a8c3d 100644 --- a/src/features/app/hooks/useAppServerEvents.ts +++ b/src/features/app/hooks/useAppServerEvents.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { AppServerEvent, ApprovalRequest, @@ -77,7 +77,7 @@ type AppServerEventHandlers = { onThreadTokenUsageUpdated?: ( workspaceId: string, threadId: string, - tokenUsage: Record, + tokenUsage: Record | null, ) => void; onAccountRateLimitsUpdated?: ( workspaceId: string, @@ -118,9 +118,18 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [ ] as const satisfies readonly SupportedAppServerMethod[]; export function useAppServerEvents(handlers: AppServerEventHandlers) { + // Use ref to keep handlers current without triggering re-subscription + const handlersRef = useRef(handlers); + + // Update ref on every render to always have latest handlers + useEffect(() => { + handlersRef.current = handlers; + }); + useEffect(() => { const unlisten = subscribeAppServerEvents((payload) => { - handlers.onAppServerEvent?.(payload); + const currentHandlers = handlersRef.current; + currentHandlers.onAppServerEvent?.(payload); const { workspace_id } = payload; const method = getAppServerRawMethod(payload); @@ -130,7 +139,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const params = getAppServerParams(payload); if (method === "codex/connected") { - handlers.onWorkspaceConnected?.(workspace_id); + currentHandlers.onWorkspaceConnected?.(workspace_id); return; } @@ -138,7 +147,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const hasRequestId = requestId !== null; if (isApprovalRequestMethod(method) && hasRequestId) { - handlers.onApprovalRequest?.({ + currentHandlers.onApprovalRequest?.({ workspace_id, request_id: requestId as string | number, method, @@ -177,7 +186,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { }; }) .filter((question) => question.id); - handlers.onRequestUserInput?.({ + currentHandlers.onRequestUserInput?.({ workspace_id, request_id: requestId as string | number, params: { @@ -195,7 +204,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onAgentMessageDelta?.({ + currentHandlers.onAgentMessageDelta?.({ workspaceId: workspace_id, threadId, itemId, @@ -212,7 +221,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ); const turnId = String(turn?.id ?? params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnStarted?.(workspace_id, threadId, turnId); + currentHandlers.onTurnStarted?.(workspace_id, threadId, turnId); } return; } @@ -221,7 +230,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const thread = (params.thread as Record | undefined) ?? null; const threadId = String(thread?.id ?? ""); if (thread && threadId) { - handlers.onThreadStarted?.(workspace_id, thread); + currentHandlers.onThreadStarted?.(workspace_id, thread); } return; } @@ -234,7 +243,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ? threadNameRaw.trim() : null; if (threadId) { - handlers.onThreadNameUpdated?.(workspace_id, { threadId, threadName }); + currentHandlers.onThreadNameUpdated?.(workspace_id, { threadId, threadName }); } return; } @@ -243,7 +252,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const action = String(params.action ?? "hide"); if (threadId) { - handlers.onBackgroundThreadAction?.(workspace_id, threadId, action); + currentHandlers.onBackgroundThreadAction?.(workspace_id, threadId, action); } return; } @@ -255,7 +264,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const messageText = String(error.message ?? ""); const willRetry = Boolean(params.willRetry ?? params.will_retry); if (threadId) { - handlers.onTurnError?.(workspace_id, threadId, turnId, { + currentHandlers.onTurnError?.(workspace_id, threadId, turnId, { message: messageText, willRetry, }); @@ -270,7 +279,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { ); const turnId = String(turn?.id ?? params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnCompleted?.(workspace_id, threadId, turnId); + currentHandlers.onTurnCompleted?.(workspace_id, threadId, turnId); } return; } @@ -279,7 +288,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const turnId = String(params.turnId ?? params.turn_id ?? ""); if (threadId) { - handlers.onTurnPlanUpdated?.(workspace_id, threadId, turnId, { + currentHandlers.onTurnPlanUpdated?.(workspace_id, threadId, turnId, { explanation: params.explanation, plan: params.plan, }); @@ -291,7 +300,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const diff = String(params.diff ?? ""); if (threadId && diff) { - handlers.onTurnDiffUpdated?.(workspace_id, threadId, diff); + currentHandlers.onTurnDiffUpdated?.(workspace_id, threadId, diff); } return; } @@ -299,10 +308,10 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { if (method === "thread/tokenUsage/updated") { const threadId = String(params.threadId ?? params.thread_id ?? ""); const tokenUsage = - (params.tokenUsage as Record | undefined) ?? - (params.token_usage as Record | undefined); - if (threadId && tokenUsage) { - handlers.onThreadTokenUsageUpdated?.(workspace_id, threadId, tokenUsage); + (params.tokenUsage as Record | null | undefined) ?? + (params.token_usage as Record | null | undefined); + if (threadId && tokenUsage !== undefined) { + currentHandlers.onThreadTokenUsageUpdated?.(workspace_id, threadId, tokenUsage); } return; } @@ -312,7 +321,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { (params.rateLimits as Record | undefined) ?? (params.rate_limits as Record | undefined); if (rateLimits) { - handlers.onAccountRateLimitsUpdated?.(workspace_id, rateLimits); + currentHandlers.onAccountRateLimitsUpdated?.(workspace_id, rateLimits); } return; } @@ -323,7 +332,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { typeof authModeRaw === "string" && authModeRaw.trim().length > 0 ? authModeRaw : null; - handlers.onAccountUpdated?.(workspace_id, authMode); + currentHandlers.onAccountUpdated?.(workspace_id, authMode); return; } @@ -337,7 +346,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const errorRaw = params.error ?? null; const error = typeof errorRaw === "string" && errorRaw.trim().length > 0 ? errorRaw : null; - handlers.onAccountLoginCompleted?.(workspace_id, { + currentHandlers.onAccountLoginCompleted?.(workspace_id, { loginId, success, error, @@ -349,13 +358,13 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const item = params.item as Record | undefined; if (threadId && item) { - handlers.onItemCompleted?.(workspace_id, threadId, item); + currentHandlers.onItemCompleted?.(workspace_id, threadId, item); } if (threadId && item?.type === "agentMessage") { const itemId = String(item.id ?? ""); const text = String(item.text ?? ""); if (itemId) { - handlers.onAgentMessageCompleted?.({ + currentHandlers.onAgentMessageCompleted?.({ workspaceId: workspace_id, threadId, itemId, @@ -370,7 +379,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const item = params.item as Record | undefined; if (threadId && item) { - handlers.onItemStarted?.(workspace_id, threadId, item); + currentHandlers.onItemStarted?.(workspace_id, threadId, item); } return; } @@ -380,7 +389,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onReasoningSummaryDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onReasoningSummaryDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -389,7 +398,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const threadId = String(params.threadId ?? params.thread_id ?? ""); const itemId = String(params.itemId ?? params.item_id ?? ""); if (threadId && itemId) { - handlers.onReasoningSummaryBoundary?.(workspace_id, threadId, itemId); + currentHandlers.onReasoningSummaryBoundary?.(workspace_id, threadId, itemId); } return; } @@ -399,7 +408,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onReasoningTextDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onReasoningTextDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -409,7 +418,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onPlanDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onPlanDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -419,7 +428,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onCommandOutputDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onCommandOutputDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -429,7 +438,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const stdin = String(params.stdin ?? ""); if (threadId && itemId) { - handlers.onTerminalInteraction?.(workspace_id, threadId, itemId, stdin); + currentHandlers.onTerminalInteraction?.(workspace_id, threadId, itemId, stdin); } return; } @@ -439,7 +448,7 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { const itemId = String(params.itemId ?? params.item_id ?? ""); const delta = String(params.delta ?? ""); if (threadId && itemId && delta) { - handlers.onFileChangeOutputDelta?.(workspace_id, threadId, itemId, delta); + currentHandlers.onFileChangeOutputDelta?.(workspace_id, threadId, itemId, delta); } return; } @@ -448,5 +457,5 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) { return () => { unlisten(); }; - }, [handlers]); + }, []); } diff --git a/src/features/app/hooks/useComposerController.ts b/src/features/app/hooks/useComposerController.ts index b6d18fda40..d91e1bfc52 100644 --- a/src/features/app/hooks/useComposerController.ts +++ b/src/features/app/hooks/useComposerController.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from "react"; -import type { QueuedMessage, WorkspaceInfo } from "../../../types"; +import type { AppMention, QueuedMessage, WorkspaceInfo } from "../../../types"; import { useComposerImages } from "../../composer/hooks/useComposerImages"; import { useQueuedSend } from "../../threads/hooks/useQueuedSend"; @@ -37,7 +37,11 @@ export function useComposerController({ workspaceId: string, options?: { activate?: boolean }, ) => Promise; - sendUserMessage: (text: string, images?: string[]) => Promise; + sendUserMessage: ( + text: string, + images?: string[], + appMentions?: AppMention[], + ) => Promise; sendUserMessageToThread: ( workspace: WorkspaceInfo, threadId: string, @@ -117,11 +121,11 @@ export function useComposerController({ ); const handleSendPrompt = useCallback( - (text: string) => { + (text: string, appMentions?: AppMention[]) => { if (!text.trim()) { return; } - void handleSend(text, []); + void handleSend(text, [], appMentions); }, [handleSend], ); diff --git a/src/features/app/hooks/useGitPanelController.test.tsx b/src/features/app/hooks/useGitPanelController.test.tsx index 2154261f65..3baf58c070 100644 --- a/src/features/app/hooks/useGitPanelController.test.tsx +++ b/src/features/app/hooks/useGitPanelController.test.tsx @@ -38,6 +38,7 @@ function makeProps(overrides?: Partial[ activeWorkspace: workspace, gitDiffPreloadEnabled: false, gitDiffIgnoreWhitespaceChanges: false, + splitChatDiffView: false, isCompact: false, isTablet: false, activeTab: "codex" as const, @@ -142,4 +143,17 @@ describe("useGitPanelController preload behavior", () => { const selectedEnabled = getLastEnabledArg(); expect(selectedEnabled).toBe(true); }); + + it("loads local diffs when split view is enabled and preload is disabled", () => { + renderHook(() => + useGitPanelController( + makeProps({ + splitChatDiffView: true, + }), + ), + ); + + const enabled = getLastEnabledArg(); + expect(enabled).toBe(true); + }); }); diff --git a/src/features/app/hooks/useGitPanelController.ts b/src/features/app/hooks/useGitPanelController.ts index 9d1d29f968..ebd82874f8 100644 --- a/src/features/app/hooks/useGitPanelController.ts +++ b/src/features/app/hooks/useGitPanelController.ts @@ -9,6 +9,7 @@ export function useGitPanelController({ activeWorkspace, gitDiffPreloadEnabled, gitDiffIgnoreWhitespaceChanges, + splitChatDiffView, isCompact, isTablet, activeTab, @@ -21,6 +22,7 @@ export function useGitPanelController({ activeWorkspace: WorkspaceInfo | null; gitDiffPreloadEnabled: boolean; gitDiffIgnoreWhitespaceChanges: boolean; + splitChatDiffView: boolean; isCompact: boolean; isTablet: boolean; activeTab: "home" | "projects" | "codex" | "git" | "log"; @@ -104,10 +106,13 @@ export function useGitPanelController({ ); const shouldLoadSelectedLocalDiff = centerMode === "diff" && Boolean(selectedDiffPath); + const shouldLoadLocalDiffsForSplitView = splitChatDiffView && diffSource === "local"; const shouldLoadLocalDiffs = Boolean(activeWorkspace) && (shouldPreloadDiffs || - (gitDiffPreloadEnabled ? diffUiVisible : shouldLoadSelectedLocalDiff)); + (gitDiffPreloadEnabled + ? diffUiVisible + : shouldLoadSelectedLocalDiff || shouldLoadLocalDiffsForSplitView)); const shouldLoadDiffs = Boolean(activeWorkspace) && (diffSource === "local" ? shouldLoadLocalDiffs : diffUiVisible); diff --git a/src/features/app/hooks/usePersistComposerSettings.ts b/src/features/app/hooks/usePersistComposerSettings.ts deleted file mode 100644 index be83213111..0000000000 --- a/src/features/app/hooks/usePersistComposerSettings.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect } from "react"; -import type { AppSettings } from "../../../types"; - -type Params = { - appSettingsLoading: boolean; - selectedModelId: string | null; - selectedEffort: string | null; - setAppSettings: (updater: (current: AppSettings) => AppSettings) => void; - queueSaveSettings: (next: AppSettings) => Promise; -}; - -export function usePersistComposerSettings({ - appSettingsLoading, - selectedModelId, - selectedEffort, - setAppSettings, - queueSaveSettings, -}: Params) { - useEffect(() => { - if (appSettingsLoading) { - return; - } - if (!selectedModelId && selectedEffort === null) { - return; - } - setAppSettings((current) => { - if ( - current.lastComposerModelId === selectedModelId && - current.lastComposerReasoningEffort === selectedEffort - ) { - return current; - } - const nextSettings = { - ...current, - lastComposerModelId: selectedModelId, - lastComposerReasoningEffort: selectedEffort, - }; - void queueSaveSettings(nextSettings); - return nextSettings; - }); - }, [ - appSettingsLoading, - queueSaveSettings, - selectedEffort, - selectedModelId, - setAppSettings, - ]); -} diff --git a/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts b/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts index 6dd0211dab..4985cc155d 100644 --- a/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts +++ b/src/features/app/hooks/useRemoteThreadRefreshOnFocus.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import type { WorkspaceInfo } from "../../../types"; type UseRemoteThreadRefreshOnFocusOptions = { @@ -14,16 +14,28 @@ export function useRemoteThreadRefreshOnFocus({ activeThreadId, refreshThread, }: UseRemoteThreadRefreshOnFocusOptions) { + const optionsRef = useRef({ backendMode, activeWorkspace, activeThreadId, refreshThread }); useEffect(() => { - if (backendMode !== "remote") { - return; - } + optionsRef.current = { backendMode, activeWorkspace, activeThreadId, refreshThread }; + }); + + useEffect(() => { + let debounceTimer: ReturnType | null = null; const refreshActiveThread = () => { - if (!activeWorkspace?.connected || !activeThreadId) { - return; + if (debounceTimer) { + clearTimeout(debounceTimer); } - void refreshThread(activeWorkspace.id, activeThreadId); + debounceTimer = setTimeout(() => { + const { backendMode: mode, activeWorkspace: ws, activeThreadId: threadId, refreshThread: refresh } = optionsRef.current; + if (mode !== "remote") { + return; + } + if (!ws?.connected || !threadId) { + return; + } + void refresh(ws.id, threadId); + }, 500); }; const handleVisibilityChange = () => { @@ -37,6 +49,9 @@ export function useRemoteThreadRefreshOnFocus({ return () => { window.removeEventListener("focus", refreshActiveThread); document.removeEventListener("visibilitychange", handleVisibilityChange); + if (debounceTimer) { + clearTimeout(debounceTimer); + } }; - }, [activeThreadId, activeWorkspace, backendMode, refreshThread]); + }, []); } diff --git a/src/features/app/hooks/useTabActivationGuard.test.tsx b/src/features/app/hooks/useTabActivationGuard.test.tsx new file mode 100644 index 0000000000..7802d8716d --- /dev/null +++ b/src/features/app/hooks/useTabActivationGuard.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useTabActivationGuard } from "./useTabActivationGuard"; + +describe("useTabActivationGuard", () => { + it("does not force home tab on phone when no workspace is selected", () => { + const setActiveTab = vi.fn(); + + renderHook(() => + useTabActivationGuard({ + activeTab: "git", + isTablet: false, + setActiveTab, + }), + ); + + expect(setActiveTab).not.toHaveBeenCalled(); + }); + + it("redirects tablet home tab selection to codex", () => { + const setActiveTab = vi.fn(); + + renderHook(() => + useTabActivationGuard({ + activeTab: "home", + isTablet: true, + setActiveTab, + }), + ); + + expect(setActiveTab).toHaveBeenCalledWith("codex"); + }); +}); diff --git a/src/features/app/hooks/useTabActivationGuard.ts b/src/features/app/hooks/useTabActivationGuard.ts index f5299839e3..55463dd191 100644 --- a/src/features/app/hooks/useTabActivationGuard.ts +++ b/src/features/app/hooks/useTabActivationGuard.ts @@ -1,32 +1,18 @@ import { useEffect } from "react"; -import type { WorkspaceInfo } from "../../../types"; type AppTab = "home" | "projects" | "codex" | "git" | "log"; type UseTabActivationGuardOptions = { activeTab: AppTab; - activeWorkspace: WorkspaceInfo | null; - isPhone: boolean; isTablet: boolean; setActiveTab: (tab: AppTab) => void; }; export function useTabActivationGuard({ activeTab, - activeWorkspace, - isPhone, isTablet, setActiveTab, }: UseTabActivationGuardOptions) { - useEffect(() => { - if (!isPhone) { - return; - } - if (!activeWorkspace && activeTab !== "home" && activeTab !== "projects") { - setActiveTab("home"); - } - }, [activeTab, activeWorkspace, isPhone, setActiveTab]); - useEffect(() => { if (!isTablet) { return; diff --git a/src/features/app/hooks/useWorkspaceActions.test.tsx b/src/features/app/hooks/useWorkspaceActions.test.tsx index f9eb7c25e1..ba6caaf7f1 100644 --- a/src/features/app/hooks/useWorkspaceActions.test.tsx +++ b/src/features/app/hooks/useWorkspaceActions.test.tsx @@ -35,6 +35,7 @@ describe("useWorkspaceActions telemetry", () => { isCompact: false, addWorkspace: vi.fn(async () => null), addWorkspaceFromPath: vi.fn(async () => null), + addWorkspacesFromPaths: vi.fn(async () => null), setActiveThreadId, setActiveTab: vi.fn(), exitDiffView: vi.fn(), diff --git a/src/features/app/hooks/useWorkspaceActions.ts b/src/features/app/hooks/useWorkspaceActions.ts index bc7be9e152..c7951953bb 100644 --- a/src/features/app/hooks/useWorkspaceActions.ts +++ b/src/features/app/hooks/useWorkspaceActions.ts @@ -7,6 +7,7 @@ type Params = { isCompact: boolean; addWorkspace: () => Promise; addWorkspaceFromPath: (path: string) => Promise; + addWorkspacesFromPaths: (paths: string[]) => Promise; setActiveThreadId: (threadId: string | null, workspaceId: string) => void; setActiveTab: (tab: "home" | "projects" | "codex" | "git" | "log") => void; exitDiffView: () => void; @@ -22,6 +23,7 @@ export function useWorkspaceActions({ isCompact, addWorkspace, addWorkspaceFromPath, + addWorkspacesFromPaths, setActiveThreadId, setActiveTab, exitDiffView, @@ -61,6 +63,28 @@ export function useWorkspaceActions({ } }, [addWorkspace, handleWorkspaceAdded, onDebug]); + const handleAddWorkspacesFromPaths = useCallback( + async (paths: string[]) => { + try { + const workspace = await addWorkspacesFromPaths(paths); + if (workspace) { + handleWorkspaceAdded(workspace); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onDebug({ + id: `${Date.now()}-client-add-workspace-error`, + timestamp: Date.now(), + source: "error", + label: "workspace/add error", + payload: message, + }); + alert(`Failed to add workspaces.\n\n${message}`); + } + }, + [addWorkspacesFromPaths, handleWorkspaceAdded, onDebug], + ); + const handleAddWorkspaceFromPath = useCallback( async (path: string) => { try { @@ -129,6 +153,7 @@ export function useWorkspaceActions({ return { handleAddWorkspace, + handleAddWorkspacesFromPaths, handleAddWorkspaceFromPath, handleAddAgent, handleAddWorktreeAgent, diff --git a/src/features/app/orchestration/useLayoutOrchestration.ts b/src/features/app/orchestration/useLayoutOrchestration.ts new file mode 100644 index 0000000000..039c0b3dcd --- /dev/null +++ b/src/features/app/orchestration/useLayoutOrchestration.ts @@ -0,0 +1,90 @@ +import { useMemo, type CSSProperties } from "react"; +import type { AppSettings } from "@/types"; + +type UseAppShellOrchestrationOptions = { + isCompact: boolean; + isPhone: boolean; + isTablet: boolean; + sidebarCollapsed: boolean; + rightPanelCollapsed: boolean; + shouldReduceTransparency: boolean; + isWorkspaceDropActive: boolean; + centerMode: "chat" | "diff"; + selectedDiffPath: string | null; + showComposer: boolean; + activeThreadId: string | null; + sidebarWidth: number; + rightPanelWidth: number; + planPanelHeight: number; + terminalPanelHeight: number; + debugPanelHeight: number; + appSettings: Pick; +}; + +export function useAppShellOrchestration({ + isCompact, + isPhone, + isTablet, + sidebarCollapsed, + rightPanelCollapsed, + shouldReduceTransparency, + isWorkspaceDropActive, + centerMode, + selectedDiffPath, + showComposer, + activeThreadId, + sidebarWidth, + rightPanelWidth, + planPanelHeight, + terminalPanelHeight, + debugPanelHeight, + appSettings, +}: UseAppShellOrchestrationOptions) { + const showGitDetail = Boolean(selectedDiffPath) && isPhone && centerMode === "diff"; + const isThreadOpen = Boolean(activeThreadId && showComposer); + + const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ + isPhone ? " layout-phone" : "" + }${isTablet ? " layout-tablet" : ""}${ + shouldReduceTransparency ? " reduced-transparency" : "" + }${!isCompact && sidebarCollapsed ? " sidebar-collapsed" : ""}${ + !isCompact && rightPanelCollapsed ? " right-panel-collapsed" : "" + }`; + + const appStyle = useMemo( + () => ({ + "--sidebar-width": `${isCompact ? sidebarWidth : sidebarCollapsed ? 0 : sidebarWidth}px`, + "--right-panel-width": `${ + isCompact ? rightPanelWidth : rightPanelCollapsed ? 0 : rightPanelWidth + }px`, + "--plan-panel-height": `${planPanelHeight}px`, + "--terminal-panel-height": `${terminalPanelHeight}px`, + "--debug-panel-height": `${debugPanelHeight}px`, + "--ui-font-family": appSettings.uiFontFamily, + "--code-font-family": appSettings.codeFontFamily, + "--code-font-size": `${appSettings.codeFontSize}px`, + } as CSSProperties), + [ + appSettings.codeFontFamily, + appSettings.codeFontSize, + appSettings.uiFontFamily, + debugPanelHeight, + isCompact, + planPanelHeight, + rightPanelCollapsed, + rightPanelWidth, + sidebarCollapsed, + sidebarWidth, + terminalPanelHeight, + ], + ); + + return { + showGitDetail, + isThreadOpen, + dropOverlayActive: isWorkspaceDropActive, + dropOverlayText: "Drop Project Here", + appClassName, + appStyle, + }; +} diff --git a/src/features/app/orchestration/useThreadCodexOrchestration.ts b/src/features/app/orchestration/useThreadCodexOrchestration.ts new file mode 100644 index 0000000000..7d3f735ada --- /dev/null +++ b/src/features/app/orchestration/useThreadCodexOrchestration.ts @@ -0,0 +1,105 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import type { AccessMode } from "@/types"; +import { useThreadCodexParams } from "@threads/hooks/useThreadCodexParams"; +import { + type PendingNewThreadSeed, +} from "@threads/utils/threadCodexParamsSeed"; + +type ThreadCodexOrchestration = { + accessMode: AccessMode; + setAccessMode: Dispatch>; + preferredModelId: string | null; + setPreferredModelId: Dispatch>; + preferredEffort: string | null; + setPreferredEffort: Dispatch>; + preferredCollabModeId: string | null; + setPreferredCollabModeId: Dispatch>; + threadCodexSelectionKey: string | null; + setThreadCodexSelectionKey: Dispatch>; + threadCodexParamsVersion: number; + getThreadCodexParams: ReturnType["getThreadCodexParams"]; + patchThreadCodexParams: ReturnType["patchThreadCodexParams"]; + persistThreadCodexParams: (patch: { + modelId?: string | null; + effort?: string | null; + accessMode?: AccessMode | null; + collaborationModeId?: string | null; + }) => void; + activeThreadIdRef: MutableRefObject; + pendingNewThreadSeedRef: MutableRefObject; +}; + +type UseThreadCodexOrchestrationParams = { + activeWorkspaceIdForParamsRef: MutableRefObject; +}; + +export function useThreadCodexOrchestration({ + activeWorkspaceIdForParamsRef, +}: UseThreadCodexOrchestrationParams): ThreadCodexOrchestration { + const { + version: threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + } = useThreadCodexParams(); + const [accessMode, setAccessMode] = useState("current"); + const [preferredModelId, setPreferredModelId] = useState(null); + const [preferredEffort, setPreferredEffort] = useState(null); + const [preferredCollabModeId, setPreferredCollabModeId] = useState( + null, + ); + const [threadCodexSelectionKey, setThreadCodexSelectionKey] = useState( + null, + ); + const activeThreadIdRef = useRef(null); + const pendingNewThreadSeedRef = useRef(null); + + const persistThreadCodexParams = useCallback( + (patch: { + modelId?: string | null; + effort?: string | null; + accessMode?: AccessMode | null; + collaborationModeId?: string | null; + }) => { + const workspaceId = activeWorkspaceIdForParamsRef.current; + const threadId = activeThreadIdRef.current; + if (!workspaceId || !threadId) { + return; + } + patchThreadCodexParams(workspaceId, threadId, patch); + }, + [activeWorkspaceIdForParamsRef, patchThreadCodexParams], + ); + + return useMemo( + () => ({ + accessMode, + setAccessMode, + preferredModelId, + setPreferredModelId, + preferredEffort, + setPreferredEffort, + preferredCollabModeId, + setPreferredCollabModeId, + threadCodexSelectionKey, + setThreadCodexSelectionKey, + threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + persistThreadCodexParams, + activeThreadIdRef, + pendingNewThreadSeedRef, + }), + [ + accessMode, + preferredCollabModeId, + preferredEffort, + preferredModelId, + threadCodexSelectionKey, + threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + persistThreadCodexParams, + ], + ); +} diff --git a/src/features/app/orchestration/useThreadOrchestration.ts b/src/features/app/orchestration/useThreadOrchestration.ts new file mode 100644 index 0000000000..40d189b5d0 --- /dev/null +++ b/src/features/app/orchestration/useThreadOrchestration.ts @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import type { Dispatch, MutableRefObject, SetStateAction } from "react"; +import type { AccessMode, AppMention, AppSettings } from "@/types"; +import { useThreadCodexParams } from "@threads/hooks/useThreadCodexParams"; +import { + buildThreadCodexSeedPatch, + createPendingThreadSeed, + resolveThreadCodexState, + type PendingNewThreadSeed, +} from "@threads/utils/threadCodexParamsSeed"; +import { makeThreadCodexParamsKey } from "@threads/utils/threadStorage"; +import { useThreadCodexOrchestration } from "./useThreadCodexOrchestration"; + +type SetState = Dispatch>; + +type PersistThreadCodexParams = ( + patch: { + modelId?: string | null; + effort?: string | null; + accessMode?: AccessMode | null; + collaborationModeId?: string | null; + }, +) => void; + +type UseThreadSelectionHandlersOrchestrationParams = { + appSettingsLoading: boolean; + setAppSettings: SetState; + queueSaveSettings: (next: AppSettings) => Promise; + activeThreadIdRef: MutableRefObject; + setSelectedModelId: (id: string | null) => void; + setSelectedEffort: (effort: string | null) => void; + setSelectedCollaborationModeId: (id: string | null) => void; + setAccessMode: SetState; + persistThreadCodexParams: PersistThreadCodexParams; +}; + +type UseThreadCodexBootstrapOrchestrationParams = { + activeWorkspaceId: string | null | undefined; +}; + +type UseThreadCodexSyncOrchestrationParams = { + activeWorkspaceId: string | null | undefined; + activeThreadId: string | null; + appSettings: Pick< + AppSettings, + "defaultAccessMode" | "lastComposerModelId" | "lastComposerReasoningEffort" + >; + threadCodexParamsVersion: number; + getThreadCodexParams: ReturnType["getThreadCodexParams"]; + patchThreadCodexParams: ReturnType["patchThreadCodexParams"]; + setThreadCodexSelectionKey: SetState; + setAccessMode: SetState; + setPreferredModelId: SetState; + setPreferredEffort: SetState; + setPreferredCollabModeId: SetState; + activeThreadIdRef: MutableRefObject; + pendingNewThreadSeedRef: MutableRefObject; + selectedModelId: string | null; + resolvedEffort: string | null; + accessMode: AccessMode; + selectedCollaborationModeId: string | null; +}; + +type MainTab = "home" | "projects" | "codex" | "git" | "log"; + +type SendOrQueueHandler = ( + text: string, + images: string[], + appMentions?: AppMention[], +) => Promise; + +type UseThreadUiOrchestrationParams = { + activeWorkspaceId: string | null | undefined; + activeThreadId: string | null; + accessMode: AccessMode; + selectedCollaborationModeId: string | null; + pendingNewThreadSeedRef: MutableRefObject; + runWithDraftStart: (runner: () => Promise) => Promise; + handleComposerSend: SendOrQueueHandler; + handleComposerQueue: SendOrQueueHandler; + clearDraftState: () => void; + exitDiffView: () => void; + resetPullRequestSelection: () => void; + selectWorkspace: (workspaceId: string) => void; + setActiveThreadId: (threadId: string | null, workspaceId?: string) => void; + setActiveTab: SetState; + isCompact: boolean; + removeThread: (workspaceId: string, threadId: string) => void; + clearDraftForThread: (threadId: string) => void; + removeImagesForThread: (threadId: string) => void; +}; + +export function useThreadCodexBootstrapOrchestration({ + activeWorkspaceId, +}: UseThreadCodexBootstrapOrchestrationParams) { + const activeWorkspaceIdForParamsRef = useRef(activeWorkspaceId ?? null); + + useEffect(() => { + activeWorkspaceIdForParamsRef.current = activeWorkspaceId ?? null; + }, [activeWorkspaceId]); + + return useThreadCodexOrchestration({ activeWorkspaceIdForParamsRef }); +} + +export function useThreadCodexSyncOrchestration({ + activeWorkspaceId, + activeThreadId, + appSettings, + threadCodexParamsVersion, + getThreadCodexParams, + patchThreadCodexParams, + setThreadCodexSelectionKey, + setAccessMode, + setPreferredModelId, + setPreferredEffort, + setPreferredCollabModeId, + activeThreadIdRef, + pendingNewThreadSeedRef, + selectedModelId, + resolvedEffort, + accessMode, + selectedCollaborationModeId, +}: UseThreadCodexSyncOrchestrationParams) { + useLayoutEffect(() => { + const workspaceId = activeWorkspaceId ?? null; + const threadId = activeThreadId ?? null; + activeThreadIdRef.current = threadId; + + if (!workspaceId) { + return; + } + + const stored = threadId ? getThreadCodexParams(workspaceId, threadId) : null; + const resolved = resolveThreadCodexState({ + workspaceId, + threadId, + defaultAccessMode: appSettings.defaultAccessMode, + lastComposerModelId: appSettings.lastComposerModelId, + lastComposerReasoningEffort: appSettings.lastComposerReasoningEffort, + stored, + pendingSeed: pendingNewThreadSeedRef.current, + }); + + setThreadCodexSelectionKey(resolved.scopeKey); + setAccessMode(resolved.accessMode); + setPreferredModelId(resolved.preferredModelId); + setPreferredEffort(resolved.preferredEffort); + setPreferredCollabModeId(resolved.preferredCollabModeId); + }, [ + activeThreadId, + activeWorkspaceId, + appSettings.defaultAccessMode, + appSettings.lastComposerModelId, + appSettings.lastComposerReasoningEffort, + getThreadCodexParams, + setPreferredCollabModeId, + setPreferredEffort, + setPreferredModelId, + setThreadCodexSelectionKey, + threadCodexParamsVersion, + setAccessMode, + activeThreadIdRef, + pendingNewThreadSeedRef, + ]); + + const seededThreadParamsRef = useRef(new Set()); + useEffect(() => { + const workspaceId = activeWorkspaceId ?? null; + const threadId = activeThreadId ?? null; + if (!workspaceId || !threadId) { + return; + } + + const key = makeThreadCodexParamsKey(workspaceId, threadId); + if (seededThreadParamsRef.current.has(key)) { + return; + } + + const stored = getThreadCodexParams(workspaceId, threadId); + if (stored) { + seededThreadParamsRef.current.add(key); + return; + } + + seededThreadParamsRef.current.add(key); + const pendingSeed = pendingNewThreadSeedRef.current; + patchThreadCodexParams( + workspaceId, + threadId, + buildThreadCodexSeedPatch({ + workspaceId, + selectedModelId, + resolvedEffort, + accessMode, + selectedCollaborationModeId, + pendingSeed, + }), + ); + if (pendingSeed?.workspaceId === workspaceId) { + pendingNewThreadSeedRef.current = null; + } + }, [ + activeThreadId, + activeWorkspaceId, + accessMode, + getThreadCodexParams, + patchThreadCodexParams, + resolvedEffort, + selectedCollaborationModeId, + selectedModelId, + pendingNewThreadSeedRef, + ]); +} + +export function useThreadSelectionHandlersOrchestration({ + appSettingsLoading, + setAppSettings, + queueSaveSettings, + activeThreadIdRef, + setSelectedModelId, + setSelectedEffort, + setSelectedCollaborationModeId, + setAccessMode, + persistThreadCodexParams, +}: UseThreadSelectionHandlersOrchestrationParams) { + const handleSelectModel = useCallback( + (id: string | null) => { + setSelectedModelId(id); + const hasActiveThread = Boolean(activeThreadIdRef.current); + if (!appSettingsLoading && !hasActiveThread) { + setAppSettings((current) => { + if (current.lastComposerModelId === id) { + return current; + } + const nextSettings = { ...current, lastComposerModelId: id }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + } + persistThreadCodexParams({ modelId: id }); + }, + [ + activeThreadIdRef, + appSettingsLoading, + persistThreadCodexParams, + queueSaveSettings, + setAppSettings, + setSelectedModelId, + ], + ); + + const handleSelectEffort = useCallback( + (raw: string | null) => { + const next = typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; + setSelectedEffort(next); + const hasActiveThread = Boolean(activeThreadIdRef.current); + if (!appSettingsLoading && !hasActiveThread) { + setAppSettings((current) => { + if (current.lastComposerReasoningEffort === next) { + return current; + } + const nextSettings = { ...current, lastComposerReasoningEffort: next }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + } + persistThreadCodexParams({ effort: next }); + }, + [ + activeThreadIdRef, + appSettingsLoading, + persistThreadCodexParams, + queueSaveSettings, + setAppSettings, + setSelectedEffort, + ], + ); + + const handleSelectCollaborationMode = useCallback( + (id: string | null) => { + setSelectedCollaborationModeId(id); + persistThreadCodexParams({ collaborationModeId: id }); + }, + [persistThreadCodexParams, setSelectedCollaborationModeId], + ); + + const handleSelectAccessMode = useCallback( + (mode: AccessMode) => { + setAccessMode(mode); + persistThreadCodexParams({ accessMode: mode }); + }, + [persistThreadCodexParams, setAccessMode], + ); + + return { + handleSelectModel, + handleSelectEffort, + handleSelectCollaborationMode, + handleSelectAccessMode, + }; +} + +export function useThreadUiOrchestration({ + activeWorkspaceId, + activeThreadId, + accessMode, + selectedCollaborationModeId, + pendingNewThreadSeedRef, + runWithDraftStart, + handleComposerSend, + handleComposerQueue, + clearDraftState, + exitDiffView, + resetPullRequestSelection, + selectWorkspace, + setActiveThreadId, + setActiveTab, + isCompact, + removeThread, + clearDraftForThread, + removeImagesForThread, +}: UseThreadUiOrchestrationParams) { + const rememberPendingNewThreadSeed = useCallback(() => { + pendingNewThreadSeedRef.current = createPendingThreadSeed({ + activeThreadId: activeThreadId ?? null, + activeWorkspaceId: activeWorkspaceId ?? null, + selectedCollaborationModeId, + accessMode, + }); + }, [ + accessMode, + activeThreadId, + activeWorkspaceId, + pendingNewThreadSeedRef, + selectedCollaborationModeId, + ]); + + const handleComposerSendWithDraftStart = useCallback( + (text: string, images: string[], appMentions?: AppMention[]) => { + rememberPendingNewThreadSeed(); + return runWithDraftStart(() => + appMentions && appMentions.length > 0 + ? handleComposerSend(text, images, appMentions) + : handleComposerSend(text, images), + ); + }, + [handleComposerSend, rememberPendingNewThreadSeed, runWithDraftStart], + ); + + const handleComposerQueueWithDraftStart = useCallback( + (text: string, images: string[], appMentions?: AppMention[]) => { + const runner = activeThreadId + ? () => + appMentions && appMentions.length > 0 + ? handleComposerQueue(text, images, appMentions) + : handleComposerQueue(text, images) + : () => + appMentions && appMentions.length > 0 + ? handleComposerSend(text, images, appMentions) + : handleComposerSend(text, images); + + if (!activeThreadId) { + rememberPendingNewThreadSeed(); + } + return runWithDraftStart(runner); + }, + [ + activeThreadId, + handleComposerQueue, + handleComposerSend, + rememberPendingNewThreadSeed, + runWithDraftStart, + ], + ); + + const handleSelectWorkspaceInstance = useCallback( + (workspaceId: string, threadId: string) => { + exitDiffView(); + resetPullRequestSelection(); + clearDraftState(); + selectWorkspace(workspaceId); + setActiveThreadId(threadId, workspaceId); + if (isCompact) { + setActiveTab("codex"); + } + }, + [ + clearDraftState, + exitDiffView, + isCompact, + resetPullRequestSelection, + selectWorkspace, + setActiveTab, + setActiveThreadId, + ], + ); + + const handleOpenThreadLink = useCallback( + (threadId: string) => { + if (!activeWorkspaceId) { + return; + } + exitDiffView(); + resetPullRequestSelection(); + clearDraftState(); + setActiveThreadId(threadId, activeWorkspaceId); + }, + [ + activeWorkspaceId, + clearDraftState, + exitDiffView, + resetPullRequestSelection, + setActiveThreadId, + ], + ); + + const handleArchiveActiveThread = useCallback(() => { + if (!activeWorkspaceId || !activeThreadId) { + return; + } + removeThread(activeWorkspaceId, activeThreadId); + clearDraftForThread(activeThreadId); + removeImagesForThread(activeThreadId); + }, [ + activeThreadId, + activeWorkspaceId, + clearDraftForThread, + removeImagesForThread, + removeThread, + ]); + + return { + handleComposerSendWithDraftStart, + handleComposerQueueWithDraftStart, + handleSelectWorkspaceInstance, + handleOpenThreadLink, + handleArchiveActiveThread, + }; +} diff --git a/src/features/app/orchestration/useWorkspaceOrchestration.ts b/src/features/app/orchestration/useWorkspaceOrchestration.ts new file mode 100644 index 0000000000..43c4dc4b22 --- /dev/null +++ b/src/features/app/orchestration/useWorkspaceOrchestration.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { WorkspaceInfo } from "@/types"; +import { useLocalUsage } from "@/features/home/hooks/useLocalUsage"; + +type ThreadSummary = { + id: string; + name?: string | null; + updatedAt: number; +}; + +type LastAgentMessage = { + text: string; + timestamp: number; +}; + +type ThreadStatus = { + isProcessing?: boolean; +}; + +type UseWorkspaceInsightsOrchestrationOptions = { + workspaces: WorkspaceInfo[]; + workspacesById: Map; + hasLoaded: boolean; + showHome: boolean; + threadsByWorkspace: Record; + lastAgentMessageByThread: Record; + threadStatusById: Record; + threadListLoadingByWorkspace: Record; + getWorkspaceGroupName: (workspaceId: string) => string | null | undefined; +}; + +type UseWorkspaceOrderingOrchestrationOptions = { + workspaces: WorkspaceInfo[]; + workspacesById: Map; + updateWorkspaceSettings: ( + workspaceId: string, + settings: Partial, + ) => Promise; +}; + +export function useWorkspaceInsightsOrchestration({ + workspaces, + workspacesById, + hasLoaded, + showHome, + threadsByWorkspace, + lastAgentMessageByThread, + threadStatusById, + threadListLoadingByWorkspace, + getWorkspaceGroupName, +}: UseWorkspaceInsightsOrchestrationOptions) { + const latestAgentRuns = useMemo(() => { + const entries: Array<{ + threadId: string; + message: string; + timestamp: number; + projectName: string; + groupName?: string | null; + workspaceId: string; + isProcessing: boolean; + }> = []; + + workspaces.forEach((workspace) => { + const threads = threadsByWorkspace[workspace.id] ?? []; + threads.forEach((thread) => { + const entry = lastAgentMessageByThread[thread.id]; + if (!entry) { + return; + } + entries.push({ + threadId: thread.id, + message: entry.text, + timestamp: entry.timestamp, + projectName: workspace.name, + groupName: getWorkspaceGroupName(workspace.id), + workspaceId: workspace.id, + isProcessing: threadStatusById[thread.id]?.isProcessing ?? false, + }); + }); + }); + + return entries.sort((a, b) => b.timestamp - a.timestamp).slice(0, 3); + }, [ + getWorkspaceGroupName, + lastAgentMessageByThread, + threadStatusById, + threadsByWorkspace, + workspaces, + ]); + + const isLoadingLatestAgents = useMemo( + () => + !hasLoaded || workspaces.some((workspace) => threadListLoadingByWorkspace[workspace.id] ?? false), + [hasLoaded, threadListLoadingByWorkspace, workspaces], + ); + + const [usageMetric, setUsageMetric] = useState<"tokens" | "time">("tokens"); + const [usageWorkspaceId, setUsageWorkspaceId] = useState(null); + + const usageWorkspaceOptions = useMemo( + () => + workspaces.map((workspace) => { + const groupName = getWorkspaceGroupName(workspace.id); + const label = groupName ? `${groupName} / ${workspace.name}` : workspace.name; + return { id: workspace.id, label }; + }), + [getWorkspaceGroupName, workspaces], + ); + + const usageWorkspacePath = useMemo(() => { + if (!usageWorkspaceId) { + return null; + } + return workspacesById.get(usageWorkspaceId)?.path ?? null; + }, [usageWorkspaceId, workspacesById]); + + useEffect(() => { + if (!usageWorkspaceId) { + return; + } + if (workspaces.some((workspace) => workspace.id === usageWorkspaceId)) { + return; + } + setUsageWorkspaceId(null); + }, [usageWorkspaceId, workspaces]); + + const { + snapshot: localUsageSnapshot, + isLoading: isLoadingLocalUsage, + error: localUsageError, + refresh: refreshLocalUsage, + } = useLocalUsage(showHome, usageWorkspacePath); + + return { + latestAgentRuns, + isLoadingLatestAgents, + usageMetric, + setUsageMetric, + usageWorkspaceId, + setUsageWorkspaceId, + usageWorkspaceOptions, + localUsageSnapshot, + isLoadingLocalUsage, + localUsageError, + refreshLocalUsage, + }; +} + +export function useWorkspaceOrderingOrchestration({ + workspaces, + workspacesById, + updateWorkspaceSettings, +}: UseWorkspaceOrderingOrchestrationOptions) { + const orderValue = useCallback( + (entry: WorkspaceInfo) => + typeof entry.settings.sortOrder === "number" + ? entry.settings.sortOrder + : Number.MAX_SAFE_INTEGER, + [], + ); + + const handleMoveWorkspace = useCallback( + async (workspaceId: string, direction: "up" | "down") => { + const target = workspacesById.get(workspaceId); + if (!target || (target.kind ?? "main") === "worktree") { + return; + } + + const targetGroupId = target.settings.groupId ?? null; + const ordered = workspaces + .filter( + (entry) => + (entry.kind ?? "main") !== "worktree" && + (entry.settings.groupId ?? null) === targetGroupId, + ) + .slice() + .sort((a, b) => { + const orderDiff = orderValue(a) - orderValue(b); + if (orderDiff !== 0) { + return orderDiff; + } + return a.name.localeCompare(b.name); + }); + + const index = ordered.findIndex((entry) => entry.id === workspaceId); + if (index === -1) { + return; + } + + const nextIndex = direction === "up" ? index - 1 : index + 1; + if (nextIndex < 0 || nextIndex >= ordered.length) { + return; + } + + const next = ordered.slice(); + const temp = next[index]; + next[index] = next[nextIndex]; + next[nextIndex] = temp; + + await Promise.all( + next.map((entry, idx) => + updateWorkspaceSettings(entry.id, { + sortOrder: idx, + }), + ), + ); + }, + [orderValue, updateWorkspaceSettings, workspaces, workspacesById], + ); + + return { handleMoveWorkspace }; +} diff --git a/src/features/apps/hooks/useApps.test.ts b/src/features/apps/hooks/useApps.test.ts index ee9c426dca..acf7dc46a0 100644 --- a/src/features/apps/hooks/useApps.test.ts +++ b/src/features/apps/hooks/useApps.test.ts @@ -3,13 +3,19 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { useApps } from "./useApps"; import { getAppsList } from "../../../services/tauri"; -import type { WorkspaceInfo } from "../../../types"; +import { subscribeAppServerEvents } from "../../../services/events"; +import type { AppServerEvent, WorkspaceInfo } from "../../../types"; vi.mock("../../../services/tauri", () => ({ getAppsList: vi.fn(), })); +vi.mock("../../../services/events", () => ({ + subscribeAppServerEvents: vi.fn(), +})); + const getAppsListMock = vi.mocked(getAppsList); +const subscribeAppServerEventsMock = vi.mocked(subscribeAppServerEvents); const workspace: WorkspaceInfo = { id: "workspace-1", @@ -20,8 +26,15 @@ const workspace: WorkspaceInfo = { }; describe("useApps", () => { + let appServerListener: ((event: AppServerEvent) => void) | null = null; + beforeEach(() => { vi.clearAllMocks(); + appServerListener = null; + subscribeAppServerEventsMock.mockImplementation((listener) => { + appServerListener = listener; + return () => {}; + }); }); afterEach(() => { @@ -53,7 +66,13 @@ describe("useApps", () => { await waitFor(() => { expect(getAppsListMock).toHaveBeenCalledTimes(1); - expect(getAppsListMock).toHaveBeenNthCalledWith(1, "workspace-1", null, 100); + expect(getAppsListMock).toHaveBeenNthCalledWith( + 1, + "workspace-1", + null, + 100, + null, + ); }); const workspaceTwo: WorkspaceInfo = { @@ -72,7 +91,13 @@ describe("useApps", () => { await waitFor(() => { expect(getAppsListMock).toHaveBeenCalledTimes(2); - expect(getAppsListMock).toHaveBeenNthCalledWith(2, "workspace-2", null, 100); + expect(getAppsListMock).toHaveBeenNthCalledWith( + 2, + "workspace-2", + null, + 100, + null, + ); expect(result.current.apps).toEqual([]); }); @@ -120,4 +145,174 @@ describe("useApps", () => { expect.objectContaining({ id: "ok", name: "Recovered App" }), ]); }); + + it("re-fetches when active thread changes for the same workspace", async () => { + getAppsListMock + .mockResolvedValueOnce({ + data: [{ id: "app-a", name: "App A", isAccessible: true }], + }) + .mockResolvedValueOnce({ + data: [{ id: "app-b", name: "App B", isAccessible: true }], + }); + + const { result, rerender } = renderHook( + ({ activeThreadId }) => + useApps({ + activeWorkspace: workspace, + activeThreadId, + enabled: true, + }), + { initialProps: { activeThreadId: "thread-1" } }, + ); + + await waitFor(() => { + expect(getAppsListMock).toHaveBeenCalledWith( + "workspace-1", + null, + 100, + "thread-1", + ); + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "app-a", name: "App A" }), + ]); + }); + + rerender({ activeThreadId: "thread-2" }); + + await waitFor(() => { + expect(getAppsListMock).toHaveBeenCalledWith( + "workspace-1", + null, + 100, + "thread-2", + ); + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "app-b", name: "App B" }), + ]); + }); + }); + + it("clears stale apps immediately when switching to a thread without cached apps", async () => { + let resolveSecond: ((value: unknown) => void) | null = null; + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + + getAppsListMock + .mockResolvedValueOnce({ + data: [{ id: "thread-1-app", name: "Thread 1 App", isAccessible: true }], + }) + .mockImplementationOnce(() => second as Promise); + + const { result, rerender } = renderHook( + ({ activeThreadId }) => + useApps({ + activeWorkspace: workspace, + activeThreadId, + enabled: true, + }), + { initialProps: { activeThreadId: "thread-1" } }, + ); + + await waitFor(() => { + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "thread-1-app", name: "Thread 1 App" }), + ]); + }); + + rerender({ activeThreadId: "thread-2" }); + + expect(result.current.apps).toEqual([]); + + await act(async () => { + resolveSecond?.({ + data: [{ id: "thread-2-app", name: "Thread 2 App", isAccessible: true }], + }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "thread-2-app", name: "Thread 2 App" }), + ]); + }); + }); + + it("applies app/list/updated notifications immediately", async () => { + getAppsListMock.mockResolvedValueOnce({ + data: [{ id: "initial", name: "Initial", isAccessible: false }], + }); + + const { result } = renderHook(() => + useApps({ + activeWorkspace: workspace, + activeThreadId: "thread-1", + enabled: true, + }), + ); + + await waitFor(() => { + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "initial", name: "Initial" }), + ]); + }); + + act(() => { + appServerListener?.({ + workspace_id: "workspace-1", + message: { + method: "app/list/updated", + params: { + threadId: "thread-1", + data: [ + { id: "live", name: "Live App", isAccessible: true }, + { id: "hidden", name: "Hidden App", isAccessible: false }, + ], + }, + }, + }); + }); + + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "live", name: "Live App", isAccessible: true }), + expect.objectContaining({ id: "hidden", name: "Hidden App", isAccessible: false }), + ]); + }); + + it("ignores app/list/updated notifications for non-active threads", async () => { + getAppsListMock.mockResolvedValueOnce({ + data: [{ id: "thread-2-app", name: "Thread 2 App", isAccessible: true }], + }); + + const { result } = renderHook(() => + useApps({ + activeWorkspace: workspace, + activeThreadId: "thread-2", + enabled: true, + }), + ); + + await waitFor(() => { + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "thread-2-app", name: "Thread 2 App" }), + ]); + }); + + act(() => { + appServerListener?.({ + workspace_id: "workspace-1", + message: { + method: "app/list/updated", + params: { + threadId: "thread-1", + data: [{ id: "wrong-thread", name: "Wrong Thread App", isAccessible: true }], + }, + }, + }); + }); + + expect(result.current.apps).toEqual([ + expect.objectContaining({ id: "thread-2-app", name: "Thread 2 App" }), + ]); + }); }); diff --git a/src/features/apps/hooks/useApps.ts b/src/features/apps/hooks/useApps.ts index 5c7c34dd3a..69358fde46 100644 --- a/src/features/apps/hooks/useApps.ts +++ b/src/features/apps/hooks/useApps.ts @@ -1,9 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AppOption, DebugEntry, WorkspaceInfo } from "../../../types"; import { getAppsList } from "../../../services/tauri"; +import { subscribeAppServerEvents } from "../../../services/events"; +import { getAppServerParams, isAppListUpdatedEvent } from "../../../utils/appServerEvents"; type UseAppsOptions = { activeWorkspace: WorkspaceInfo | null; + activeThreadId?: string | null; enabled: boolean; onDebug?: (entry: DebugEntry) => void; }; @@ -41,39 +44,69 @@ function normalizeAppsResponse(response: any): AppOption[] { }); } -export function useApps({ activeWorkspace, enabled, onDebug }: UseAppsOptions) { +type AppsFetchTarget = { + workspaceId: string; + threadId: string | null; +}; + +function buildFetchKey(workspaceId: string, threadId: string | null): string { + return `${workspaceId}::${threadId ?? ""}`; +} + +export function useApps({ + activeWorkspace, + activeThreadId = null, + enabled, + onDebug, +}: UseAppsOptions) { const [apps, setApps] = useState([]); const [retryVersion, setRetryVersion] = useState(0); - const lastFetchedWorkspaceId = useRef(null); - const inFlightWorkspaceId = useRef(null); - const pendingWorkspaceId = useRef(null); + const appsByKey = useRef>({}); + const lastFetchedKey = useRef(null); + const visibleKey = useRef(null); + const inFlightKey = useRef(null); + const pendingTarget = useRef(null); const retryTimer = useRef | null>(null); const workspaceId = activeWorkspace?.id ?? null; + const threadId = + typeof activeThreadId === "string" && activeThreadId.trim().length > 0 + ? activeThreadId + : null; const isConnected = Boolean(activeWorkspace?.connected); const workspaceIdRef = useRef(workspaceId); + const threadIdRef = useRef(threadId); const enabledRef = useRef(enabled); const connectedRef = useRef(isConnected); workspaceIdRef.current = workspaceId; + threadIdRef.current = threadId; enabledRef.current = enabled; connectedRef.current = isConnected; - const executeFetch = useCallback(async (targetWorkspaceId: string) => { - if (inFlightWorkspaceId.current) { - pendingWorkspaceId.current = targetWorkspaceId; + const executeFetch = useCallback(async (target: AppsFetchTarget) => { + const targetKey = buildFetchKey(target.workspaceId, target.threadId); + if (inFlightKey.current) { + pendingTarget.current = target; return; } - inFlightWorkspaceId.current = targetWorkspaceId; + inFlightKey.current = targetKey; onDebug?.({ id: `${Date.now()}-client-apps-list`, timestamp: Date.now(), source: "client", label: "app/list", - payload: { workspaceId: targetWorkspaceId }, + payload: { workspaceId: target.workspaceId, threadId: target.threadId }, }); try { - const response = await getAppsList(targetWorkspaceId, null, 100); + const response = await getAppsList( + target.workspaceId, + null, + 100, + target.threadId, + ); + const nextApps = normalizeAppsResponse(response); + appsByKey.current[targetKey] = nextApps; onDebug?.({ id: `${Date.now()}-server-apps-list`, timestamp: Date.now(), @@ -82,13 +115,15 @@ export function useApps({ activeWorkspace, enabled, onDebug }: UseAppsOptions) { payload: response, }); if ( - workspaceIdRef.current === targetWorkspaceId && + workspaceIdRef.current === target.workspaceId && + threadIdRef.current === target.threadId && enabledRef.current && connectedRef.current ) { - setApps(normalizeAppsResponse(response)); + setApps(nextApps); + visibleKey.current = targetKey; } - lastFetchedWorkspaceId.current = targetWorkspaceId; + lastFetchedKey.current = targetKey; if (retryTimer.current) { clearTimeout(retryTimer.current); retryTimer.current = null; @@ -102,7 +137,8 @@ export function useApps({ activeWorkspace, enabled, onDebug }: UseAppsOptions) { payload: error instanceof Error ? error.message : String(error), }); if ( - workspaceIdRef.current === targetWorkspaceId && + workspaceIdRef.current === target.workspaceId && + threadIdRef.current === target.threadId && enabledRef.current && connectedRef.current && !retryTimer.current @@ -113,14 +149,14 @@ export function useApps({ activeWorkspace, enabled, onDebug }: UseAppsOptions) { }, 1500); } } finally { - inFlightWorkspaceId.current = null; - if ( - pendingWorkspaceId.current && - pendingWorkspaceId.current !== targetWorkspaceId - ) { - const pending = pendingWorkspaceId.current; - pendingWorkspaceId.current = null; - if (pending === workspaceIdRef.current) { + inFlightKey.current = null; + const pending = pendingTarget.current; + if (pending && buildFetchKey(pending.workspaceId, pending.threadId) !== targetKey) { + pendingTarget.current = null; + if ( + pending.workspaceId === workspaceIdRef.current && + pending.threadId === threadIdRef.current + ) { void executeFetch(pending); } } @@ -130,32 +166,90 @@ export function useApps({ activeWorkspace, enabled, onDebug }: UseAppsOptions) { const refreshApps = useCallback(async () => { if (!workspaceId || !isConnected || !enabled) { setApps([]); - lastFetchedWorkspaceId.current = null; + lastFetchedKey.current = null; + visibleKey.current = null; + pendingTarget.current = null; if (retryTimer.current) { clearTimeout(retryTimer.current); retryTimer.current = null; } return; } - void executeFetch(workspaceId); - }, [enabled, executeFetch, isConnected, workspaceId]); + void executeFetch({ workspaceId, threadId }); + }, [enabled, executeFetch, isConnected, threadId, workspaceId]); useEffect(() => { if (!workspaceId || !isConnected || !enabled) { setApps([]); - lastFetchedWorkspaceId.current = null; - pendingWorkspaceId.current = null; + lastFetchedKey.current = null; + visibleKey.current = null; + pendingTarget.current = null; if (retryTimer.current) { clearTimeout(retryTimer.current); retryTimer.current = null; } return; } - if (lastFetchedWorkspaceId.current === workspaceId) { + const currentKey = buildFetchKey(workspaceId, threadId); + if (visibleKey.current !== currentKey) { + setApps(appsByKey.current[currentKey] ?? []); + visibleKey.current = currentKey; + } + if (lastFetchedKey.current === currentKey) { return; } void refreshApps(); - }, [enabled, isConnected, refreshApps, retryVersion, workspaceId]); + }, [enabled, isConnected, refreshApps, retryVersion, threadId, workspaceId]); + + useEffect(() => { + if (!workspaceId || !isConnected || !enabled) { + return; + } + + return subscribeAppServerEvents((event) => { + if (event.workspace_id !== workspaceId) { + return; + } + if (!isAppListUpdatedEvent(event)) { + return; + } + + const params = getAppServerParams(event); + const eventThreadIdRaw = + params.threadId ?? + params.thread_id ?? + (typeof params.thread === "object" && + params.thread !== null && + "id" in params.thread + ? (params.thread as { id?: unknown }).id + : null); + const eventThreadId = + typeof eventThreadIdRaw === "string" && eventThreadIdRaw.trim().length > 0 + ? eventThreadIdRaw + : null; + const currentThreadId = threadIdRef.current; + if (eventThreadId && eventThreadId !== currentThreadId) { + return; + } + if (!Array.isArray(params.data)) { + return; + } + + onDebug?.({ + id: `${Date.now()}-server-apps-list-updated`, + timestamp: Date.now(), + source: "server", + label: "app/list updated", + payload: event, + }); + const currentKey = buildFetchKey(workspaceId, threadIdRef.current); + const nextApps = normalizeAppsResponse({ data: params.data }); + appsByKey.current[currentKey] = nextApps; + setApps(nextApps); + visibleKey.current = currentKey; + lastFetchedKey.current = currentKey; + }); + }, [enabled, isConnected, onDebug, workspaceId]); useEffect( () => () => { diff --git a/src/features/apps/utils/appMentions.test.ts b/src/features/apps/utils/appMentions.test.ts new file mode 100644 index 0000000000..f4051c0593 --- /dev/null +++ b/src/features/apps/utils/appMentions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + collectMentionNames, + connectorMentionSlug, + resolveBoundAppMentions, +} from "./appMentions"; + +describe("connectorMentionSlug", () => { + it("normalizes connector labels to mention slugs", () => { + expect(connectorMentionSlug("Calendar App")).toBe("calendar-app"); + expect(connectorMentionSlug("Miro+Boards")).toBe("miro-boards"); + }); +}); + +describe("resolveBoundAppMentions", () => { + it("returns only mentions that still appear in the message text", () => { + const mentions = resolveBoundAppMentions("check $calendar-app and $notes", [ + { + slug: "calendar-app", + mention: { name: "Calendar App", path: "app://connector_calendar" }, + }, + { + slug: "drive", + mention: { name: "Drive", path: "app://connector_drive" }, + }, + ]); + + expect(mentions).toEqual([ + { name: "Calendar App", path: "app://connector_calendar" }, + ]); + }); + + it("collects mention names with valid boundaries", () => { + expect(Array.from(collectMentionNames("$calendar and $drive")).sort()).toEqual([ + "calendar", + "drive", + ]); + expect(Array.from(collectMentionNames("foo$calendar $")).sort()).toEqual([]); + }); +}); diff --git a/src/features/apps/utils/appMentions.ts b/src/features/apps/utils/appMentions.ts new file mode 100644 index 0000000000..e002baf8de --- /dev/null +++ b/src/features/apps/utils/appMentions.ts @@ -0,0 +1,71 @@ +import type { AppMention } from "../../../types"; + +export type AppMentionBinding = { + slug: string; + mention: AppMention; +}; + +const MENTION_NAME_CHAR = /[A-Za-z0-9_-]/; + +export function connectorMentionSlug(name: string): string { + let normalized = ""; + for (const character of name) { + if (/[A-Za-z0-9]/.test(character)) { + normalized += character.toLowerCase(); + } else { + normalized += "-"; + } + } + const trimmed = normalized.replace(/^-+|-+$/g, ""); + return trimmed || "app"; +} + +export function collectMentionNames(text: string): Set { + const names = new Set(); + for (let index = 0; index < text.length; index += 1) { + if (text[index] !== "$") { + continue; + } + const prev = index > 0 ? text[index - 1] : ""; + if (prev && MENTION_NAME_CHAR.test(prev)) { + continue; + } + let end = index + 1; + while (end < text.length && MENTION_NAME_CHAR.test(text[end])) { + end += 1; + } + if (end === index + 1) { + continue; + } + names.add(text.slice(index + 1, end).toLowerCase()); + index = end - 1; + } + return names; +} + +export function resolveBoundAppMentions( + text: string, + bindings: AppMentionBinding[], +): AppMention[] { + if (!text || bindings.length === 0) { + return []; + } + const names = collectMentionNames(text); + if (names.size === 0) { + return []; + } + + const seenPaths = new Set(); + const mentions: AppMention[] = []; + for (const binding of bindings) { + if (!names.has(binding.slug)) { + continue; + } + if (seenPaths.has(binding.mention.path)) { + continue; + } + seenPaths.add(binding.mention.path); + mentions.push(binding.mention); + } + return mentions; +} diff --git a/src/features/collaboration/hooks/useCollaborationModes.test.tsx b/src/features/collaboration/hooks/useCollaborationModes.test.tsx index 37ee58e362..f2d90168e0 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.test.tsx +++ b/src/features/collaboration/hooks/useCollaborationModes.test.tsx @@ -130,4 +130,139 @@ describe("useCollaborationModes", () => { ]), ); }); + + it("resets to the workspace default when selectionKey changes and preferredModeId is null", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + ({ + workspace, + enabled, + preferredModeId, + selectionKey, + }: { + workspace: WorkspaceInfo | null; + enabled: boolean; + preferredModeId: string | null; + selectionKey: string | null; + }) => + useCollaborationModes({ + activeWorkspace: workspace, + enabled, + preferredModeId, + selectionKey, + }), + { + initialProps: { + workspace: workspaceOne, + enabled: true, + preferredModeId: "default" as string | null, + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => expect(result.current.selectedCollaborationModeId).toBe("default")); + + act(() => { + result.current.setSelectedCollaborationModeId("plan"); + }); + expect(result.current.selectedCollaborationModeId).toBe("plan"); + + // Thread switch with no stored override: preferredModeId is null. + rerender({ + workspace: workspaceOne, + enabled: true, + preferredModeId: null, + selectionKey: "thread-b", + }); + + expect(result.current.selectedCollaborationModeId).toBe("default"); + }); + + it("falls back to the workspace default when the preferredModeId is stale", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + (props: { + enabled: boolean; + preferredModeId: string | null; + selectionKey: string; + }) => + useCollaborationModes({ + activeWorkspace: workspaceOne, + enabled: props.enabled, + preferredModeId: props.preferredModeId, + selectionKey: props.selectionKey, + }), + { + initialProps: { + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => { + expect(result.current.collaborationModes.length).toBeGreaterThan(0); + }); + expect(result.current.selectedCollaborationModeId).toBe("plan"); + + rerender({ + enabled: true, + preferredModeId: "stale-mode-id", + selectionKey: "thread-b", + }); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("default"); + }); + }); + + it("reapplies preferred mode when collaboration is re-enabled on the same thread", async () => { + vi.mocked(getCollaborationModes).mockResolvedValue(makeModesResponse()); + + const { result, rerender } = renderHook( + (props: { + enabled: boolean; + preferredModeId: string | null; + selectionKey: string; + }) => + useCollaborationModes({ + activeWorkspace: workspaceOne, + enabled: props.enabled, + preferredModeId: props.preferredModeId, + selectionKey: props.selectionKey, + }), + { + initialProps: { + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }, + }, + ); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("plan"); + }); + + rerender({ + enabled: false, + preferredModeId: "plan", + selectionKey: "thread-a", + }); + expect(result.current.selectedCollaborationModeId).toBeNull(); + + rerender({ + enabled: true, + preferredModeId: "plan", + selectionKey: "thread-a", + }); + + await waitFor(() => { + expect(result.current.selectedCollaborationModeId).toBe("plan"); + }); + }); }); diff --git a/src/features/collaboration/hooks/useCollaborationModes.ts b/src/features/collaboration/hooks/useCollaborationModes.ts index 71ab17e733..bb241e1a2f 100644 --- a/src/features/collaboration/hooks/useCollaborationModes.ts +++ b/src/features/collaboration/hooks/useCollaborationModes.ts @@ -9,12 +9,33 @@ import { getCollaborationModes } from "../../../services/tauri"; type UseCollaborationModesOptions = { activeWorkspace: WorkspaceInfo | null; enabled: boolean; + preferredModeId?: string | null; + selectionKey?: string | null; onDebug?: (entry: DebugEntry) => void; }; +function pickWorkspaceDefaultModeId(modes: CollaborationModeOption[]): string | null { + return ( + modes.find( + (mode) => + mode.id.trim().toLowerCase() === "default" || + mode.mode.trim().toLowerCase() === "default", + )?.id ?? + modes.find( + (mode) => + mode.id.trim().toLowerCase() === "code" || + mode.mode.trim().toLowerCase() === "code", + )?.id ?? + modes[0]?.id ?? + null + ); +} + export function useCollaborationModes({ activeWorkspace, enabled, + preferredModeId = null, + selectionKey = null, onDebug, }: UseCollaborationModesOptions) { const [modes, setModes] = useState([]); @@ -23,6 +44,8 @@ export function useCollaborationModes({ const previousWorkspaceId = useRef(null); const inFlight = useRef(false); const selectedModeIdRef = useRef(null); + const lastSelectionKey = useRef(null); + const lastEnabled = useRef(enabled); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); @@ -136,26 +159,14 @@ export function useCollaborationModes({ .filter((mode): mode is CollaborationModeOption => mode !== null); setModes(data); lastFetchedWorkspaceId.current = workspaceId; - const preferredModeId = - data.find( - (mode) => - mode.id.trim().toLowerCase() === "default" || - mode.mode.trim().toLowerCase() === "default", - )?.id ?? - data.find( - (mode) => - mode.id.trim().toLowerCase() === "code" || - mode.mode.trim().toLowerCase() === "code", - )?.id ?? - data[0]?.id ?? - null; + const workspaceDefaultModeId = pickWorkspaceDefaultModeId(data); setSelectedModeId((currentSelection) => { const selection = currentSelection ?? selectedModeIdRef.current; if (!selection) { - return preferredModeId; + return workspaceDefaultModeId; } if (!data.some((mode) => mode.id === selection)) { - return preferredModeId; + return workspaceDefaultModeId; } return selection; }); @@ -176,6 +187,33 @@ export function useCollaborationModes({ selectedModeIdRef.current = selectedModeId; }, [selectedModeId]); + useEffect(() => { + const wasEnabled = lastEnabled.current; + lastEnabled.current = enabled; + if (!enabled) { + return; + } + const enabledJustReenabled = !wasEnabled; + if (!enabledJustReenabled && selectionKey === lastSelectionKey.current) { + return; + } + lastSelectionKey.current = selectionKey; + // When switching threads, prefer the per-thread override. If there is no stored override, + // reset to the workspace default instead of carrying over the previous thread's selection. + // Also validate that a stored override still exists; otherwise fall back to the workspace default + // so collaboration payload generation remains enabled. + setSelectedModeId(() => { + if (!modes.length) { + // If modes aren't loaded yet, keep the preferred ID (if any) until refresh validates it. + return preferredModeId; + } + if (preferredModeId && modes.some((mode) => mode.id === preferredModeId)) { + return preferredModeId; + } + return pickWorkspaceDefaultModeId(modes); + }); + }, [enabled, modes, preferredModeId, selectionKey]); + useEffect(() => { if (previousWorkspaceId.current !== workspaceId) { previousWorkspaceId.current = workspaceId; diff --git a/src/features/composer/components/Composer.tsx b/src/features/composer/components/Composer.tsx index efc53102d5..8ea5889f7c 100644 --- a/src/features/composer/components/Composer.tsx +++ b/src/features/composer/components/Composer.tsx @@ -9,6 +9,7 @@ import { type ClipboardEvent, } from "react"; import type { + AppMention, AppOption, ComposerEditorSettings, CustomPromptOption, @@ -22,6 +23,11 @@ import type { } from "../../threads/hooks/useReviewPrompt"; import { computeDictationInsertion } from "../../../utils/dictation"; import { isComposingEvent } from "../../../utils/keys"; +import { + connectorMentionSlug, + resolveBoundAppMentions, + type AppMentionBinding, +} from "../../apps/utils/appMentions"; import { getFenceTriggerLine, getLineIndent, @@ -39,8 +45,8 @@ import { ComposerQueue } from "./ComposerQueue"; import { isMobilePlatform } from "../../../utils/platformPaths"; type ComposerProps = { - onSend: (text: string, images: string[]) => void; - onQueue: (text: string, images: string[]) => void; + onSend: (text: string, images: string[], appMentions?: AppMention[]) => void; + onQueue: (text: string, images: string[], appMentions?: AppMention[]) => void; onStop: () => void; canStop: boolean; disabled?: boolean; @@ -120,6 +126,13 @@ type ComposerProps = { onReviewPromptUpdateCustomInstructions?: (value: string) => void; onReviewPromptConfirmCustom?: () => Promise; onFileAutocompleteActiveChange?: (active: boolean) => void; + contextActions?: { + id: string; + label: string; + title?: string; + disabled?: boolean; + onSelect: () => void | Promise; + }[]; }; const DEFAULT_EDITOR_SETTINGS: ComposerEditorSettings = { @@ -210,9 +223,11 @@ export const Composer = memo(function Composer({ onReviewPromptUpdateCustomInstructions, onReviewPromptConfirmCustom, onFileAutocompleteActiveChange, + contextActions = [], }: ComposerProps) { const [text, setText] = useState(draftText); const [selectionStart, setSelectionStart] = useState(null); + const [appMentionBindings, setAppMentionBindings] = useState([]); const [suggestionsStyle, setSuggestionsStyle] = useState< CSSProperties | undefined >(undefined); @@ -243,6 +258,15 @@ export const Composer = memo(function Composer({ [onDraftChange], ); + const bindingsFromMentions = useCallback( + (mentions?: AppMention[]) => + (mentions ?? []).map((mention) => ({ + slug: connectorMentionSlug(mention.name), + mention, + })), + [], + ); + const { isAutocompleteOpen, autocompleteMatches, @@ -266,6 +290,32 @@ export const Composer = memo(function Composer({ textareaRef, setText: setComposerText, setSelectionStart, + onItemApplied: (item, context) => { + if (context.triggerChar !== "$" || item.group !== "Apps" || !item.mentionPath) { + return; + } + const slug = context.insertedText.trim().toLowerCase(); + if (!slug) { + return; + } + const nextBinding: AppMentionBinding = { + slug, + mention: { + name: item.label, + path: item.mentionPath, + }, + }; + setAppMentionBindings((prev) => { + const filtered = prev.filter( + (binding) => + !( + binding.slug === nextBinding.slug && + binding.mention.path === nextBinding.mention.path + ), + ); + return [...filtered, nextBinding]; + }); + }, }); useEffect(() => { onFileAutocompleteActiveChange?.(fileTriggerActive); @@ -340,10 +390,17 @@ export const Composer = memo(function Composer({ if (trimmed) { recordHistory(trimmed); } - onSend(trimmed, attachedImages); + const resolvedMentions = resolveBoundAppMentions(trimmed, appMentionBindings); + if (resolvedMentions.length > 0) { + onSend(trimmed, attachedImages, resolvedMentions); + } else { + onSend(trimmed, attachedImages); + } resetHistoryNavigation(); setComposerText(""); + setAppMentionBindings([]); }, [ + appMentionBindings, attachedImages, disabled, onSend, @@ -364,10 +421,17 @@ export const Composer = memo(function Composer({ if (trimmed) { recordHistory(trimmed); } - onQueue(trimmed, attachedImages); + const resolvedMentions = resolveBoundAppMentions(trimmed, appMentionBindings); + if (resolvedMentions.length > 0) { + onQueue(trimmed, attachedImages, resolvedMentions); + } else { + onQueue(trimmed, attachedImages); + } resetHistoryNavigation(); setComposerText(""); + setAppMentionBindings([]); }, [ + appMentionBindings, attachedImages, disabled, onQueue, @@ -377,23 +441,41 @@ export const Composer = memo(function Composer({ text, ]); + useEffect(() => { + setAppMentionBindings([]); + }, [historyKey]); + useEffect(() => { if (!prefillDraft) { return; } setComposerText(prefillDraft.text); + setAppMentionBindings(bindingsFromMentions(prefillDraft.appMentions)); resetHistoryNavigation(); onPrefillHandled?.(prefillDraft.id); - }, [onPrefillHandled, prefillDraft, resetHistoryNavigation, setComposerText]); + }, [ + bindingsFromMentions, + onPrefillHandled, + prefillDraft, + resetHistoryNavigation, + setComposerText, + ]); useEffect(() => { if (!insertText) { return; } setComposerText(insertText.text); + setAppMentionBindings(bindingsFromMentions(insertText.appMentions)); resetHistoryNavigation(); onInsertHandled?.(insertText.id); - }, [insertText, onInsertHandled, resetHistoryNavigation, setComposerText]); + }, [ + bindingsFromMentions, + insertText, + onInsertHandled, + resetHistoryNavigation, + setComposerText, + ]); useEffect(() => { if (!dictationTranscript) { @@ -556,6 +638,24 @@ export const Composer = memo(function Composer({ onEditQueued={onEditQueued} onDeleteQueued={onDeleteQueued} /> + {contextActions.length > 0 ? ( +
+ {contextActions.map((action) => ( + + ))} +
+ ) : null} ({ subscribeWindowDragDrop: vi.fn(() => () => {}), @@ -24,10 +25,11 @@ vi.mock("../../../utils/platformPaths", async () => { }); type HarnessProps = { - onSend: (text: string, images: string[]) => void; + onSend: (text: string, images: string[], appMentions?: AppMention[]) => void; + apps?: AppOption[]; }; -function ComposerHarness({ onSend }: HarnessProps) { +function ComposerHarness({ onSend, apps = [] }: HarnessProps) { const [draftText, setDraftText] = useState(""); const textareaRef = useRef(null); @@ -53,7 +55,7 @@ function ComposerHarness({ onSend }: HarnessProps) { accessMode="current" onSelectAccessMode={() => {}} skills={[]} - apps={[]} + apps={apps} prompts={[]} files={[]} draftText={draftText} @@ -109,4 +111,33 @@ describe("Composer send triggers", () => { expect(onSend).toHaveBeenCalledWith("dismiss keyboard", []); expect(blurSpy).toHaveBeenCalledTimes(1); }); + + it("sends explicit app mentions when an app autocomplete item is selected", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByRole("textbox"); + fireEvent.change(textarea, { target: { value: "$cal" } }); + fireEvent.keyDown(textarea, { key: "Tab" }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend).toHaveBeenCalledWith( + "$calendar-app", + [], + [{ name: "Calendar App", path: "app://connector_calendar" }], + ); + }); }); diff --git a/src/features/composer/hooks/useComposerAutocomplete.ts b/src/features/composer/hooks/useComposerAutocomplete.ts index 960d6fbb47..219631bb15 100644 --- a/src/features/composer/hooks/useComposerAutocomplete.ts +++ b/src/features/composer/hooks/useComposerAutocomplete.ts @@ -8,6 +8,7 @@ export type AutocompleteItem = { hint?: string; cursorOffset?: number; group?: "Files" | "Skills" | "Apps" | "Slash" | "Prompts"; + mentionPath?: string; }; export type AutocompleteTrigger = { diff --git a/src/features/composer/hooks/useComposerAutocompleteState.test.tsx b/src/features/composer/hooks/useComposerAutocompleteState.test.tsx index 43145bc99c..84fdbf5785 100644 --- a/src/features/composer/hooks/useComposerAutocompleteState.test.tsx +++ b/src/features/composer/hooks/useComposerAutocompleteState.test.tsx @@ -174,8 +174,8 @@ describe("useComposerAutocompleteState $ completions", () => { ], apps: [ { - id: "calendar", - name: "Calendar", + id: "connector_calendar", + name: "Calendar App", description: "Calendar app", isAccessible: true, installUrl: null, @@ -201,11 +201,12 @@ describe("useComposerAutocompleteState $ completions", () => { const ids = result.current.autocompleteMatches.map((item) => item.id); const groups = result.current.autocompleteMatches.map((item) => item.group); const appSuggestion = result.current.autocompleteMatches.find( - (item) => item.id === "app:calendar", + (item) => item.id === "app:connector_calendar", ); - expect(ids).toEqual(["skill:skill-a", "skill:skill-b", "app:calendar"]); + expect(ids).toEqual(["skill:skill-a", "skill:skill-b", "app:connector_calendar"]); expect(groups).toEqual(["Skills", "Skills", "Apps"]); expect(ids).not.toContain("app:not-ready"); - expect(appSuggestion?.insertText).toBe("calendar"); + expect(appSuggestion?.insertText).toBe("calendar-app"); + expect(appSuggestion?.mentionPath).toBe("app://connector_calendar"); }); }); diff --git a/src/features/composer/hooks/useComposerAutocompleteState.ts b/src/features/composer/hooks/useComposerAutocompleteState.ts index 194f3d1e72..876d9bb182 100644 --- a/src/features/composer/hooks/useComposerAutocompleteState.ts +++ b/src/features/composer/hooks/useComposerAutocompleteState.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from "react"; import type { AutocompleteItem } from "./useComposerAutocomplete"; import { useComposerAutocomplete } from "./useComposerAutocomplete"; import type { AppOption, CustomPromptOption } from "../../../types"; +import { connectorMentionSlug } from "../../apps/utils/appMentions"; import { buildPromptInsertText, findNextPromptArgCursor, @@ -23,6 +24,10 @@ type UseComposerAutocompleteStateArgs = { textareaRef: React.RefObject; setText: (next: string) => void; setSelectionStart: (next: number | null) => void; + onItemApplied?: ( + item: AutocompleteItem, + context: { triggerChar: string; insertedText: string }, + ) => void; }; const MAX_FILE_SUGGESTIONS = 500; @@ -77,6 +82,7 @@ export function useComposerAutocompleteState({ textareaRef, setText, setSelectionStart, + onItemApplied, }: UseComposerAutocompleteStateArgs) { const skillItems = useMemo( () => [ @@ -90,12 +96,13 @@ export function useComposerAutocompleteState({ ...apps .filter((app) => app.isAccessible) .map((app) => ({ - id: `app:${app.id}`, - label: app.name, - description: app.description, - insertText: app.id, - group: "Apps" as const, - })), + id: `app:${app.id}`, + label: app.name, + description: app.description, + insertText: connectorMentionSlug(app.name), + group: "Apps" as const, + mentionPath: `app://${app.id}`, + })), ], [apps, skills], ); @@ -261,6 +268,7 @@ export function useComposerAutocompleteState({ : !/^\s/.test(after); const nextText = `${before}${actualInsert}${needsSpace ? " " : ""}${after}`; setText(nextText); + onItemApplied?.(item, { triggerChar, insertedText: actualInsert }); closeAutocomplete(); requestAnimationFrame(() => { const textarea = textareaRef.current; @@ -288,6 +296,7 @@ export function useComposerAutocompleteState({ setText, text, textareaRef, + onItemApplied, ], ); diff --git a/src/features/debug/hooks/useDebugLog.ts b/src/features/debug/hooks/useDebugLog.ts index f6bc7065e6..9d2ce10a90 100644 --- a/src/features/debug/hooks/useDebugLog.ts +++ b/src/features/debug/hooks/useDebugLog.ts @@ -1,13 +1,34 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { DebugEntry } from "../../../types"; const MAX_DEBUG_ENTRIES = 200; +function summarizePayload(payload: unknown): unknown { + if (Array.isArray(payload)) { + return { _type: "array", count: payload.length, sample: payload.slice(0, 5) }; + } + if (payload && typeof payload === "object") { + const obj = payload as Record; + const summarized: Record = {}; + for (const key of Object.keys(obj)) { + if (Array.isArray(obj[key])) { + summarized[key] = { _type: "array", count: (obj[key] as unknown[]).length }; + } else { + summarized[key] = obj[key]; + } + } + return summarized; + } + return payload; +} + export function useDebugLog() { const [debugOpen, setDebugOpenState] = useState(false); const [debugEntries, setDebugEntries] = useState([]); const [hasDebugAlerts, setHasDebugAlerts] = useState(false); const [debugPinned, setDebugPinned] = useState(false); + const debugOpenRef = useRef(debugOpen); + debugOpenRef.current = debugOpen; const isAlertEntry = useCallback((entry: DebugEntry) => { if (entry.source === "error" || entry.source === "stderr") { @@ -24,27 +45,19 @@ export function useDebugLog() { return false; }, []); - const shouldStoreEntry = useCallback( - (entry: DebugEntry) => { - if (debugOpen) { - return true; - } - return isAlertEntry(entry); - }, - [debugOpen, isAlertEntry], - ); - const addDebugEntry = useCallback( (entry: DebugEntry) => { - if (!shouldStoreEntry(entry)) { + const isAlert = isAlertEntry(entry); + if (!debugOpenRef.current && !isAlert) { return; } - if (isAlertEntry(entry)) { + if (isAlert) { setHasDebugAlerts(true); } - setDebugEntries((prev) => [...prev, entry].slice(-MAX_DEBUG_ENTRIES)); + const compactEntry = { ...entry, payload: summarizePayload(entry.payload) }; + setDebugEntries((prev) => [...prev, compactEntry].slice(-MAX_DEBUG_ENTRIES)); }, - [isAlertEntry, shouldStoreEntry], + [isAlertEntry], ); const handleCopyDebug = useCallback(async () => { diff --git a/src/features/git/components/DiffBlock.tsx b/src/features/git/components/DiffBlock.tsx index c504267027..94c6de6f3e 100644 --- a/src/features/git/components/DiffBlock.tsx +++ b/src/features/git/components/DiffBlock.tsx @@ -12,6 +12,21 @@ type DiffBlockProps = { index: number, event: MouseEvent | KeyboardEvent, ) => void; + onLineMouseDown?: ( + line: ParsedDiffLine, + index: number, + event: MouseEvent, + ) => void; + onLineMouseEnter?: ( + line: ParsedDiffLine, + index: number, + event: MouseEvent, + ) => void; + onLineMouseUp?: ( + line: ParsedDiffLine, + index: number, + event: MouseEvent, + ) => void; selectedRange?: { start: number; end: number } | null; parsedLines?: ParsedDiffLine[] | null; }; @@ -21,6 +36,9 @@ export function DiffBlock({ language, showLineNumbers = true, onLineSelect, + onLineMouseDown, + onLineMouseEnter, + onLineMouseUp, selectedRange = null, parsedLines = null, }: DiffBlockProps) { @@ -61,6 +79,27 @@ export function DiffBlock({ } : undefined } + onMouseDown={ + isSelectable + ? (event) => { + onLineMouseDown?.(line, index, event); + } + : undefined + } + onMouseEnter={ + isSelectable + ? (event) => { + onLineMouseEnter?.(line, index, event); + } + : undefined + } + onMouseUp={ + isSelectable + ? (event) => { + onLineMouseUp?.(line, index, event); + } + : undefined + } onKeyDown={ isSelectable ? (event) => { diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index d713edbdb7..c90a32b73e 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -238,4 +238,5 @@ describe("GitDiffPanel", () => { expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts"); }); + }); diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index 53d09dbd71..733a5dcd49 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -5,15 +5,26 @@ import { FileDiff, WorkerPoolContextProvider } from "@pierre/diffs/react"; import type { FileDiffMetadata } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; +import GitCommitHorizontal from "lucide-react/dist/esm/icons/git-commit-horizontal"; import { workerFactory } from "../../../utils/diffsWorker"; -import type { GitHubPullRequest, GitHubPullRequestComment } from "../../../types"; +import type { + GitHubPullRequest, + GitHubPullRequestComment, + PullRequestReviewAction, + PullRequestReviewIntent, + PullRequestSelectionRange, +} from "../../../types"; import { formatRelativeTime } from "../../../utils/time"; +import { parseDiff, type ParsedDiffLine } from "../../../utils/diff"; import { DIFF_VIEWER_HIGHLIGHTER_OPTIONS, DIFF_VIEWER_SCROLL_CSS, } from "../../design-system/diff/diffViewerTheme"; import { Markdown } from "../../messages/components/Markdown"; import { ImageDiffCard } from "./ImageDiffCard"; +import { DiffBlock } from "./DiffBlock"; +import { splitPath } from "./GitDiffPanel.utils"; +import { usePullRequestLineSelection } from "../hooks/usePullRequestLineSelection"; type GitDiffViewerItem = { path: string; @@ -40,6 +51,18 @@ type GitDiffViewerProps = { pullRequestComments?: GitHubPullRequestComment[]; pullRequestCommentsLoading?: boolean; pullRequestCommentsError?: string | null; + pullRequestReviewActions?: PullRequestReviewAction[]; + onRunPullRequestReview?: (options: { + intent: PullRequestReviewIntent; + question?: string; + selection?: PullRequestSelectionRange | null; + images?: string[]; + }) => Promise; + pullRequestReviewLaunching?: boolean; + pullRequestReviewThreadId?: string | null; + onCheckoutPullRequest?: ( + pullRequest: GitHubPullRequest, + ) => Promise | void; canRevert?: boolean; onRevertFile?: (path: string) => Promise | void; onActivePathChange?: (path: string) => void; @@ -60,6 +83,20 @@ type DiffCardProps = { ignoreWhitespaceChanges: boolean; showRevert: boolean; onRequestRevert?: (path: string) => void; + interactiveSelectionEnabled: boolean; + selectedRange?: { start: number; end: number } | null; + onLineSelect?: (index: number, shiftKey: boolean) => void; + onLineMouseDown?: (index: number, button: number, shiftKey: boolean) => void; + onLineMouseEnter?: (index: number) => void; + onLineMouseUp?: () => void; + reviewActions?: PullRequestReviewAction[]; + onRunReviewAction?: ( + intent: PullRequestReviewIntent, + parsedLines: ParsedDiffLine[], + ) => void | Promise; + onClearSelection?: () => void; + pullRequestReviewLaunching?: boolean; + pullRequestReviewThreadId?: string | null; }; const DiffCard = memo(function DiffCard({ @@ -70,7 +107,20 @@ const DiffCard = memo(function DiffCard({ ignoreWhitespaceChanges, showRevert, onRequestRevert, + interactiveSelectionEnabled, + selectedRange = null, + onLineSelect, + onLineMouseDown, + onLineMouseEnter, + onLineMouseUp, + reviewActions = [], + onRunReviewAction, + onClearSelection, + pullRequestReviewLaunching = false, + pullRequestReviewThreadId = null, }: DiffCardProps) { + const { name: fileName, dir } = useMemo(() => splitPath(entry.path), [entry.path]); + const displayDir = dir ? `${dir}/` : ""; const diffOptions = useMemo( () => ({ diffStyle, @@ -114,16 +164,29 @@ const DiffCard = memo(function DiffCard({ return "Diff unavailable."; }, [entry.diff, ignoreWhitespaceChanges, isLoading]); + const parsedLines = useMemo(() => parseDiff(entry.diff), [entry.diff]); + const hasSelectableLines = useMemo( + () => + parsedLines.some( + (line) => line.type === "add" || line.type === "del" || line.type === "context", + ), + [parsedLines], + ); + const useInteractiveDiff = interactiveSelectionEnabled && hasSelectableLines; + return ( -
+
{entry.status} - {entry.path} + + {fileName} + {displayDir && {displayDir}} + {showRevert && (
- {entry.diff.trim().length > 0 && fileDiff ? ( + {useInteractiveDiff && selectedRange && reviewActions.length > 0 ? ( +
+ {reviewActions.map((action) => ( + + ))} + + {pullRequestReviewThreadId ? ( + + Last review thread: {pullRequestReviewThreadId} + + ) : null} +
+ ) : null} + {useInteractiveDiff ? ( +
+ { + onLineSelect?.(index, event.shiftKey); + }} + onLineMouseDown={(_line, index, event) => { + event.preventDefault(); + onLineMouseDown?.(index, event.button, event.shiftKey); + }} + onLineMouseEnter={(_line, index) => { + onLineMouseEnter?.(index); + }} + onLineMouseUp={() => { + onLineMouseUp?.(); + }} + selectedRange={selectedRange} + /> +
+ ) : entry.diff.trim().length > 0 && fileDiff ? (
Promise | void; }; const PullRequestSummary = memo(function PullRequestSummary({ @@ -173,6 +292,7 @@ const PullRequestSummary = memo(function PullRequestSummary({ pullRequestComments, pullRequestCommentsLoading, pullRequestCommentsError, + onCheckoutPullRequest, }: PullRequestSummaryProps) { const prUpdatedLabel = pullRequest.updatedAt ? formatRelativeTime(new Date(pullRequest.updatedAt).getTime()) @@ -180,6 +300,7 @@ const PullRequestSummary = memo(function PullRequestSummary({ const prAuthor = pullRequest.author?.login ?? "unknown"; const prBody = pullRequest.body?.trim() ?? ""; const [isTimelineExpanded, setIsTimelineExpanded] = useState(false); + const [isCheckingOut, setIsCheckingOut] = useState(false); const sortedComments = useMemo(() => { if (!pullRequestComments?.length) { return []; @@ -213,22 +334,40 @@ const PullRequestSummary = memo(function PullRequestSummary({ {pullRequest.title}
- {hasDiffs && ( - - )} +
+ {hasDiffs && ( + + )} + {onCheckoutPullRequest ? ( + + ) : null} +
@{prAuthor} @@ -356,6 +495,11 @@ export function GitDiffViewer({ pullRequestComments, pullRequestCommentsLoading = false, pullRequestCommentsError = null, + pullRequestReviewActions = [], + onRunPullRequestReview, + pullRequestReviewLaunching = false, + pullRequestReviewThreadId = null, + onCheckoutPullRequest, canRevert = false, onRevertFile, onActivePathChange, @@ -369,6 +513,21 @@ export function GitDiffViewer({ const rowResizeObserversRef = useRef(new Map()); const rowNodesByPathRef = useRef(new Map()); const hasActivePathHandler = Boolean(onActivePathChange); + const interactiveSelectionEnabled = Boolean( + pullRequest && + diffStyle === "unified" && + onRunPullRequestReview && + pullRequestReviewActions.length > 0, + ); + const { + clearSelection, + selectLine, + startDragSelection, + updateDragSelection, + finishDragSelection, + selectedRangeForPath, + buildSelectionRange, + } = usePullRequestLineSelection(); const poolOptions = useMemo(() => ({ workerFactory }), []); const highlighterOptions = useMemo( () => DIFF_VIEWER_HIGHLIGHTER_OPTIONS, @@ -427,8 +586,36 @@ export function GitDiffViewer({ } return diffs[0]; }, [diffs, selectedPath, indexByPath]); + const stickyPathDisplay = useMemo(() => { + if (!stickyEntry) { + return null; + } + const { name, dir } = splitPath(stickyEntry.path); + return { fileName: name, displayDir: dir ? `${dir}/` : "" }; + }, [stickyEntry]); const showRevert = canRevert && Boolean(onRevertFile); + + const handleRunSelectionReview = useCallback( + async ( + intent: PullRequestReviewIntent, + entry: GitDiffViewerItem, + parsedLines: ParsedDiffLine[], + ) => { + if (!onRunPullRequestReview) { + return; + } + const selection = buildSelectionRange(entry.path, entry.status, parsedLines); + if (!selection) { + return; + } + await onRunPullRequestReview({ + intent, + selection, + }); + }, + [buildSelectionRange, onRunPullRequestReview], + ); const handleRequestRevert = useCallback( async (path: string) => { if (!onRevertFile) { @@ -476,6 +663,16 @@ export function GitDiffViewer({ activePathRef.current = selectedPath; }, [selectedPath]); + useEffect(() => { + if (!interactiveSelectionEnabled) { + clearSelection(); + } + }, [clearSelection, interactiveSelectionEnabled]); + + useEffect(() => { + clearSelection(); + }, [clearSelection, pullRequest?.number]); + useEffect(() => { onActivePathChangeRef.current = onActivePathChange; }, [onActivePathChange]); @@ -581,13 +778,29 @@ export function GitDiffViewer({ } rowVirtualizer.scrollToIndex(0, { align: "start" }); }, [diffs.length, rowVirtualizer]); + const emptyStateCopy = pullRequest + ? { + title: "No file changes in this pull request", + subtitle: + "The pull request loaded, but there are no diff hunks to render for this selection.", + hint: "Try switching to another pull request or commit from the Git panel.", + } + : { + title: "Working tree is clean", + subtitle: "No local changes were detected for the current workspace.", + hint: "Make an edit, stage a file, or select a commit to inspect changes here.", + }; return ( -
+
{pullRequest && ( )} {!error && stickyEntry && ( @@ -608,7 +822,14 @@ export function GitDiffViewer({ > {stickyEntry.status} - {stickyEntry.path} + + + {stickyPathDisplay?.fileName ?? stickyEntry.path} + + {stickyPathDisplay?.displayDir && ( + {stickyPathDisplay.displayDir} + )} + {showRevert && (
diff --git a/src/features/layout/components/DesktopLayout.tsx b/src/features/layout/components/DesktopLayout.tsx index 2f450f8701..28b2e21096 100644 --- a/src/features/layout/components/DesktopLayout.tsx +++ b/src/features/layout/components/DesktopLayout.tsx @@ -12,6 +12,7 @@ type DesktopLayoutProps = { topbarLeftNode: ReactNode; centerMode: "chat" | "diff"; preloadGitDiffs: boolean; + splitChatDiffView: boolean; messagesNode: ReactNode; gitDiffViewerNode: ReactNode; gitDiffPanelNode: ReactNode; @@ -36,6 +37,7 @@ export function DesktopLayout({ topbarLeftNode, centerMode, preloadGitDiffs, + splitChatDiffView, messagesNode, gitDiffViewerNode, gitDiffPanelNode, @@ -50,12 +52,19 @@ export function DesktopLayout({ }: DesktopLayoutProps) { const diffLayerRef = useRef(null); const chatLayerRef = useRef(null); - const shouldRenderDiffViewer = preloadGitDiffs || centerMode === "diff"; + const shouldRenderDiffViewer = + splitChatDiffView || preloadGitDiffs || centerMode === "diff"; useEffect(() => { const diffLayer = diffLayerRef.current; const chatLayer = chatLayerRef.current; + if (splitChatDiffView) { + diffLayer?.removeAttribute("inert"); + chatLayer?.removeAttribute("inert"); + return; + } + if (diffLayer) { if (centerMode === "diff") { diffLayer.removeAttribute("inert"); @@ -81,7 +90,7 @@ export function DesktopLayout({ ) { activeElement.blur(); } - }, [centerMode]); + }, [centerMode, splitChatDiffView]); return ( <> @@ -103,21 +112,44 @@ export function DesktopLayout({ <> {approvalToastsNode} -
-
- {shouldRenderDiffViewer ? gitDiffViewerNode : null} -
-
- {messagesNode} -
+
+ {splitChatDiffView ? ( + <> +
+ {messagesNode} +
+
+ {shouldRenderDiffViewer ? gitDiffViewerNode : null} +
+ + ) : ( + <> +
+ {shouldRenderDiffViewer ? gitDiffViewerNode : null} +
+
+ {messagesNode} +
+ + )}
void; }; +export type ComposerContextAction = { + id: string; + label: string; + title?: string; + disabled?: boolean; + onSelect: () => void | Promise; +}; + export type LayoutNodesOptions = { workspaces: WorkspaceInfo[]; groupedWorkspaces: Array<{ @@ -203,6 +215,9 @@ export type LayoutNodesOptions = { branchName: string; branches: BranchInfo[]; onCheckoutBranch: (name: string) => Promise; + onCheckoutPullRequest: ( + pullRequest: GitHubPullRequest, + ) => Promise | void; onCreateBranch: (name: string) => Promise; onCopyThread: () => void | Promise; onToggleTerminal: () => void; @@ -276,6 +291,15 @@ export type LayoutNodesOptions = { selectedPullRequestComments: GitHubPullRequestComment[]; selectedPullRequestCommentsLoading: boolean; selectedPullRequestCommentsError: string | null; + pullRequestReviewActions: PullRequestReviewAction[]; + onRunPullRequestReview: (options: { + intent: PullRequestReviewIntent; + question?: string; + selection?: PullRequestSelectionRange | null; + images?: string[]; + }) => Promise; + pullRequestReviewLaunching: boolean; + pullRequestReviewThreadId: string | null; onSelectPullRequest: (pullRequest: GitHubPullRequest) => void; gitRemoteUrl: string | null; gitRoot: string | null; @@ -343,8 +367,16 @@ export type LayoutNodesOptions = { onRevealWorkspacePrompts: () => void | Promise; onRevealGeneralPrompts: () => void | Promise; canRevealGeneralPrompts: boolean; - onSend: (text: string, images: string[]) => void | Promise; - onQueue: (text: string, images: string[]) => void | Promise; + onSend: ( + text: string, + images: string[], + appMentions?: AppMention[], + ) => void | Promise; + onQueue: ( + text: string, + images: string[], + appMentions?: AppMention[], + ) => void | Promise; onStop: () => void; canStop: boolean; onFileAutocompleteActiveChange?: (active: boolean) => void; @@ -423,6 +455,7 @@ export type LayoutNodesOptions = { onDismissDictationError: () => void; dictationHint: string | null; onDismissDictationHint: () => void; + composerContextActions: ComposerContextAction[]; showComposer: boolean; composerSendLabel?: string; plan: TurnPlan | null; diff --git a/src/features/models/hooks/useModels.ts b/src/features/models/hooks/useModels.ts index 8e9a620190..4668c1d8bd 100644 --- a/src/features/models/hooks/useModels.ts +++ b/src/features/models/hooks/useModels.ts @@ -1,24 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DebugEntry, ModelOption, WorkspaceInfo } from "../../../types"; import { getConfigModel, getModelList } from "../../../services/tauri"; +import { + normalizeEffortValue, + parseModelListResponse, +} from "../utils/modelListResponse"; type UseModelsOptions = { activeWorkspace: WorkspaceInfo | null; onDebug?: (entry: DebugEntry) => void; preferredModelId?: string | null; preferredEffort?: string | null; + selectionKey?: string | null; }; const CONFIG_MODEL_DESCRIPTION = "Configured in CODEX_HOME/config.toml"; -const normalizeEffort = (value: unknown): string | null => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -}; - const findModelByIdOrModel = ( models: ModelOption[], idOrModel: string | null, @@ -44,6 +41,7 @@ export function useModels({ onDebug, preferredModelId = null, preferredEffort = null, + selectionKey = null, }: UseModelsOptions) { const [models, setModels] = useState([]); const [configModel, setConfigModel] = useState(null); @@ -54,10 +52,20 @@ export function useModels({ const hasUserSelectedModel = useRef(false); const hasUserSelectedEffort = useRef(false); const lastWorkspaceId = useRef(null); + const lastSelectionKey = useRef(null); const workspaceId = activeWorkspace?.id ?? null; const isConnected = Boolean(activeWorkspace?.connected); + useEffect(() => { + if (selectionKey === lastSelectionKey.current) { + return; + } + lastSelectionKey.current = selectionKey; + hasUserSelectedModel.current = false; + hasUserSelectedEffort.current = false; + }, [selectionKey]); + useEffect(() => { if (workspaceId === lastWorkspaceId.current) { return; @@ -111,7 +119,7 @@ export function useModels({ if (supported && supported.length > 0) { return supported; } - const defaultEffort = normalizeEffort(selectedModel?.defaultReasoningEffort); + const defaultEffort = normalizeEffortValue(selectedModel?.defaultReasoningEffort); return defaultEffort ? [defaultEffort] : []; }, [selectedModel]); @@ -120,18 +128,18 @@ export function useModels({ const supportedEfforts = model.supportedReasoningEfforts.map( (effort) => effort.reasoningEffort, ); - const currentEffort = normalizeEffort(selectedEffort); + const currentEffort = normalizeEffortValue(selectedEffort); if (preferCurrent && currentEffort) { return currentEffort; } if (supportedEfforts.length === 0) { - return normalizeEffort(preferredEffort); + return normalizeEffortValue(preferredEffort); } - const preferred = normalizeEffort(preferredEffort); + const preferred = normalizeEffortValue(preferredEffort); if (preferred && supportedEfforts.includes(preferred)) { return preferred; } - return normalizeEffort(model.defaultReasoningEffort); + return normalizeEffortValue(model.defaultReasoningEffort); }, [preferredEffort, selectedEffort], ); @@ -194,27 +202,7 @@ export function useModels({ payload: response, }); setConfigModel(configModelFromConfig); - const rawData = response?.result?.data ?? response?.data ?? []; - const dataFromServer: ModelOption[] = rawData.map((item: any) => ({ - id: String(item.id ?? item.model ?? ""), - model: String(item.model ?? item.id ?? ""), - displayName: String(item.displayName ?? item.display_name ?? item.model ?? ""), - description: String(item.description ?? ""), - supportedReasoningEfforts: Array.isArray(item.supportedReasoningEfforts) - ? item.supportedReasoningEfforts - : Array.isArray(item.supported_reasoning_efforts) - ? item.supported_reasoning_efforts.map((effort: any) => ({ - reasoningEffort: String( - effort.reasoningEffort ?? effort.reasoning_effort ?? "", - ), - description: String(effort.description ?? ""), - })) - : [], - defaultReasoningEffort: normalizeEffort( - item.defaultReasoningEffort ?? item.default_reasoning_effort, - ), - isDefault: Boolean(item.isDefault ?? item.is_default ?? false), - })); + const dataFromServer: ModelOption[] = parseModelListResponse(response); const data = (() => { if (!configModelFromConfig) { return dataFromServer; @@ -290,11 +278,11 @@ export function useModels({ if (!selectedModel) { return; } - const currentEffort = normalizeEffort(selectedEffort); + const currentEffort = normalizeEffortValue(selectedEffort); if (currentEffort) { return; } - const nextEffort = normalizeEffort(selectedModel.defaultReasoningEffort); + const nextEffort = normalizeEffortValue(selectedModel.defaultReasoningEffort); if (nextEffort === null) { return; } diff --git a/src/features/models/utils/modelListResponse.ts b/src/features/models/utils/modelListResponse.ts new file mode 100644 index 0000000000..a5065a73cb --- /dev/null +++ b/src/features/models/utils/modelListResponse.ts @@ -0,0 +1,97 @@ +import type { ModelOption } from "../../../types"; + +export function normalizeEffortValue(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function extractModelItems(response: unknown): unknown[] { + if (!response || typeof response !== "object") { + return []; + } + + const record = response as Record; + const result = + record.result && typeof record.result === "object" + ? (record.result as Record) + : null; + + const resultData = result?.data; + if (Array.isArray(resultData)) { + return resultData; + } + + const topLevelData = record.data; + if (Array.isArray(topLevelData)) { + return topLevelData; + } + + return []; +} + +function parseReasoningEfforts(item: Record): ModelOption["supportedReasoningEfforts"] { + const camel = item.supportedReasoningEfforts; + if (Array.isArray(camel)) { + return camel + .map((effort) => { + if (!effort || typeof effort !== "object") { + return null; + } + const entry = effort as Record; + return { + reasoningEffort: String(entry.reasoningEffort ?? entry.reasoning_effort ?? ""), + description: String(entry.description ?? ""), + }; + }) + .filter((effort): effort is { reasoningEffort: string; description: string } => + effort !== null, + ); + } + + const snake = item.supported_reasoning_efforts; + if (Array.isArray(snake)) { + return snake + .map((effort) => { + if (!effort || typeof effort !== "object") { + return null; + } + const entry = effort as Record; + return { + reasoningEffort: String(entry.reasoningEffort ?? entry.reasoning_effort ?? ""), + description: String(entry.description ?? ""), + }; + }) + .filter((effort): effort is { reasoningEffort: string; description: string } => + effort !== null, + ); + } + + return []; +} + +export function parseModelListResponse(response: unknown): ModelOption[] { + const items = extractModelItems(response); + + return items + .map((item) => { + if (!item || typeof item !== "object") { + return null; + } + const record = item as Record; + return { + id: String(record.id ?? record.model ?? ""), + model: String(record.model ?? record.id ?? ""), + displayName: String(record.displayName ?? record.display_name ?? record.model ?? ""), + description: String(record.description ?? ""), + supportedReasoningEfforts: parseReasoningEfforts(record), + defaultReasoningEffort: normalizeEffortValue( + record.defaultReasoningEffort ?? record.default_reasoning_effort, + ), + isDefault: Boolean(record.isDefault ?? record.is_default ?? false), + } satisfies ModelOption; + }) + .filter((model): model is ModelOption => model !== null); +} diff --git a/src/features/settings/components/SettingsNav.tsx b/src/features/settings/components/SettingsNav.tsx index c1f37dc611..7061dbdeb4 100644 --- a/src/features/settings/components/SettingsNav.tsx +++ b/src/features/settings/components/SettingsNav.tsx @@ -9,7 +9,7 @@ import FlaskConical from "lucide-react/dist/esm/icons/flask-conical"; import ExternalLink from "lucide-react/dist/esm/icons/external-link"; import Layers from "lucide-react/dist/esm/icons/layers"; import ServerCog from "lucide-react/dist/esm/icons/server-cog"; -import { PanelNavItem, PanelNavList } from "../../design-system/components/panel/PanelPrimitives"; +import { PanelNavItem, PanelNavList } from "@/features/design-system/components/panel/PanelPrimitives"; import type { CodexSection } from "./settingsTypes"; type SettingsNavProps = { diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 653733fe61..5895cb2f04 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -10,7 +10,9 @@ import { } from "@testing-library/react"; import type { ComponentProps } from "react"; import { describe, expect, it, vi } from "vitest"; -import type { AppSettings, WorkspaceInfo } from "../../../types"; +import type { AppSettings, WorkspaceInfo } from "@/types"; +import { getModelList } from "@services/tauri"; +import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; import { SettingsView } from "./SettingsView"; vi.mock("@tauri-apps/plugin-dialog", () => ({ @@ -18,6 +20,18 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), })); +vi.mock("@services/tauri", async () => { + const actual = await vi.importActual( + "@services/tauri", + ); + return { + ...actual, + getModelList: vi.fn(), + }; +}); + +const getModelListMock = vi.mocked(getModelList); + const baseSettings: AppSettings = { codexBin: null, codexArgs: null, @@ -29,6 +43,7 @@ const baseSettings: AppSettings = { orbitAuthUrl: null, orbitRunnerName: null, orbitAutoStartRunner: false, + keepDaemonRunningAfterAppClose: false, orbitUseAccess: false, orbitAccessClientId: null, orbitAccessClientSecretRef: null, @@ -58,6 +73,7 @@ const baseSettings: AppSettings = { theme: "system", usageShowRemaining: false, showMessageFilePath: true, + threadTitleAutogenerationEnabled: false, uiFontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', codeFontFamily: @@ -65,8 +81,10 @@ const baseSettings: AppSettings = { codeFontSize: 11, notificationSoundsEnabled: true, systemNotificationsEnabled: true, + splitChatDiffView: false, preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, + commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, experimentalCollabEnabled: false, collaborationModesEnabled: true, steerEnabled: true, @@ -370,6 +388,29 @@ describe("SettingsView Display", () => { }); }); + it("toggles split chat and diff center panes", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderDisplaySection({ onUpdateAppSettings }); + + const row = screen + .getByText("Split chat and diff center panes") + .closest(".settings-toggle-row") as HTMLElement | null; + if (!row) { + throw new Error("Expected split center panes row"); + } + const toggle = row.querySelector("button.settings-toggle") as HTMLButtonElement | null; + if (!toggle) { + throw new Error("Expected split center panes toggle"); + } + fireEvent.click(toggle); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ splitChatDiffView: true }), + ); + }); + }); + it("toggles reduce transparency", async () => { const onToggleTransparency = vi.fn(); renderDisplaySection({ onToggleTransparency, reduceTransparency: false }); @@ -1227,6 +1268,216 @@ describe("SettingsView Codex overrides", () => { }); }); +describe("SettingsView Codex defaults", () => { + const createModelListResponse = (models: Array>) => ({ + result: { data: models }, + }); + + it("uses the latest model and medium effort by default (no Default option)", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + getModelListMock.mockResolvedValue( + createModelListResponse([ + { + id: "gpt-4.1", + model: "gpt-4.1", + displayName: "GPT-4.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "Reasoning effort", + ) as HTMLSelectElement; + + await waitFor(() => { + expect(getModelListMock).toHaveBeenCalledWith("w1"); + expect(modelSelect.value).toBe("gpt-5.1"); + }); + + expect(within(modelSelect).queryByRole("option", { name: /default/i })).toBeNull(); + expect(within(effortSelect).queryByRole("option", { name: /default/i })).toBeNull(); + expect(effortSelect.value).toBe("medium"); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + lastComposerModelId: "gpt-5.1", + lastComposerReasoningEffort: "medium", + }), + ); + }); + }); + + it("updates model and effort when the user changes the selects", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + getModelListMock.mockResolvedValue( + createModelListResponse([ + { + id: "gpt-4.1", + model: "gpt-4.1", + displayName: "GPT-4.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + { + id: "gpt-5.1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "" }, + { reasoningEffort: "medium", description: "" }, + { reasoningEffort: "high", description: "" }, + ], + defaultReasoningEffort: "medium", + isDefault: false, + }, + ]), + ); + + render( + , + ); + + const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement; + const effortSelect = screen.getByLabelText( + "Reasoning effort", + ) as HTMLSelectElement; + + await waitFor(() => { + expect(modelSelect.disabled).toBe(false); + expect(modelSelect.value).toBe("gpt-5.1"); + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerModelId: "gpt-5.1" }), + ); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(modelSelect, { target: { value: "gpt-4.1" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerModelId: "gpt-4.1" }), + ); + }); + + onUpdateAppSettings.mockClear(); + fireEvent.change(effortSelect, { target: { value: "high" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ lastComposerReasoningEffort: "high" }), + ); + }); + }); +}); + describe("SettingsView Features", () => { it("updates personality selection", async () => { const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 57ad12c979..d6ca73b721 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -1,6 +1,3 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ask, open } from "@tauri-apps/plugin-dialog"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; import ChevronLeft from "lucide-react/dist/esm/icons/chevron-left"; import X from "lucide-react/dist/esm/icons/x"; import type { @@ -8,73 +5,18 @@ import type { CodexDoctorResult, CodexUpdateResult, DictationModelStatus, - TcpDaemonStatus, - TailscaleDaemonCommandPreview, - TailscaleStatus, WorkspaceSettings, WorkspaceGroup, WorkspaceInfo, -} from "../../../types"; -import { - getCodexConfigPath, - listWorkspaces, - tailscaleDaemonStart, - tailscaleDaemonStatus, - tailscaleDaemonStop, - tailscaleDaemonCommandPreview as fetchTailscaleDaemonCommandPreview, - tailscaleStatus as fetchTailscaleStatus, -} from "../../../services/tauri"; -import { - isMacPlatform, - isMobilePlatform, - isWindowsPlatform, -} from "../../../utils/platformPaths"; -import { clampUiScale } from "../../../utils/uiScale"; -import { - DEFAULT_CODE_FONT_FAMILY, - DEFAULT_UI_FONT_FAMILY, - clampCodeFontSize, - normalizeFontFamily, -} from "../../../utils/fonts"; -import { useGlobalAgentsMd } from "../hooks/useGlobalAgentsMd"; -import { useGlobalCodexConfigToml } from "../hooks/useGlobalCodexConfigToml"; -import { useSettingsOpenAppDrafts } from "../hooks/useSettingsOpenAppDrafts"; -import { useSettingsShortcutDrafts } from "../hooks/useSettingsShortcutDrafts"; -import { useSettingsViewCloseShortcuts } from "../hooks/useSettingsViewCloseShortcuts"; -import { useSettingsViewNavigation } from "../hooks/useSettingsViewNavigation"; -import { ModalShell } from "../../design-system/components/modal/ModalShell"; +} from "@/types"; +import { useSettingsViewCloseShortcuts } from "@settings/hooks/useSettingsViewCloseShortcuts"; +import { useSettingsViewNavigation } from "@settings/hooks/useSettingsViewNavigation"; +import { useSettingsViewOrchestration } from "@settings/hooks/useSettingsViewOrchestration"; +import { ModalShell } from "@/features/design-system/components/modal/ModalShell"; import { SettingsNav } from "./SettingsNav"; import type { CodexSection, OrbitServiceClient } from "./settingsTypes"; -import { SettingsProjectsSection } from "./sections/SettingsProjectsSection"; -import { SettingsEnvironmentsSection } from "./sections/SettingsEnvironmentsSection"; -import { SettingsDisplaySection } from "./sections/SettingsDisplaySection"; -import { SettingsComposerSection } from "./sections/SettingsComposerSection"; -import { SettingsDictationSection } from "./sections/SettingsDictationSection"; -import { SettingsShortcutsSection } from "./sections/SettingsShortcutsSection"; -import { SettingsOpenAppsSection } from "./sections/SettingsOpenAppsSection"; -import { SettingsGitSection } from "./sections/SettingsGitSection"; -import { SettingsCodexSection } from "./sections/SettingsCodexSection"; -import { SettingsServerSection } from "./sections/SettingsServerSection"; -import { SettingsFeaturesSection } from "./sections/SettingsFeaturesSection"; -import { - COMPOSER_PRESET_CONFIGS, - COMPOSER_PRESET_LABELS, - DEFAULT_REMOTE_HOST, - DICTATION_MODELS, - ORBIT_DEFAULT_POLL_INTERVAL_SECONDS, - ORBIT_MAX_INLINE_POLL_SECONDS, - ORBIT_SERVICES, - SETTINGS_SECTION_LABELS, -} from "./settingsViewConstants"; -import { - buildEditorContentMeta, - buildWorkspaceOverrideDrafts, - delay, - getOrbitStatusText, - normalizeOverrideValue, - normalizeWorktreeSetupScript, - type OrbitActionResult, -} from "./settingsViewHelpers"; +import { ORBIT_SERVICES, SETTINGS_SECTION_LABELS } from "./settingsViewConstants"; +import { SettingsSectionContainers } from "./sections/SettingsSectionContainers"; export type SettingsViewProps = { workspaceGroups: WorkspaceGroup[]; @@ -166,1092 +108,41 @@ export function SettingsView({ useMobileMasterDetail, handleSelectSection, } = useSettingsViewNavigation({ initialSection }); - const [environmentWorkspaceId, setEnvironmentWorkspaceId] = useState( - null, - ); - const [environmentDraftScript, setEnvironmentDraftScript] = useState(""); - const [environmentSavedScript, setEnvironmentSavedScript] = useState( - null, - ); - const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState< - string | null - >(null); - const [environmentError, setEnvironmentError] = useState(null); - const [environmentSaving, setEnvironmentSaving] = useState(false); - const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? ""); - const [codexArgsDraft, setCodexArgsDraft] = useState(appSettings.codexArgs ?? ""); - const [remoteHostDraft, setRemoteHostDraft] = useState(appSettings.remoteBackendHost); - const [remoteTokenDraft, setRemoteTokenDraft] = useState(appSettings.remoteBackendToken ?? ""); - const [orbitWsUrlDraft, setOrbitWsUrlDraft] = useState(appSettings.orbitWsUrl ?? ""); - const [orbitAuthUrlDraft, setOrbitAuthUrlDraft] = useState(appSettings.orbitAuthUrl ?? ""); - const [orbitRunnerNameDraft, setOrbitRunnerNameDraft] = useState( - appSettings.orbitRunnerName ?? "", - ); - const [orbitAccessClientIdDraft, setOrbitAccessClientIdDraft] = useState( - appSettings.orbitAccessClientId ?? "", - ); - const [orbitAccessClientSecretRefDraft, setOrbitAccessClientSecretRefDraft] = - useState(appSettings.orbitAccessClientSecretRef ?? ""); - const [orbitStatusText, setOrbitStatusText] = useState(null); - const [orbitAuthCode, setOrbitAuthCode] = useState(null); - const [orbitVerificationUrl, setOrbitVerificationUrl] = useState( - null, - ); - const [orbitBusyAction, setOrbitBusyAction] = useState(null); - const [tailscaleStatus, setTailscaleStatus] = useState( - null, - ); - const [tailscaleStatusBusy, setTailscaleStatusBusy] = useState(false); - const [tailscaleStatusError, setTailscaleStatusError] = useState(null); - const [tailscaleCommandPreview, setTailscaleCommandPreview] = - useState(null); - const [tailscaleCommandBusy, setTailscaleCommandBusy] = useState(false); - const [tailscaleCommandError, setTailscaleCommandError] = useState( - null, - ); - const [tcpDaemonStatus, setTcpDaemonStatus] = useState(null); - const [tcpDaemonBusyAction, setTcpDaemonBusyAction] = useState< - "start" | "stop" | "status" | null - >(null); - const [mobileConnectBusy, setMobileConnectBusy] = useState(false); - const [mobileConnectStatusText, setMobileConnectStatusText] = useState( - null, - ); - const [mobileConnectStatusError, setMobileConnectStatusError] = useState(false); - const mobilePlatform = useMemo(() => isMobilePlatform(), []); - const [scaleDraft, setScaleDraft] = useState( - `${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`, - ); - const [uiFontDraft, setUiFontDraft] = useState(appSettings.uiFontFamily); - const [codeFontDraft, setCodeFontDraft] = useState(appSettings.codeFontFamily); - const [codeFontSizeDraft, setCodeFontSizeDraft] = useState(appSettings.codeFontSize); - const [codexBinOverrideDrafts, setCodexBinOverrideDrafts] = useState< - Record - >({}); - const [codexHomeOverrideDrafts, setCodexHomeOverrideDrafts] = useState< - Record - >({}); - const [codexArgsOverrideDrafts, setCodexArgsOverrideDrafts] = useState< - Record - >({}); - const [groupDrafts, setGroupDrafts] = useState>({}); - const [newGroupName, setNewGroupName] = useState(""); - const [groupError, setGroupError] = useState(null); - const { - openAppDrafts, - openAppSelectedId, - handleOpenAppDraftChange, - handleOpenAppKindChange, - handleCommitOpenAppsDrafts, - handleMoveOpenApp, - handleDeleteOpenApp, - handleAddOpenApp, - handleSelectOpenAppDefault, - } = useSettingsOpenAppDrafts({ + + const orchestration = useSettingsViewOrchestration({ + workspaceGroups, + groupedWorkspaces, + ungroupedLabel, + reduceTransparency, + onToggleTransparency, appSettings, + openAppIconById, onUpdateAppSettings, + onRunDoctor, + onRunCodexUpdate, + onUpdateWorkspaceCodexBin, + onUpdateWorkspaceSettings, + scaleShortcutTitle, + scaleShortcutText, + onTestNotificationSound, + onTestSystemNotification, + onMoveWorkspace, + onDeleteWorkspace, + onCreateWorkspaceGroup, + onRenameWorkspaceGroup, + onMoveWorkspaceGroup, + onDeleteWorkspaceGroup, + onAssignWorkspaceGroup, + onMobileConnectSuccess, + dictationModelStatus, + onDownloadDictationModel, + onCancelDictationDownload, + onRemoveDictationModel, + orbitServiceClient, }); - const [doctorState, setDoctorState] = useState<{ - status: "idle" | "running" | "done"; - result: CodexDoctorResult | null; - }>({ status: "idle", result: null }); - - const [codexUpdateState, setCodexUpdateState] = useState<{ - status: "idle" | "running" | "done"; - result: CodexUpdateResult | null; - }>({ status: "idle", result: null }); - const { - content: globalAgentsContent, - exists: globalAgentsExists, - truncated: globalAgentsTruncated, - isLoading: globalAgentsLoading, - isSaving: globalAgentsSaving, - error: globalAgentsError, - isDirty: globalAgentsDirty, - setContent: setGlobalAgentsContent, - refresh: refreshGlobalAgents, - save: saveGlobalAgents, - } = useGlobalAgentsMd(); - const { - content: globalConfigContent, - exists: globalConfigExists, - truncated: globalConfigTruncated, - isLoading: globalConfigLoading, - isSaving: globalConfigSaving, - error: globalConfigError, - isDirty: globalConfigDirty, - setContent: setGlobalConfigContent, - refresh: refreshGlobalConfig, - save: saveGlobalConfig, - } = useGlobalCodexConfigToml(); - const [openConfigError, setOpenConfigError] = useState(null); - const [isSavingSettings, setIsSavingSettings] = useState(false); - const { shortcutDrafts, handleShortcutKeyDown, clearShortcut } = - useSettingsShortcutDrafts({ - appSettings, - onUpdateAppSettings, - }); - const latestSettingsRef = useRef(appSettings); - const dictationReady = dictationModelStatus?.state === "ready"; - const globalAgentsEditorMeta = buildEditorContentMeta({ - isLoading: globalAgentsLoading, - isSaving: globalAgentsSaving, - exists: globalAgentsExists, - truncated: globalAgentsTruncated, - isDirty: globalAgentsDirty, - }); - const globalConfigEditorMeta = buildEditorContentMeta({ - isLoading: globalConfigLoading, - isSaving: globalConfigSaving, - exists: globalConfigExists, - truncated: globalConfigTruncated, - isDirty: globalConfigDirty, - }); - const globalAgentsMeta = globalAgentsEditorMeta.meta; - const globalAgentsSaveLabel = globalAgentsEditorMeta.saveLabel; - const globalAgentsSaveDisabled = globalAgentsEditorMeta.saveDisabled; - const globalAgentsRefreshDisabled = globalAgentsEditorMeta.refreshDisabled; - const globalConfigMeta = globalConfigEditorMeta.meta; - const globalConfigSaveLabel = globalConfigEditorMeta.saveLabel; - const globalConfigSaveDisabled = globalConfigEditorMeta.saveDisabled; - const globalConfigRefreshDisabled = globalConfigEditorMeta.refreshDisabled; - const optionKeyLabel = isMacPlatform() ? "Option" : "Alt"; - const metaKeyLabel = isMacPlatform() - ? "Command" - : isWindowsPlatform() - ? "Windows" - : "Meta"; - const selectedDictationModel = useMemo(() => { - return ( - DICTATION_MODELS.find( - (model) => model.id === appSettings.dictationModelId, - ) ?? DICTATION_MODELS[1] - ); - }, [appSettings.dictationModelId]); - - const projects = useMemo( - () => groupedWorkspaces.flatMap((group) => group.workspaces), - [groupedWorkspaces], - ); - const mainWorkspaces = useMemo( - () => projects.filter((workspace) => (workspace.kind ?? "main") !== "worktree"), - [projects], - ); - const environmentWorkspace = useMemo(() => { - if (mainWorkspaces.length === 0) { - return null; - } - if (environmentWorkspaceId) { - const found = mainWorkspaces.find((workspace) => workspace.id === environmentWorkspaceId); - if (found) { - return found; - } - } - return mainWorkspaces[0] ?? null; - }, [environmentWorkspaceId, mainWorkspaces]); - const environmentSavedScriptFromWorkspace = useMemo(() => { - return normalizeWorktreeSetupScript(environmentWorkspace?.settings.worktreeSetupScript); - }, [environmentWorkspace?.settings.worktreeSetupScript]); - const environmentDraftNormalized = useMemo(() => { - return normalizeWorktreeSetupScript(environmentDraftScript); - }, [environmentDraftScript]); - const environmentDirty = environmentDraftNormalized !== environmentSavedScript; - const hasCodexHomeOverrides = useMemo( - () => projects.some((workspace) => workspace.settings.codexHome != null), - [projects], - ); useSettingsViewCloseShortcuts(onClose); - useEffect(() => { - latestSettingsRef.current = appSettings; - }, [appSettings]); - - useEffect(() => { - setCodexPathDraft(appSettings.codexBin ?? ""); - }, [appSettings.codexBin]); - - useEffect(() => { - setCodexArgsDraft(appSettings.codexArgs ?? ""); - }, [appSettings.codexArgs]); - - useEffect(() => { - setRemoteHostDraft(appSettings.remoteBackendHost); - }, [appSettings.remoteBackendHost]); - - useEffect(() => { - setRemoteTokenDraft(appSettings.remoteBackendToken ?? ""); - }, [appSettings.remoteBackendToken]); - - useEffect(() => { - setOrbitWsUrlDraft(appSettings.orbitWsUrl ?? ""); - }, [appSettings.orbitWsUrl]); - - useEffect(() => { - setOrbitAuthUrlDraft(appSettings.orbitAuthUrl ?? ""); - }, [appSettings.orbitAuthUrl]); - - useEffect(() => { - setOrbitRunnerNameDraft(appSettings.orbitRunnerName ?? ""); - }, [appSettings.orbitRunnerName]); - - useEffect(() => { - setOrbitAccessClientIdDraft(appSettings.orbitAccessClientId ?? ""); - }, [appSettings.orbitAccessClientId]); - - useEffect(() => { - setOrbitAccessClientSecretRefDraft(appSettings.orbitAccessClientSecretRef ?? ""); - }, [appSettings.orbitAccessClientSecretRef]); - - useEffect(() => { - setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); - }, [appSettings.uiScale]); - - useEffect(() => { - setUiFontDraft(appSettings.uiFontFamily); - }, [appSettings.uiFontFamily]); - - useEffect(() => { - setCodeFontDraft(appSettings.codeFontFamily); - }, [appSettings.codeFontFamily]); - - useEffect(() => { - setCodeFontSizeDraft(appSettings.codeFontSize); - }, [appSettings.codeFontSize]); - - const handleOpenConfig = useCallback(async () => { - setOpenConfigError(null); - try { - const configPath = await getCodexConfigPath(); - await revealItemInDir(configPath); - } catch (error) { - setOpenConfigError( - error instanceof Error ? error.message : "Unable to open config.", - ); - } - }, []); - - useEffect(() => { - setCodexBinOverrideDrafts((prev) => - buildWorkspaceOverrideDrafts( - projects, - prev, - (workspace) => workspace.codex_bin ?? null, - ), - ); - setCodexHomeOverrideDrafts((prev) => - buildWorkspaceOverrideDrafts( - projects, - prev, - (workspace) => workspace.settings.codexHome ?? null, - ), - ); - setCodexArgsOverrideDrafts((prev) => - buildWorkspaceOverrideDrafts( - projects, - prev, - (workspace) => workspace.settings.codexArgs ?? null, - ), - ); - }, [projects]); - - useEffect(() => { - setGroupDrafts((prev) => { - const next: Record = {}; - workspaceGroups.forEach((group) => { - next[group.id] = prev[group.id] ?? group.name; - }); - return next; - }); - }, [workspaceGroups]); - - useEffect(() => { - if (!environmentWorkspace) { - setEnvironmentWorkspaceId(null); - setEnvironmentLoadedWorkspaceId(null); - setEnvironmentSavedScript(null); - setEnvironmentDraftScript(""); - setEnvironmentError(null); - setEnvironmentSaving(false); - return; - } - - if (environmentWorkspaceId !== environmentWorkspace.id) { - setEnvironmentWorkspaceId(environmentWorkspace.id); - } - }, [environmentWorkspace, environmentWorkspaceId]); - - useEffect(() => { - if (!environmentWorkspace) { - return; - } - - if (environmentLoadedWorkspaceId !== environmentWorkspace.id) { - setEnvironmentLoadedWorkspaceId(environmentWorkspace.id); - setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); - setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); - setEnvironmentError(null); - return; - } - - if (!environmentDirty && environmentSavedScript !== environmentSavedScriptFromWorkspace) { - setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); - setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); - setEnvironmentError(null); - } - }, [ - environmentDirty, - environmentLoadedWorkspaceId, - environmentSavedScript, - environmentSavedScriptFromWorkspace, - environmentWorkspace, - ]); - - const nextCodexBin = codexPathDraft.trim() ? codexPathDraft.trim() : null; - const nextCodexArgs = codexArgsDraft.trim() ? codexArgsDraft.trim() : null; - const codexDirty = - nextCodexBin !== (appSettings.codexBin ?? null) || - nextCodexArgs !== (appSettings.codexArgs ?? null); - - const trimmedScale = scaleDraft.trim(); - const parsedPercent = trimmedScale - ? Number(trimmedScale.replace("%", "")) - : Number.NaN; - const parsedScale = Number.isFinite(parsedPercent) ? parsedPercent / 100 : null; - - const handleSaveCodexSettings = async () => { - setIsSavingSettings(true); - try { - await onUpdateAppSettings({ - ...appSettings, - codexBin: nextCodexBin, - codexArgs: nextCodexArgs, - }); - } finally { - setIsSavingSettings(false); - } - }; - - const updateRemoteBackendSettings = useCallback( - async ({ - host, - token, - provider, - orbitWsUrl, - }: { - host?: string; - token?: string | null; - provider?: AppSettings["remoteBackendProvider"]; - orbitWsUrl?: string | null; - }) => { - const latestSettings = latestSettingsRef.current; - const nextHost = host ?? latestSettings.remoteBackendHost; - const nextToken = - token === undefined ? latestSettings.remoteBackendToken : token; - const nextProvider = provider ?? latestSettings.remoteBackendProvider; - const nextOrbitWsUrl = - orbitWsUrl === undefined ? latestSettings.orbitWsUrl : orbitWsUrl; - const nextSettings: AppSettings = { - ...latestSettings, - remoteBackendHost: nextHost, - remoteBackendToken: nextToken, - remoteBackendProvider: nextProvider, - orbitWsUrl: nextOrbitWsUrl, - ...(mobilePlatform - ? { - backendMode: "remote", - } - : {}), - }; - const unchanged = - nextSettings.remoteBackendHost === latestSettings.remoteBackendHost && - nextSettings.remoteBackendToken === latestSettings.remoteBackendToken && - nextSettings.orbitWsUrl === latestSettings.orbitWsUrl && - nextSettings.backendMode === latestSettings.backendMode && - nextSettings.remoteBackendProvider === latestSettings.remoteBackendProvider; - if (unchanged) { - return; - } - await onUpdateAppSettings(nextSettings); - latestSettingsRef.current = nextSettings; - }, - [mobilePlatform, onUpdateAppSettings], - ); - - const applyRemoteHost = async (rawValue: string) => { - const nextHost = rawValue.trim() || DEFAULT_REMOTE_HOST; - setRemoteHostDraft(nextHost); - await updateRemoteBackendSettings({ host: nextHost }); - }; - - const handleCommitRemoteHost = async () => { - await applyRemoteHost(remoteHostDraft); - }; - - const handleCommitRemoteToken = async () => { - const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; - setRemoteTokenDraft(nextToken ?? ""); - await updateRemoteBackendSettings({ token: nextToken }); - }; - - const handleMobileConnectTest = () => { - void (async () => { - const provider = latestSettingsRef.current.remoteBackendProvider; - const nextToken = remoteTokenDraft.trim() ? remoteTokenDraft.trim() : null; - setRemoteTokenDraft(nextToken ?? ""); - setMobileConnectBusy(true); - setMobileConnectStatusText(null); - setMobileConnectStatusError(false); - try { - if (provider === "tcp") { - const nextHost = remoteHostDraft.trim() || DEFAULT_REMOTE_HOST; - setRemoteHostDraft(nextHost); - await updateRemoteBackendSettings({ - host: nextHost, - token: nextToken, - }); - } else { - const nextOrbitWsUrl = normalizeOverrideValue(orbitWsUrlDraft); - setOrbitWsUrlDraft(nextOrbitWsUrl ?? ""); - if (!nextOrbitWsUrl) { - throw new Error("Orbit websocket URL is required."); - } - await updateRemoteBackendSettings({ - token: nextToken, - orbitWsUrl: nextOrbitWsUrl, - }); - } - const workspaces = await listWorkspaces(); - const workspaceCount = workspaces.length; - const workspaceWord = workspaceCount === 1 ? "workspace" : "workspaces"; - setMobileConnectStatusText( - `Connected. ${workspaceCount} ${workspaceWord} reachable on the remote backend.`, - ); - await onMobileConnectSuccess?.(); - } catch (error) { - setMobileConnectStatusError(true); - setMobileConnectStatusText( - error instanceof Error ? error.message : "Unable to connect to remote backend.", - ); - } finally { - setMobileConnectBusy(false); - } - })(); - }; - - useEffect(() => { - if (!mobilePlatform) { - return; - } - setMobileConnectStatusText(null); - setMobileConnectStatusError(false); - }, [ - appSettings.remoteBackendProvider, - mobilePlatform, - orbitWsUrlDraft, - remoteHostDraft, - remoteTokenDraft, - ]); - - const handleChangeRemoteProvider = async ( - provider: AppSettings["remoteBackendProvider"], - ) => { - if (provider === latestSettingsRef.current.remoteBackendProvider) { - return; - } - await updateRemoteBackendSettings({ - provider, - }); - }; - - const handleRefreshTailscaleStatus = useCallback(() => { - void (async () => { - setTailscaleStatusBusy(true); - setTailscaleStatusError(null); - try { - const status = await fetchTailscaleStatus(); - setTailscaleStatus(status); - } catch (error) { - setTailscaleStatusError( - error instanceof Error ? error.message : "Unable to load Tailscale status.", - ); - } finally { - setTailscaleStatusBusy(false); - } - })(); - }, []); - - const handleRefreshTailscaleCommandPreview = useCallback(() => { - void (async () => { - setTailscaleCommandBusy(true); - setTailscaleCommandError(null); - try { - const preview = await fetchTailscaleDaemonCommandPreview(); - setTailscaleCommandPreview(preview); - } catch (error) { - setTailscaleCommandError( - error instanceof Error - ? error.message - : "Unable to build Tailscale daemon command.", - ); - } finally { - setTailscaleCommandBusy(false); - } - })(); - }, []); - - const handleUseSuggestedTailscaleHost = async () => { - const suggestedHost = tailscaleStatus?.suggestedRemoteHost ?? null; - if (!suggestedHost) { - return; - } - await applyRemoteHost(suggestedHost); - }; - - const runTcpDaemonAction = useCallback( - async ( - action: "start" | "stop" | "status", - run: () => Promise, - ) => { - setTcpDaemonBusyAction(action); - try { - const status = await run(); - setTcpDaemonStatus(status); - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unable to update mobile access daemon status."; - setTcpDaemonStatus((prev) => ({ - state: "error", - pid: null, - startedAtMs: null, - lastError: errorMessage, - listenAddr: prev?.listenAddr ?? null, - })); - } finally { - setTcpDaemonBusyAction(null); - } - }, - [], - ); - - const handleTcpDaemonStart = useCallback(async () => { - await runTcpDaemonAction("start", tailscaleDaemonStart); - }, [runTcpDaemonAction]); - - const handleTcpDaemonStop = useCallback(async () => { - await runTcpDaemonAction("stop", tailscaleDaemonStop); - }, [runTcpDaemonAction]); - - const handleTcpDaemonStatus = useCallback(async () => { - await runTcpDaemonAction("status", tailscaleDaemonStatus); - }, [runTcpDaemonAction]); - - const handleCommitOrbitWsUrl = async () => { - const nextValue = normalizeOverrideValue(orbitWsUrlDraft); - setOrbitWsUrlDraft(nextValue ?? ""); - await updateRemoteBackendSettings({ - orbitWsUrl: nextValue, - }); - }; - - const handleCommitOrbitAuthUrl = async () => { - const nextValue = normalizeOverrideValue(orbitAuthUrlDraft); - setOrbitAuthUrlDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAuthUrl) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAuthUrl: nextValue, - }); - }; - - const handleCommitOrbitRunnerName = async () => { - const nextValue = normalizeOverrideValue(orbitRunnerNameDraft); - setOrbitRunnerNameDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitRunnerName) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitRunnerName: nextValue, - }); - }; - - const handleCommitOrbitAccessClientId = async () => { - const nextValue = normalizeOverrideValue(orbitAccessClientIdDraft); - setOrbitAccessClientIdDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAccessClientId) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAccessClientId: nextValue, - }); - }; - - const handleCommitOrbitAccessClientSecretRef = async () => { - const nextValue = normalizeOverrideValue(orbitAccessClientSecretRefDraft); - setOrbitAccessClientSecretRefDraft(nextValue ?? ""); - if (nextValue === appSettings.orbitAccessClientSecretRef) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - orbitAccessClientSecretRef: nextValue, - }); - }; - - const runOrbitAction = async ( - actionKey: string, - actionLabel: string, - action: () => Promise, - successFallback: string, - ): Promise => { - setOrbitBusyAction(actionKey); - setOrbitStatusText(`${actionLabel}...`); - try { - const result = await action(); - setOrbitStatusText(getOrbitStatusText(result, successFallback)); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`${actionLabel} failed: ${message}`); - return null; - } finally { - setOrbitBusyAction(null); - } - }; - - const syncRemoteBackendToken = async (nextToken: string | null) => { - const normalizedToken = nextToken?.trim() ? nextToken.trim() : null; - setRemoteTokenDraft(normalizedToken ?? ""); - const latestSettings = latestSettingsRef.current; - if (normalizedToken === latestSettings.remoteBackendToken) { - return; - } - const nextSettings = { - ...latestSettings, - remoteBackendToken: normalizedToken, - }; - await onUpdateAppSettings({ - ...nextSettings, - }); - latestSettingsRef.current = nextSettings; - }; - - const handleOrbitConnectTest = () => { - void runOrbitAction( - "connect-test", - "Connect test", - orbitServiceClient.orbitConnectTest, - "Orbit connection test succeeded.", - ); - }; - - const handleOrbitSignIn = () => { - void (async () => { - setOrbitBusyAction("sign-in"); - setOrbitStatusText("Starting Orbit sign in..."); - setOrbitAuthCode(null); - setOrbitVerificationUrl(null); - try { - const startResult = await orbitServiceClient.orbitSignInStart(); - setOrbitAuthCode(startResult.userCode ?? startResult.deviceCode); - setOrbitVerificationUrl( - startResult.verificationUriComplete ?? startResult.verificationUri, - ); - setOrbitStatusText( - "Orbit sign in started. Finish authorization in the browser window, then keep this dialog open while we poll for completion.", - ); - - const maxPollWindowSeconds = Math.max( - 1, - Math.min(startResult.expiresInSeconds, ORBIT_MAX_INLINE_POLL_SECONDS), - ); - const deadlineMs = Date.now() + maxPollWindowSeconds * 1000; - let pollIntervalSeconds = Math.max( - 1, - startResult.intervalSeconds || ORBIT_DEFAULT_POLL_INTERVAL_SECONDS, - ); - - while (Date.now() < deadlineMs) { - await delay(pollIntervalSeconds * 1000); - const pollResult = await orbitServiceClient.orbitSignInPoll( - startResult.deviceCode, - ); - setOrbitStatusText( - getOrbitStatusText(pollResult, "Orbit sign in status refreshed."), - ); - - if (pollResult.status === "pending") { - if (typeof pollResult.intervalSeconds === "number") { - pollIntervalSeconds = Math.max(1, pollResult.intervalSeconds); - } - continue; - } - - if (pollResult.status === "authorized") { - if (pollResult.token) { - await syncRemoteBackendToken(pollResult.token); - } - } - return; - } - - setOrbitStatusText( - "Orbit sign in is still pending. Leave this window open and try Sign In again if authorization just completed.", - ); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`Sign In failed: ${message}`); - } finally { - setOrbitBusyAction(null); - } - })(); - }; - - const handleOrbitSignOut = () => { - void (async () => { - const result = await runOrbitAction( - "sign-out", - "Sign Out", - orbitServiceClient.orbitSignOut, - "Signed out from Orbit.", - ); - if (result !== null) { - try { - await syncRemoteBackendToken(null); - setOrbitAuthCode(null); - setOrbitVerificationUrl(null); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown Orbit error"; - setOrbitStatusText(`Sign Out failed: ${message}`); - } - } - })(); - }; - - const handleOrbitRunnerStart = () => { - void runOrbitAction( - "runner-start", - "Start Runner", - orbitServiceClient.orbitRunnerStart, - "Orbit runner started.", - ); - }; - - const handleOrbitRunnerStop = () => { - void runOrbitAction( - "runner-stop", - "Stop Runner", - orbitServiceClient.orbitRunnerStop, - "Orbit runner stopped.", - ); - }; - - const handleOrbitRunnerStatus = () => { - void runOrbitAction( - "runner-status", - "Refresh Status", - orbitServiceClient.orbitRunnerStatus, - "Orbit runner status refreshed.", - ); - }; - - useEffect(() => { - if (appSettings.remoteBackendProvider !== "tcp") { - return; - } - if (!mobilePlatform) { - handleRefreshTailscaleCommandPreview(); - void handleTcpDaemonStatus(); - } - if (tailscaleStatus === null && !tailscaleStatusBusy && !tailscaleStatusError) { - handleRefreshTailscaleStatus(); - } - }, [ - appSettings.remoteBackendProvider, - appSettings.remoteBackendToken, - handleRefreshTailscaleCommandPreview, - handleRefreshTailscaleStatus, - handleTcpDaemonStatus, - mobilePlatform, - tailscaleStatus, - tailscaleStatusBusy, - tailscaleStatusError, - ]); - - const handleCommitScale = async () => { - if (parsedScale === null) { - setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); - return; - } - const nextScale = clampUiScale(parsedScale); - setScaleDraft(`${Math.round(nextScale * 100)}%`); - if (nextScale === appSettings.uiScale) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - uiScale: nextScale, - }); - }; - - const handleResetScale = async () => { - if (appSettings.uiScale === 1) { - setScaleDraft("100%"); - return; - } - setScaleDraft("100%"); - await onUpdateAppSettings({ - ...appSettings, - uiScale: 1, - }); - }; - - const handleCommitUiFont = async () => { - const nextFont = normalizeFontFamily( - uiFontDraft, - DEFAULT_UI_FONT_FAMILY, - ); - setUiFontDraft(nextFont); - if (nextFont === appSettings.uiFontFamily) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - uiFontFamily: nextFont, - }); - }; - - const handleCommitCodeFont = async () => { - const nextFont = normalizeFontFamily( - codeFontDraft, - DEFAULT_CODE_FONT_FAMILY, - ); - setCodeFontDraft(nextFont); - if (nextFont === appSettings.codeFontFamily) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - codeFontFamily: nextFont, - }); - }; - - const handleCommitCodeFontSize = async (nextSize: number) => { - const clampedSize = clampCodeFontSize(nextSize); - setCodeFontSizeDraft(clampedSize); - if (clampedSize === appSettings.codeFontSize) { - return; - } - await onUpdateAppSettings({ - ...appSettings, - codeFontSize: clampedSize, - }); - }; - - const handleComposerPresetChange = ( - preset: AppSettings["composerEditorPreset"], - ) => { - const config = COMPOSER_PRESET_CONFIGS[preset]; - void onUpdateAppSettings({ - ...appSettings, - composerEditorPreset: preset, - ...config, - }); - }; - - const handleBrowseCodex = async () => { - const selection = await open({ multiple: false, directory: false }); - if (!selection || Array.isArray(selection)) { - return; - } - setCodexPathDraft(selection); - }; - - const handleRunDoctor = async () => { - setDoctorState({ status: "running", result: null }); - try { - const result = await onRunDoctor(nextCodexBin, nextCodexArgs); - setDoctorState({ status: "done", result }); - } catch (error) { - setDoctorState({ - status: "done", - result: { - ok: false, - codexBin: nextCodexBin, - version: null, - appServerOk: false, - details: error instanceof Error ? error.message : String(error), - path: null, - nodeOk: false, - nodeVersion: null, - nodeDetails: null, - }, - }); - } - }; - - const handleRunCodexUpdate = async () => { - setCodexUpdateState({ status: "running", result: null }); - try { - if (!onRunCodexUpdate) { - setCodexUpdateState({ - status: "done", - result: { - ok: false, - method: "unknown", - package: null, - beforeVersion: null, - afterVersion: null, - upgraded: false, - output: null, - details: "Codex updates are not available in this build.", - }, - }); - return; - } - - const result = await onRunCodexUpdate(nextCodexBin, nextCodexArgs); - setCodexUpdateState({ status: "done", result }); - } catch (error) { - setCodexUpdateState({ - status: "done", - result: { - ok: false, - method: "unknown", - package: null, - beforeVersion: null, - afterVersion: null, - upgraded: false, - output: null, - details: error instanceof Error ? error.message : String(error), - }, - }); - } - }; - - const handleSaveEnvironmentSetup = async () => { - if (!environmentWorkspace || environmentSaving) { - return; - } - const nextScript = environmentDraftNormalized; - setEnvironmentSaving(true); - setEnvironmentError(null); - try { - await onUpdateWorkspaceSettings(environmentWorkspace.id, { - worktreeSetupScript: nextScript, - }); - setEnvironmentSavedScript(nextScript); - setEnvironmentDraftScript(nextScript ?? ""); - } catch (error) { - setEnvironmentError(error instanceof Error ? error.message : String(error)); - } finally { - setEnvironmentSaving(false); - } - }; - - const trimmedGroupName = newGroupName.trim(); - const canCreateGroup = Boolean(trimmedGroupName); - - const handleCreateGroup = async () => { - setGroupError(null); - try { - const created = await onCreateWorkspaceGroup(newGroupName); - if (created) { - setNewGroupName(""); - } - } catch (error) { - setGroupError(error instanceof Error ? error.message : String(error)); - } - }; - - const handleRenameGroup = async (group: WorkspaceGroup) => { - const draft = groupDrafts[group.id] ?? ""; - const trimmed = draft.trim(); - if (!trimmed || trimmed === group.name) { - setGroupDrafts((prev) => ({ - ...prev, - [group.id]: group.name, - })); - return; - } - setGroupError(null); - try { - await onRenameWorkspaceGroup(group.id, trimmed); - } catch (error) { - setGroupError(error instanceof Error ? error.message : String(error)); - setGroupDrafts((prev) => ({ - ...prev, - [group.id]: group.name, - })); - } - }; - - const updateGroupCopiesFolder = async ( - groupId: string, - copiesFolder: string | null, - ) => { - setGroupError(null); - try { - await onUpdateAppSettings({ - ...appSettings, - workspaceGroups: appSettings.workspaceGroups.map((entry) => - entry.id === groupId ? { ...entry, copiesFolder } : entry, - ), - }); - } catch (error) { - setGroupError(error instanceof Error ? error.message : String(error)); - } - }; - - const handleChooseGroupCopiesFolder = async (group: WorkspaceGroup) => { - const selection = await open({ multiple: false, directory: true }); - if (!selection || Array.isArray(selection)) { - return; - } - await updateGroupCopiesFolder(group.id, selection); - }; - - const handleClearGroupCopiesFolder = async (group: WorkspaceGroup) => { - if (!group.copiesFolder) { - return; - } - await updateGroupCopiesFolder(group.id, null); - }; - - const handleDeleteGroup = async (group: WorkspaceGroup) => { - const groupProjects = - groupedWorkspaces.find((entry) => entry.id === group.id)?.workspaces ?? []; - const detail = - groupProjects.length > 0 - ? `\n\nProjects in this group will move to "${ungroupedLabel}".` - : ""; - const confirmed = await ask( - `Delete "${group.name}"?${detail}`, - { - title: "Delete Group", - kind: "warning", - okLabel: "Delete", - cancelLabel: "Cancel", - }, - ); - if (!confirmed) { - return; - } - setGroupError(null); - try { - await onDeleteWorkspaceGroup(group.id); - } catch (error) { - setGroupError(error instanceof Error ? error.message : String(error)); - } - }; const activeSectionLabel = SETTINGS_SECTION_LABELS[activeSection]; const settingsBodyClassName = `settings-body${ useMobileMasterDetail ? " settings-body-mobile-master-detail" : "" @@ -1300,252 +191,18 @@ export function SettingsView({ Sections -
- {activeSectionLabel} -
+
{activeSectionLabel}
)}
- {activeSection === "projects" && ( - - )} - {activeSection === "environments" && ( - - )} - {activeSection === "display" && ( - - )} - {activeSection === "composer" && ( - - )} - {activeSection === "dictation" && ( - - )} - {activeSection === "shortcuts" && ( - - )} - {activeSection === "open-apps" && ( - - )} - {activeSection === "git" && ( - - )} - {activeSection === "server" && ( - - )} - {activeSection === "codex" && ( - { - void refreshGlobalAgents(); - }} - onSaveGlobalAgents={() => { - void saveGlobalAgents(); - }} - onRefreshGlobalConfig={() => { - void refreshGlobalConfig(); - }} - onSaveGlobalConfig={() => { - void saveGlobalConfig(); - }} - onUpdateWorkspaceCodexBin={onUpdateWorkspaceCodexBin} - onUpdateWorkspaceSettings={onUpdateWorkspaceSettings} - /> - )} - {activeSection === "features" && ( - { - void handleOpenConfig(); - }} - onUpdateAppSettings={onUpdateAppSettings} - /> - )} +
)} -
+
); } diff --git a/src/features/settings/components/sections/SettingsCodexSection.tsx b/src/features/settings/components/sections/SettingsCodexSection.tsx index 0d2c5f06b8..84ebdd2439 100644 --- a/src/features/settings/components/sections/SettingsCodexSection.tsx +++ b/src/features/settings/components/sections/SettingsCodexSection.tsx @@ -1,16 +1,23 @@ +import { useEffect, useMemo, useRef } from "react"; import Stethoscope from "lucide-react/dist/esm/icons/stethoscope"; import type { Dispatch, SetStateAction } from "react"; import type { AppSettings, CodexDoctorResult, CodexUpdateResult, + ModelOption, WorkspaceInfo, -} from "../../../../types"; -import { FileEditorCard } from "../../../shared/components/FileEditorCard"; +} from "@/types"; +import { FileEditorCard } from "@/features/shared/components/FileEditorCard"; type SettingsCodexSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + defaultModels: ModelOption[]; + defaultModelsLoading: boolean; + defaultModelsError: string | null; + defaultModelsConnectedWorkspaceCount: number; + onRefreshDefaultModels: () => void; codexPathDraft: string; codexArgsDraft: string; codexDirty: boolean; @@ -68,9 +75,58 @@ const normalizeOverrideValue = (value: string): string | null => { return trimmed ? trimmed : null; }; +const DEFAULT_REASONING_EFFORT = "medium"; + +const normalizeEffortValue = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed.toLowerCase() : null; +}; + +function coerceSavedModelSlug(value: string | null, models: ModelOption[]): string | null { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return null; + } + const bySlug = models.find((model) => model.model === trimmed); + if (bySlug) { + return bySlug.model; + } + const byId = models.find((model) => model.id === trimmed); + return byId ? byId.model : null; +} + +const getReasoningSupport = (model: ModelOption | null): boolean => { + if (!model) { + return false; + } + return model.supportedReasoningEfforts.length > 0 || model.defaultReasoningEffort !== null; +}; + +const getReasoningOptions = (model: ModelOption | null): string[] => { + if (!model) { + return []; + } + const supported = model.supportedReasoningEfforts + .map((effort) => normalizeEffortValue(effort.reasoningEffort)) + .filter((effort): effort is string => Boolean(effort)); + if (supported.length > 0) { + return Array.from(new Set(supported)); + } + const fallback = normalizeEffortValue(model.defaultReasoningEffort); + return fallback ? [fallback] : []; +}; + export function SettingsCodexSection({ appSettings, onUpdateAppSettings, + defaultModels, + defaultModelsLoading, + defaultModelsError, + defaultModelsConnectedWorkspaceCount, + onRefreshDefaultModels, codexPathDraft, codexArgsDraft, codexDirty, @@ -113,6 +169,87 @@ export function SettingsCodexSection({ onUpdateWorkspaceCodexBin, onUpdateWorkspaceSettings, }: SettingsCodexSectionProps) { + const latestModelSlug = defaultModels[0]?.model ?? null; + const savedModelSlug = useMemo( + () => coerceSavedModelSlug(appSettings.lastComposerModelId, defaultModels), + [appSettings.lastComposerModelId, defaultModels], + ); + const selectedModelSlug = savedModelSlug ?? latestModelSlug ?? ""; + const selectedModel = useMemo( + () => defaultModels.find((model) => model.model === selectedModelSlug) ?? null, + [defaultModels, selectedModelSlug], + ); + const reasoningSupported = useMemo( + () => getReasoningSupport(selectedModel), + [selectedModel], + ); + const reasoningOptions = useMemo( + () => getReasoningOptions(selectedModel), + [selectedModel], + ); + const savedEffort = useMemo( + () => normalizeEffortValue(appSettings.lastComposerReasoningEffort), + [appSettings.lastComposerReasoningEffort], + ); + const selectedEffort = useMemo(() => { + if (!reasoningSupported) { + return ""; + } + if (savedEffort && reasoningOptions.includes(savedEffort)) { + return savedEffort; + } + if (reasoningOptions.includes(DEFAULT_REASONING_EFFORT)) { + return DEFAULT_REASONING_EFFORT; + } + const fallback = normalizeEffortValue(selectedModel?.defaultReasoningEffort); + if (fallback && reasoningOptions.includes(fallback)) { + return fallback; + } + return reasoningOptions[0] ?? ""; + }, [reasoningOptions, reasoningSupported, savedEffort, selectedModel]); + + const didNormalizeDefaultsRef = useRef(false); + useEffect(() => { + if (didNormalizeDefaultsRef.current) { + return; + } + if (!defaultModels.length) { + return; + } + const savedRawModel = (appSettings.lastComposerModelId ?? "").trim(); + const savedRawEffort = (appSettings.lastComposerReasoningEffort ?? "").trim(); + const shouldNormalizeModel = savedRawModel.length === 0 || savedModelSlug === null; + const shouldNormalizeEffort = + reasoningSupported && + (savedRawEffort.length === 0 || + savedEffort === null || + !reasoningOptions.includes(savedEffort)); + if (!shouldNormalizeModel && !shouldNormalizeEffort) { + didNormalizeDefaultsRef.current = true; + return; + } + + const next: AppSettings = { + ...appSettings, + lastComposerModelId: shouldNormalizeModel ? selectedModelSlug : appSettings.lastComposerModelId, + lastComposerReasoningEffort: shouldNormalizeEffort + ? selectedEffort + : appSettings.lastComposerReasoningEffort, + }; + didNormalizeDefaultsRef.current = true; + void onUpdateAppSettings(next); + }, [ + appSettings, + defaultModels.length, + onUpdateAppSettings, + reasoningOptions, + reasoningSupported, + savedEffort, + savedModelSlug, + selectedModelSlug, + selectedEffort, + ]); + return (
Codex
@@ -266,10 +403,99 @@ export function SettingsCodexSection({ )}
-
- +
+
+ Default parameters +
+ +
+
+ +
+ {defaultModelsConnectedWorkspaceCount === 0 + ? "Connect a project to load available models." + : defaultModelsLoading + ? "Loading models…" + : defaultModelsError + ? `Couldn’t load models: ${defaultModelsError}` + : "Used when there is no thread-specific override."} +
+
+
+ + +
+
+ +
+
+ +
+ {reasoningSupported + ? "Available options depend on the selected model." + : "The selected model does not expose reasoning effort options."} +
+
+ +
+ +
+
+ +
+ Used when there is no thread-specific override. +
+