feat: project editor with drag & drop, search, Quick Open (#11)

* fix: add retry logic to sendInboxMessage for concurrent writes

On Windows, parallel writes to the same inbox file cause race conditions
where atomicWrite verification fails (another process overwrites between
write and verify). Added retry loop (8 attempts) matching the existing
pattern in addTaskComment. Bumps teamctl version to 11.

Fixes CI failure: test (windows-latest) "parallel messages to same inbox"

* fix: enhance CLI installer and session management

- Updated the postinstall script in package.json to handle rebuild failures gracefully.
- Added clearContext option in team launch requests to allow starting fresh sessions without resuming previous context.
- Improved CLI installer logging by integrating raw output chunks for better terminal rendering.
- Refactored components to utilize TerminalLogPanel for displaying installation logs, enhancing user experience during CLI installation.
- Updated various services and hooks to support the new clearContext feature and raw logging.

* fix: update MemberBadge and LaunchTeamDialog components for improved functionality

- Modified MemberBadge to display 'lead' for team leads instead of the full name.
- Refactored LaunchTeamDialog to simplify model selection logic and replace the Select component with a custom button-based interface for better user experience.
- Enhanced KanbanTaskCard to include meta actions for task management, improving the layout and functionality for manual review tasks.

* feat: auto-publish releases with stable download links

- Change releaseType from draft to release for auto-publishing
- Add upload-stable-links job to create version-agnostic asset copies
- Update README with direct download URLs per platform
- Add Requirements section to Installation
- Remove downloads/platform badges
- Add docs/RELEASE.md with versioning and release guide
- Move community docs to .github/

* improvemtns

* improvement

* fix: handle Windows spawn EINVAL on non-ASCII paths and add helper utilities

* improvements

* fix: enhance child process environment handling for Windows

- Added a helper function to build the child process environment with the correct HOME directory, addressing issues with non-ASCII usernames on Windows.
- Updated CLI installer methods to utilize the new environment setup for improved compatibility and error handling.

* refactor: replace execFile and spawn with execCli and spawnCli in CLI and TeamProvisioning services

- Updated CliInstallerService and TeamProvisioningService to use execCli and spawnCli for improved error handling and compatibility, particularly on Windows.
- Enhanced child process utility functions to better manage non-ASCII paths and provide consistent behavior across different platforms.
- Adjusted tests to mock new child process utilities and verify correct usage in service methods.

* fix

* fix windows

* feat: add download badges with direct links per platform

* refactor: move download buttons to Installation section

* refactor: move Docker files to docker/ and CHANGELOG to docs/

* refactor: move vite.standalone.config to docker/, remove .nvmrc

* refactor: merge tsconfig.test.json into tsconfig.json

* refactor: remove .editorconfig, .gitattributes, merge knip.json into package.json

* fix: adjust macOS download badge sizing

* feat: implement in-app project editor with CodeMirror integration

- Added architectural plan and iteration plan for the in-app project editor.
- Introduced new components for the editor, including CodeEditorOverlay, FileTreePanel, and EditorTabsPanel.
- Established state management using Zustand for editor state persistence.
- Implemented IPC channels for file operations and editor functionality.
- Enhanced TeamDetailView with a button to open the editor overlay.
- Conducted reuse analysis for existing components to optimize codebase integration.

* feat: enhance in-app project editor with architecture documentation and service updates

- Added detailed architecture and component hierarchy documentation for the in-app project editor.
- Introduced `ProjectFileService` to manage file operations with improved path validation.
- Updated `electron.vite.config.ts` to set `UV_THREADPOOL_SIZE` for better performance on Windows.
- Deferred non-critical startup tasks in `index.ts` to avoid thread pool contention.
- Enhanced `CliInstallerService` with timeout handling for status gathering to prevent UI hangs.
- Added tests for `CliInstallerService` to ensure proper timeout behavior.

* feat: enhance project editor with file management, Git integration, and UI improvements

- Introduced `EditorFileWatcher` for live file change detection and `GitStatusService` for displaying Git status in the file tree.
- Added context menu for file operations (create, delete) and implemented multi-tab support for the editor.
- Enhanced user experience with keyboard shortcuts, search functionality, and breadcrumb navigation.
- Updated IPC channels for file operations and integrated conflict detection during file saves.
- Improved performance with file watcher optimizations and virtualized file tree rendering.

* feat: enhance project editor with autosave, improved file management, and performance optimizations

- Implemented draft autosave functionality to prevent data loss during crashes, with recovery options for unsaved changes.
- Updated file management services to support better path validation and conflict detection.
- Enhanced performance with optimized file watcher and caching strategies for project scanning.
- Improved user experience with confirmation dialogs for unsaved changes and refined keyboard shortcuts.
- Documented testing strategies and rollback plans for iterative development.

* feat: integrate simple-git for enhanced Git status tracking and improve file watcher performance

- Replaced direct Git command usage with `simple-git` for more reliable status tracking, including support for renamed files and conflict detection.
- Updated IPC channels to reflect changes in Git status retrieval method.
- Enhanced file watcher initialization on Windows to prevent UV thread pool saturation by starting watchers sequentially.
- Improved application startup by staggering context system initialization and notification listeners to optimize performance.

* feat: optimize IPC initialization and context management for improved app performance

- Deferred IPC-heavy initialization to occur after the first paint to prevent app freezing on Windows.
- Staggered notification listener setup to avoid saturating the UV thread pool during startup.
- Updated context system initialization to be lazy, ensuring local context is always ready without upfront costs.
- Enhanced data fetching sequence to reduce simultaneous IPC calls, improving overall responsiveness.

* feat: enhance project editor with new error boundary and file handling improvements

- Introduced `EditorErrorBoundary` component to catch runtime errors in CodeMirror, providing a fallback UI to prevent crashes.
- Updated file handling logic to utilize `isbinaryfile` for more reliable binary detection, replacing manual null-byte scans.
- Enhanced `openFile` method to prevent duplicate tabs for already opened files.
- Improved documentation for new components and updated file lists to reflect recent changes.
- Optimized file watcher and path validation processes for better performance and reliability.

* feat: enhance TeamConfigReader with improved file handling and concurrency

- Introduced `mapLimit` function to manage concurrent processing of team directories, optimizing performance.
- Added `readFileHead` function to read the beginning of large configuration files efficiently.
- Implemented `extractQuotedString` to safely extract values from JSON strings in configuration headers.
- Enhanced error handling and validation for team configuration files, ensuring robust processing of team data.
- Updated logic to handle large configuration files differently, improving overall reliability and performance.

* feat: enhance team management with improved session and project path history handling

- Introduced constants for maximum session and project path history limits to optimize memory usage.
- Updated `TeamConfigReader` and `TeamProvisioningService` to limit session and project path history to defined maximums.
- Enhanced `TeamMembersMetaStore` to handle large meta files more efficiently by checking file size before processing.
- Refactored state management in `teamSlice` to include optimized lookups for team summaries by name and session ID.

* feat: enhance GlobalTaskDetailDialog and TaskDetailDialog with loading state management

- Added a loading state to the TaskDetailDialog to display a loading indicator while fetching team data.
- Updated GlobalTaskDetailDialog to pass the loading state to TaskDetailDialog.
- Modified the selectTeam function in teamSlice to accept options for skipping project auto-selection, improving team data handling.

* feat: optimize team display name resolution and enhance file change handling

- Introduced a caching mechanism for team display names to reduce redundant API calls and improve performance.
- Updated file change event handling to debounce cache invalidation, preventing unnecessary rescans during rapid file changes.
- Enhanced GlobalTaskDetailDialog and TaskDetailDialog to manage loading states and improve team data fetching logic.
- Refactored teamSlice to streamline team selection and data loading processes.

* fix: improve team selection logic and prevent duplicate fetches

- Enhanced GlobalTaskDetailDialog to handle loading states more effectively, preventing unnecessary re-fetching of team data.
- Updated selectTeam function in teamSlice to guard against duplicate in-flight fetches for the same team, improving performance and user experience.
- Refactored dependencies in useEffect to ensure proper data loading behavior.

* feat: enhance team data fetching with performance logging and timeout handling

- Added performance logging to the handleGetData function, tracking the duration of team data retrieval and logging warnings for slow responses.
- Implemented a timeout mechanism in the selectTeam function to prevent long-running fetch operations, improving user experience.
- Enhanced the getTeamData method with detailed timing metrics for each data loading step, allowing for better performance analysis and debugging.

* feat: implement timeout handling and logging for team data fetching

- Added a timeout mechanism to the getBranch calls in TeamDataService to prevent hangs on Windows setups, improving reliability during team data retrieval.
- Introduced performance logging in TeamDetailView and teamSlice to track the start and completion of team selection processes, enhancing debugging capabilities.
- Updated error handling to provide clearer warnings during team provisioning and selection, improving user experience.

* feat: enhance MarkdownViewer and task dialogs with loading state management and performance logging

- Introduced character limits for Markdown content in MarkdownViewer to prevent UI freezes with large content.
- Added state management for raw content display in MarkdownViewer, allowing users to expand and view large markdown files.
- Implemented performance logging in GlobalTaskDetailDialog and TaskDetailDialog to track loading states and improve debugging.
- Updated loading state handling in TaskDetailDialog to ensure accurate representation of loading conditions.

* feat: enhance TaskCommentsSection with improved comment rendering and visibility management

- Added state management for visible comments, allowing users to see a limited number of comments for better performance.
- Implemented logic to cap the number of rendered comments, preventing UI freezes with large comment lists.
- Introduced sorting for comments based on creation date to display the most recent comments first.
- Updated the UI to inform users when only a subset of comments is being displayed, enhancing user experience.

* feat: enhance MarkdownViewer with improved character limits and syntax highlighting management

- Updated character limits for Markdown content to prevent UI freezes with large inputs.
- Introduced logic to disable syntax highlighting for medium/large content and show raw previews for very large content.
- Enhanced responsiveness of the MarkdownViewer by managing rendering based on content size.

* refactor: remove console warnings from team-related components for cleaner logging

- Eliminated console warnings in TeamDetailView, GlobalTaskDetailDialog, and TaskDetailDialog to streamline logging and reduce clutter during team data operations.
- Updated the selectTeam function in teamSlice to remove unnecessary logging, enhancing performance and readability.

* feat: add project editor with drag & drop file management

- Backend: ProjectFileService with file CRUD, search, git status, file watcher
- IPC: 12 editor channels with security validation and path containment
- Store: editorSlice with multi-tab management, draft persistence, conflict detection
- UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus
- Move: fs.rename with EXDEV fallback, full path remapping across all caches
- Tests: comprehensive coverage for services, IPC handlers, store, and utilities

* fix: rename closeTab/setActiveTab to closeEditorTab/setActiveEditorTab

Resolve naming collision between editorSlice and tabSlice.
Both slices defined closeTab and setActiveTab, and since editorSlice
was spread last in the store composition, it silently overwrote the
tabSlice methods, breaking tab management.

* fix: editor improvements — isDir bug, scroll-to-line, Quick Open, a11y

- Fix isDir heuristic: use backend-provided isDirectory instead of
  filename-based guessing (breaks for Makefile, .github, etc.)
- Add scroll-to-line on search result click via editorPendingGoToLine
- Add Cmd+Shift+W shortcut for toggling line wrap
- Rewrite Quick Open to fetch all project files from backend API
  instead of flattening the loaded tree (limited to expanded dirs)
- Fix fd leak in atomicWrite: close file handle in finally block
- Add a11y: role=dialog/alert, aria-modal, aria-label on modals
- Add type=button on error state buttons

---------

Co-authored-by: Алексей <aleksei@example.com>
This commit is contained in:
Илия 2026-03-01 07:56:59 +02:00 committed by GitHub
parent 263c683b42
commit 945d5b237c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
191 changed files with 22372 additions and 2272 deletions

View file

@ -1,12 +0,0 @@
root = true
[*.{ts,tsx,js,jsx,json,md,yml,yaml}]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

1
.gitattributes vendored
View file

@ -1 +0,0 @@
* text=auto eol=lf

View file

View file

@ -17,4 +17,4 @@ This project follows the Contributor Covenant Code of Conduct.
Maintainers are here to help keep our community welcoming. Theyll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards.
## Reporting
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md` (`.github/SECURITY.md`).

View file

@ -26,11 +26,11 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
For maximum trust, run the Docker container with `--network none`:
```bash
docker build -t claude-agent-teams-ui .
docker build -t claude-agent-teams-ui -f docker/Dockerfile .
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
Or with Docker Compose, uncomment `network_mode: "none"` in `docker-compose.yml`.
Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compose.yml`.
## IPC & Input Validation

View file

@ -218,3 +218,41 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm dist:linux
upload-stable-links:
needs: [release-mac, release-win, release-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Upload stable-named assets for /latest/download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
REPO="${GITHUB_REPOSITORY}"
DOWNLOAD_BASE="https://github.com/${REPO}/releases/download/v${VERSION}"
declare -A FILES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Claude-Agent-Teams-UI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Claude-Agent-Teams-UI-${VERSION}.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Claude-Agent-Teams-UI-Setup-${VERSION}.exe"
["Claude-Agent-Teams-UI.AppImage"]="Claude-Agent-Teams-UI-${VERSION}.AppImage"
["Claude-Agent-Teams-UI-amd64.deb"]="claude-agent-teams-ui_${VERSION}_amd64.deb"
["Claude-Agent-Teams-UI-x86_64.rpm"]="claude-agent-teams-ui-${VERSION}.x86_64.rpm"
["Claude-Agent-Teams-UI.pacman"]="claude-agent-teams-ui-${VERSION}.pacman"
)
# Remove old stable assets (ignore errors if they don't exist)
for STABLE_NAME in "${!FILES[@]}"; do
gh release delete-asset "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --yes 2>/dev/null || true
done
# Download versioned files and re-upload with stable names
for STABLE_NAME in "${!FILES[@]}"; do
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
curl -fSL -o "$STABLE_NAME" "${DOWNLOAD_BASE}/${VERSIONED_NAME}" && \
gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber
rm -f "$STABLE_NAME"
done

1
.nvmrc
View file

@ -1 +0,0 @@
20

View file

@ -10,27 +10,11 @@
<p align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
</p>
<br />
<p align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/Linux-Download-FCC624?logo=linux&logoColor=black&style=flat" alt="Download for Linux" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
</a>
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
</p>
<p align="center">
<sub>100% free, open source. No API keys. No configuration. Just download, open, and see everything Claude Code did.</sub>
<sub>100% free, open source. No API keys. No configuration.</sub>
</p>
<br />
@ -47,16 +31,44 @@
## Installation
### Direct Download
No prerequisites — Claude Code can be installed and configured directly from the app.
| Platform | Download | Notes |
|----------|----------|-------|
| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open |
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
<table align="center">
<tr>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a>
<br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x64.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a>
</td>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-Setup.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a>
<br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-amd64.deb">
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
</a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-x86_64.rpm">
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
</a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI.pacman">
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
</a>
</td>
</tr>
</table>
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
---
@ -114,11 +126,11 @@ pnpm dist # macOS + Windows + Linux
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](.github/CODE_OF_CONDUCT.md).
## Security
IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](SECURITY.md) for details.
IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](.github/SECURITY.md) for details.
## License

View file

@ -2,7 +2,7 @@
# Claude Agent Teams UI — Docker Compose
#
# Quick start:
# docker compose up
# docker compose -f docker/docker-compose.yml up
#
# Then open http://localhost:3456 in your browser.
#
@ -14,7 +14,9 @@
services:
claude-agent-teams-ui:
build: .
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "3456:3456"
volumes:

154
docs/RELEASE.md Normal file
View file

@ -0,0 +1,154 @@
# Release Guide
## Versioning (SemVer)
Format: `MAJOR.MINOR.PATCH`
| Bump | When | Example |
|---------|-------------------------------------------------------------|------------------|
| MAJOR | Breaking changes, major UI overhaul, incompatible data format changes | 1.0.0 → 2.0.0 |
| MINOR | New features, new panels/views, new integrations | 1.0.0 → 1.1.0 |
| PATCH | Bug fixes, performance improvements, small UI tweaks | 1.0.0 → 1.0.1 |
## Release Process
### 1. Prepare
```bash
# Make sure branch is clean and pushed
git status
git push origin <branch>
```
### 2. Create tag and push
```bash
git tag v<VERSION>
git push origin v<VERSION>
```
This triggers the `release.yml` GitHub Actions workflow which:
- Builds the app (ubuntu)
- Packages macOS arm64 + x64 (with code signing & notarization)
- Packages Windows (NSIS installer)
- Packages Linux (AppImage, deb, rpm, pacman)
- Creates a GitHub Release with all artifacts
### 3. Update release notes
After the workflow completes, edit the release notes:
```bash
gh release edit v<VERSION> --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
<paste release notes here>
EOF
)"
```
## Release Notes Template
```markdown
## Claude Agent Teams UI v<VERSION>
<1-2 sentence summary of the release>
### What's New
- feat: <feature description>
- feat: <feature description>
### Improvements
- improve: <improvement description>
### Bug Fixes
- fix: <bug fix description>
### Downloads
| Platform | File |
|----------|------|
| macOS (Apple Silicon) | [Claude-Agent-Teams-UI-<VERSION>-arm64.dmg](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>-arm64.dmg) |
| macOS (Intel) | [Claude-Agent-Teams-UI-<VERSION>.dmg](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>.dmg) |
| Windows | [Claude-Agent-Teams-UI-Setup-<VERSION>.exe](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-Setup-<VERSION>.exe) |
| Linux (AppImage) | [Claude-Agent-Teams-UI-<VERSION>.AppImage](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/Claude-Agent-Teams-UI-<VERSION>.AppImage) |
| Linux (deb) | [claude-agent-teams-ui_<VERSION>_amd64.deb](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui_<VERSION>_amd64.deb) |
| Linux (rpm) | [claude-agent-teams-ui-<VERSION>.x86_64.rpm](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.x86_64.rpm) |
| Linux (pacman) | [claude-agent-teams-ui-<VERSION>.pacman](https://github.com/777genius/claude_agent_teams_ui/releases/download/v<VERSION>/claude-agent-teams-ui-<VERSION>.pacman) |
```
## Changelog Guidelines
Write changelog entries from the **user's perspective**, not the developer's.
**Good:**
- "Add team member activity timeline with live status tracking"
- "Fix crash when opening sessions with corrupted JSONL data"
- "Improve session list loading speed by 3x with streaming parser"
**Bad:**
- "Refactor ChunkBuilder to use new pipeline"
- "Update dependencies"
- "Fix bug in useEffect cleanup"
Group entries by type: `What's New` > `Improvements` > `Bug Fixes` > `Breaking Changes` (if any).
## File Naming Convention
electron-builder generates these artifacts per platform:
| Platform | Versioned Name | Stable Name (for /latest/download) |
|------------------|--------------------------------------------------|--------------------------------------------|
| macOS arm64 DMG | `Claude-Agent-Teams-UI-<VER>-arm64.dmg` | `Claude-Agent-Teams-UI-arm64.dmg` |
| macOS x64 DMG | `Claude-Agent-Teams-UI-<VER>.dmg` | `Claude-Agent-Teams-UI-x64.dmg` |
| macOS arm64 ZIP | `Claude-Agent-Teams-UI-<VER>-arm64-mac.zip` | — |
| macOS x64 ZIP | `Claude-Agent-Teams-UI-<VER>-mac.zip` | — |
| Windows | `Claude-Agent-Teams-UI-Setup-<VER>.exe` | `Claude-Agent-Teams-UI-Setup.exe` |
| Linux AppImage | `Claude-Agent-Teams-UI-<VER>.AppImage` | `Claude-Agent-Teams-UI.AppImage` |
| Linux deb | `claude-agent-teams-ui_<VER>_amd64.deb` | `Claude-Agent-Teams-UI-amd64.deb` |
| Linux rpm | `claude-agent-teams-ui-<VER>.x86_64.rpm` | `Claude-Agent-Teams-UI-x86_64.rpm` |
| Linux pacman | `claude-agent-teams-ui-<VER>.pacman` | `Claude-Agent-Teams-UI.pacman` |
## Stable Download Links
The `upload-stable-links` job in `release.yml` re-uploads key assets with version-agnostic names.
This enables permanent links in README that always point to the latest release:
```
https://github.com/777genius/claude_agent_teams_ui/releases/latest/download/Claude-Agent-Teams-UI-arm64.dmg
```
GitHub automatically redirects `/releases/latest/download/FILENAME` to the asset from the most recent release. No README updates needed when releasing a new version.
## macOS Code Signing
macOS builds are signed and notarized via GitHub Actions secrets:
| Secret | Description |
|-------------------------------|------------------------------|
| `CSC_LINK` | Base64-encoded .p12 certificate |
| `CSC_KEY_PASSWORD` | Certificate password |
| `APPLE_ID` | Apple Developer account email |
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com |
| `APPLE_TEAM_ID` | Apple Developer Team ID |
Without these secrets, macOS builds will be unsigned (users need to bypass Gatekeeper manually).
## Auto-Update
electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` alongside release artifacts. These files enable the built-in auto-updater — users get notified when a new version is available.
## Quick Reference
```bash
# Create and publish a release
git tag v1.1.0
git push origin v1.1.0
# Wait for CI to finish (~10 min), then update notes
# Delete a release (if needed)
gh release delete v1.1.0 --repo 777genius/claude_agent_teams_ui --yes
git tag -d v1.1.0
git push origin :refs/tags/v1.1.0
# Check workflow status
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
```

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
# In-App Code Editor — План реализации
## Обзор
На странице `TeamDetailView` рядом с путём проекта (`data.config.projectPath`) добавляется кнопка "Open in Editor", открывающая полноэкранный редактор кода прямо внутри приложения. Редактор позволяет просматривать файловое дерево проекта, открывать файлы во вкладках с подсветкой синтаксиса, редактировать и сохранять их, создавать/удалять файлы, искать по содержимому, и отображать git-статусы.
## Tech Stack
- **Editor engine**: CodeMirror 6 (20+ пакетов `@codemirror/*` уже в `package.json`, 16 языковых пакетов)
- **Не ProseMirror**: ProseMirror -- rich-text WYSIWYG, CodeMirror -- код-редактор. Один автор (Marijn Haverbeke), CM6 уже глубоко интегрирован
- **UI**: React 18, Tailwind CSS, lucide-react иконки, Radix UI (контекстное меню, confirm dialog)
- **State**: Zustand slice (`editorSlice.ts`)
- **Виртуализация**: `@tanstack/react-virtual` (уже в проекте)
- **Fuzzy search**: `cmdk` v1.0.4 (уже в зависимостях)
- **Новые npm-зависимости**: `@codemirror/search` (итерация 1), `isbinaryfile` v5 (итерация 1, binary detection), `@radix-ui/react-context-menu` (итерация 3), `simple-git` v3.32+ (итерация 5, git status), `chokidar` v4 (итерация 5, file watcher). Остальное уже установлено
## Ключевые архитектурные решения
| Решение | Обоснование |
|---------|-------------|
| `ProjectFileService` (не `FileEditorService`) | Лучше отражает scope; аналог `TeamDataService` |
| Stateless сервис (без `rootPath` в конструкторе) | Каждый метод принимает `projectRoot`; не привязан к одному проекту |
| EditorState pooling (не CSS show/hide) | Один EditorView + `Map<tabId, EditorState>` в useRef; экономия RAM ~8-12x |
| `editorModifiedFiles: Set<string>` (не `Record<string, string>`) | Контент живёт только в CM6 EditorState; 0 re-render при наборе текста |
| `validateFilePath()` из `pathValidation.ts` (не свой `assertInsideRoot`) | Уже проверяет traversal, symlinks, sensitive patterns, cross-platform |
| `projectRoot` в module-level state (не от renderer) | Фиксируется при `editor:open`; IPC handlers берут из state |
| Overlay вместо Radix Dialog | Radix Dialog ограничивает фокус, конфликтует с CM6 |
## Навигация по плану
| # | Файл | Содержимое |
|---|------|------------|
| — | [architecture.md](architecture.md) | Архитектура, безопасность, state, IPC API, сервисы, компоненты, CM6, shortcuts, CSS |
| 0 | [iter-0-refactoring.md](iter-0-refactoring.md) | PR 0: Обязательные рефакторинги R1-R4 (отдельный PR) |
| 1 | [iter-1-walking-skeleton.md](iter-1-walking-skeleton.md) | Итерация 1: Read-only файловый браузер |
| 2 | [iter-2-editable-save.md](iter-2-editable-save.md) | Итерация 2: Editable CodeMirror + сохранение |
| 3 | [iter-3-multi-tab-crud.md](iter-3-multi-tab-crud.md) | Итерация 3: Multi-tab + создание/удаление файлов |
| 4 | [iter-4-search-shortcuts.md](iter-4-search-shortcuts.md) | Итерация 4: Горячие клавиши, поиск, UX polish |
| 5 | [iter-5-git-watching.md](iter-5-git-watching.md) | Итерация 5: Git status, file watching, conflict detection |
| — | [file-list.md](file-list.md) | Риски, бенчмарки, полный список файлов |
| — | [research-tasks.md](research-tasks.md) | 5 исследовательских задач (все COMPLETED) |
| — | [wireframes-draft.md](wireframes-draft.md) | ASCII wireframes (DRAFT, пересмотр позже) |
## Общая статистика
- **Новые файлы**: ~36
- **Модификации**: ~18 существующих файлов
- **Тесты**: ~15 новых тестовых файлов
- **Итерации**: 6 (PR 0 + 5 итераций)
- **Ресёрч**: 5/5 завершён (R1-R5, см. [research-tasks.md](research-tasks.md))

View file

@ -0,0 +1,951 @@
# Архитектура
## Архитектурная диаграмма
```
┌─────────────────────────────────────────────┐
│ TeamDetailView.tsx │
│ [FolderOpen icon] [Edit button] ◄──────────┤ Кнопка запуска
└──────────────────┬──────────────────────────┘
│ open={true}
┌──────────────────▼──────────────────────────┐
│ ProjectEditorOverlay (fixed inset-0) │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ EditorFile- │ │ EditorTabBar │ │
│ │ Tree │ │ ┌────────────────┐ │ │
│ │ (generic │ │ │ CodeMirrorEditor│ │ │
│ │ FileTree │ │ │ (single View, │ │ │
│ │ + render- │ │ │ pooled States) │ │ │
│ │ props) │ │ └────────────────┘ │ │
│ └──────────────┘ │ EditorStatusBar │ │
│ └──────────────────────┘ │
└──────────────────┬──────────────────────────┘
│ IPC (invokeIpcWithResult)
┌──────────────────▼──────────────────────────┐
│ Preload Bridge │
│ editor: { readDir, readFile, writeFile, │
│ createFile, deleteFile, createDir, │
│ searchInFiles, gitStatus } │
└──────────────────┬──────────────────────────┘
┌──────────────────▼──────────────────────────┐
│ Main Process: editor.ts (IPC handlers) │
│ activeProjectRoot (module-level state) │
│ wrapHandler() из ipcWrapper.ts │
│ │
│ ┌────────────────────────────────────┐ │
│ │ ProjectFileService (stateless) │ │
│ │ validateFilePath() на КАЖДЫЙ вызов │ │
│ │ fs.readdir / readFile / writeFile │ │
│ │ atomic write (tmp + rename) │ │
│ └────────────────────────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ FileSearchService (итерация 4) │ │
│ │ GitStatusService (итерация 5) │ │
│ │ EditorFileWatcher (итерация 5) │ │
│ └────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Компонентная иерархия
```
src/renderer/components/team/editor/
├── ProjectEditorOverlay.tsx # Полноэкранный overlay (~150-200 LOC)
├── EditorFileTree.tsx # Обёртка над generic FileTree (~150-200 LOC)
├── EditorTabBar.tsx # Панель вкладок (~100-130 LOC)
├── CodeMirrorEditor.tsx # CM6 wrapper: lifecycle + EditorState pooling + editorBridge (~250-350 LOC)
├── EditorToolbar.tsx # Save, Undo, Redo, язык (~80-100 LOC)
├── EditorStatusBar.tsx # Ln:Col, язык, отступы, кодировка (~60-80 LOC)
├── EditorContextMenu.tsx # Context menu для дерева файлов (итерация 3)
├── NewFileDialog.tsx # Inline-input для имени нового файла (итерация 3)
├── QuickOpenDialog.tsx # Cmd+P fuzzy search (итерация 4)
├── SearchInFilesPanel.tsx # Cmd+Shift+F результаты (итерация 4)
├── EditorBreadcrumb.tsx # Breadcrumb навигация (итерация 4)
├── EditorEmptyState.tsx # Нет открытых файлов + shortcuts шпаргалка
├── EditorBinaryState.tsx # Заглушка для бинарных файлов
├── EditorErrorState.tsx # Заглушка для ошибок чтения (EACCES, ENOENT)
├── EditorErrorBoundary.tsx # React ErrorBoundary для CM6 crashes (аналог DiffErrorBoundary)
├── EditorShortcutsHelp.tsx # Модальное окно shortcuts (кнопка ?)
└── GitStatusBadge.tsx # M/U/A бейджи в дереве (итерация 5)
src/renderer/utils/
└── editorBridge.ts # Module-level singleton: Store ↔ CM6 refs bridge (R3)
src/renderer/components/common/
└── FileTree.tsx # Generic FileTree<T> с render-props (рефакторинг из ReviewFileTree)
```
## Слои и направление зависимостей
```
shared/types/editor.ts (чистые типы, zero deps)
<- main/services/editor/ (зависит от fs, path, shared/types)
<- main/ipc/editor.ts (зависит от service + shared types)
<- preload/index.ts (зависит от ipcChannels)
<- renderer/store/ (зависит от api layer + shared types)
<- renderer/components/ (зависит от store + utils)
```
Обратных зависимостей нет. Каждый слой зависит только от нижнего.
---
## Безопасность
Каждый IPC handler, работающий с файловой системой, ОБЯЗАН выполнять полный набор проверок. Ниже -- чеклист для каждого handler и описание конкретных уязвимостей.
### Обязательный чеклист для каждого IPC handler
```
[ ] projectRoot из module-level state, НЕ из параметров renderer (SEC-5)
[ ] validateFilePath(path, projectRoot) ДО файловой операции (SEC-1) — кроме readDir (см. ниже)
[ ] Для WRITE-операций (writeFile, createFile, createDir, deleteFile): ДОПОЛНИТЕЛЬНО проверить `isPathWithinRoot(normalizedPath, activeProjectRoot)` ПОСЛЕ `validateFilePath()`. Причина: `validateFilePath()` считает `~/.claude` разрешённой директорией (для read-use-case review.ts), но editor НЕ должен записывать за пределы проекта (SEC-14)
[ ] Для readDir: containment через `isPathWithinAllowedDirectories()`, НЕ `validateFilePath()`. Sensitive файлы помечаются `isSensitive: true`, но НЕ фильтруются. Symlinks: `realpath()` + re-check containment (SEC-2, SEC-6)
[ ] fs.lstat() + isFile()/isDirectory() перед чтением (SEC-4)
[ ] stats.size <= MAX_FILE_SIZE_FULL (2MB) для полной загрузки; <= MAX_FILE_SIZE_PREVIEW (5MB) для preview (SEC-4)
[ ] Buffer.byteLength(content) <= MAX_WRITE_SIZE (2MB) перед записью
[ ] Device paths (/dev/, /proc/, /sys/) блокируются (SEC-4)
[ ] Запись в .git/ запрещена (SEC-12)
[ ] Post-read realpath verify -- TOCTOU mitigation (SEC-3)
[ ] Atomic write через tmp + rename (SEC-9)
[ ] Для rename (если добавлен): ОБА пути валидируются (SEC-10) -- НЕ в MVP
[ ] validateFileName() при создании файлов (SEC-7)
[ ] Только literal search в searchInFiles, НЕ regex (SEC-8)
[ ] Логирование через createLogger('IPC:editor')
[ ] Обёртка в wrapHandler -> IpcResult<T>
```
### Конкретные уязвимости и их решения
| ID | Уязвимость | Критичность | Решение |
|----|-----------|-------------|---------|
| SEC-1 | Path traversal через IPC | Critical | `validateFilePath()` из `pathValidation.ts` на каждом handler. Для `rename` -- оба пути |
| SEC-2 | Symlink escape в readDir | Critical | `entry.isSymbolicLink()` -> `fs.realpath()` -> `validateFilePath()`. Молча пропускать symlinks за пределами |
| SEC-3 | TOCTOU race condition | High | Post-read: `fs.realpath()` + повторная `validateFilePath()`. Write: atomic tmp + rename |
| SEC-4 | File size / device DoS | High | `fs.lstat()` + `isFile()` до чтения. Block `/dev/`, `/proc/`, `/sys/`. Лимит 2MB |
| SEC-5 | projectRoot от renderer | High | Module-level `let activeProjectRoot` в `editor.ts`. Устанавливается через `editor:open` |
| SEC-6 | Credential leakage | Medium | `validateFilePath()` блокирует read. В дереве: иконка замка, "Sensitive file" при клике |
| SEC-7 | XSS через имена файлов | Medium | React JSX экранирует. `validateFileName()` при создании: запрет control chars, path separators, NUL, `..`, длина > 255 |
| SEC-8 | ReDoS в searchInFiles | Medium | Только literal string search. Max 1000 файлов, max 1MB на файл |
| SEC-9 | Non-atomic write | Medium | Переиспользовать `atomicWriteAsync()` из `team/atomicWrite.ts` (randomUUID, fsync, EXDEV fallback, mkdir). Перемещается в `src/main/utils/atomicWrite.ts` |
| SEC-10 | rename двойная валидация | High | Валидировать оба пути + проверить что newPath не существует. **НЕ в MVP** -- rename убран из ProjectFileService |
| SEC-12 | Запись в .git/ | Medium | Проверка `isGitInternalPath()` в writeFile/createFile/rename |
| SEC-13 | IPC rate limiting | Low | Debounce на renderer + max 100 вызовов/секунду на main. AbortController |
| SEC-14 | `validateFilePath()` allows `~/.claude` writes | High | `validateFilePath()` считает `~/.claude/**` разрешённой директорией (линия 112: `isPathWithinRoot(target, claudeDir) → true`). Для read — ОК (review.ts). Для editor write — НЕТ: без дополнительной проверки editor может перезаписать `~/.claude/settings.json`, `teams/*/config.json` и др. Решение: в КАЖДОМ write-handler ПОСЛЕ `validateFilePath()` добавить `isPathWithinRoot(validation.normalizedPath!, activeProjectRoot)`. Если false — throw |
| SEC-15 | `editor:open` projectPath validation | Medium | `editor:open` принимает `projectPath` от renderer без валидации. Злонамеренный renderer может передать `"/"`, делая все пути валидными. Решение: validate при `editor:open``path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`, `!isPathWithinRoot(path, claudeDir)` |
### SEC-11: ИСПРАВЛЕНО (hotfix применён)
`handleSaveEditedFile` в `src/main/ipc/review.ts` ранее принимал `filePath` от renderer без валидации. **Hotfix уже применён**: добавлен `validateFilePath(filePath, null)` с проверкой перед записью, блокировкой недопустимых путей и логированием отказов. Патч также инвалидирует кеш `FileContentResolver` после сохранения.
### Новые security-утилиты (добавить в `src/main/utils/`)
| Утилита | Файл | Назначение |
|---------|------|------------|
| `validateFileName(name)` | `pathValidation.ts` | Запрет `.`, `..`, control chars, path separators, NUL, length > 255 |
| `isDevicePath(path)` | `pathValidation.ts` | Проверка `/dev/`, `/proc/`, `/sys/`, `\\\\.\\` |
| `isGitInternalPath(path)` | `pathValidation.ts` | Проверка `.git/` в пути (запрет записи, не чтения) |
| `atomicWriteAsync(path, content)` | `atomicWrite.ts` | **Перемещение** из `src/main/services/team/atomicWrite.ts`НЕ писать заново. Уже имеет randomUUID, fsync, EXDEV fallback |
### Паттерн IPC handler (обязательный)
```typescript
// src/main/ipc/editor.ts
let activeProjectRoot: string | null = null;
async function handleEditorReadFile(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<ReadFileResult>> {
return wrapHandler('readFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, activeProjectRoot);
if (!validation.valid) throw new Error(validation.error!);
// 1b. Project-only containment (SEC-14: block ~/.claude writes)
// ОБЯЗАТЕЛЬНО для write-handlers (writeFile, createFile, createDir, deleteFile)
// Для read-handlers (readFile, readDir) — не нужно (validateFilePath достаточно)
// if (!isPathWithinRoot(validation.normalizedPath!, activeProjectRoot)) {
// throw new Error('Path is outside project root');
// }
// 2. Device path block
if (isDevicePath(validation.normalizedPath!)) throw new Error('Device files blocked');
// 3. File type check
const stats = await fs.lstat(validation.normalizedPath!);
if (!stats.isFile()) throw new Error('Not a regular file');
// 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Binary check (isbinaryfile v5 — UTF-16, BOM, encoding hints)
const isBinary = await isBinaryFile(validation.normalizedPath!);
// 6. Read
const content = isBinary ? '' : await fs.readFile(validation.normalizedPath!, 'utf8');
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(validation.normalizedPath!);
const postValidation = validateFilePath(realPath, activeProjectRoot);
if (!postValidation.valid) throw new Error('Path changed during read');
return { content, size: stats.size, truncated: false, encoding: 'utf-8', isBinary };
});
}
```
---
## State Management
### Zustand slice: `editorSlice.ts`
Минимальный slice с Группой 1 создаётся на итерации 1. Группы 2-4 добавляются на итерациях 2-3.
Slice разбит на 4 логические группы:
```typescript
export interface EditorSlice {
// ═══════════════════════════════════════════════════
// Группа 1: File tree state + actions
// ═══════════════════════════════════════════════════
editorProjectPath: string | null;
editorFileTree: FileTreeEntry | null;
editorFileTreeLoading: boolean;
editorFileTreeError: string | null;
editorExpandedDirs: Record<string, boolean>; // Сохраняется при re-open. Record — согласовано с editorModifiedFiles (Zustand не отслеживает мутации Set)
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
// closeEditor() выполняет полный cleanup:
// try {
// 1. IPC editor:close → сброс activeProjectRoot + остановка watcher (best-effort)
// } catch (e) { console.error('editor:close failed', e); }
// finally {
// // ВСЕГДА выполняется, даже если IPC упал:
// 2. stateCache.current.clear() — освободить все EditorState из Map
// 3. scrollTopCache.current.clear() — освободить scroll positions
// 4. viewRef.current?.destroy() — уничтожить активный EditorView
// 5. Сброс slice state: tabs=[], tree=null, modified={}, expandedDirs={}, loading={}, errors={}
// }
loadFileTree: (dirPath: string) => Promise<void>;
expandDirectory: (dirPath: string) => Promise<void>;
// ═══════════════════════════════════════════════════
// Группа 2: Tab management
// ═══════════════════════════════════════════════════
editorOpenTabs: EditorFileTab[];
editorActiveTabId: string | null;
openFile: (filePath: string) => Promise<void>; // Dedup: если filePath уже в editorOpenTabs → setActiveTab(existing), не создавать дубликат
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
// ═══════════════════════════════════════════════════
// Группа 3: Content + Save
// ВАЖНО: Контент НЕ хранится в store!
// Контент живёт в EditorState (Map<tabId, EditorState> в useRef).
// В store -- только dirty flags, loading и статусы сохранения.
// ═══════════════════════════════════════════════════
editorFileLoading: Record<string, boolean>; // per-file loading indicator
editorModifiedFiles: Record<string, boolean>; // dirty markers (НЕ содержимое!). Record вместо Set — Zustand не отслеживает мутации Set
editorSaving: Record<string, boolean>;
editorSaveError: Record<string, string>;
markFileModified: (filePath: string) => void; // debounced, 300ms
markFileSaved: (filePath: string) => void;
saveFile: (filePath: string, content: string) => Promise<void>;
// Компонент CodeMirrorEditor вызывает: saveFile(filePath, viewRef.current.state.doc.toString())
// Store НЕ обращается к useRef — контент передаётся как аргумент при вызове
saveAllFiles: (getContent: (filePath: string) => string | null) => Promise<void>;
// CodeMirrorEditor передаёт callback: saveAllFiles((fp) => stateCache.current.get(fp)?.doc.toString() ?? null)
discardChanges: (filePath: string) => void;
hasUnsavedChanges: () => boolean; // Object.keys(editorModifiedFiles).length > 0
// ═══════════════════════════════════════════════════
// Группа 4: File operations (итерация 3)
// ═══════════════════════════════════════════════════
createFile: (parentDir: string, name: string) => Promise<void>;
deleteFile: (filePath: string) => Promise<void>;
createDirectory: (parentDir: string, name: string) => Promise<void>;
}
```
### EditorFileTab
```typescript
interface EditorFileTab {
id: string; // = filePath (уникальный ключ)
filePath: string; // Абсолютный путь
fileName: string; // Имя файла для отображения
disambiguatedLabel?: string; // "(main/utils)" для дублей
language: string; // Определяется по расширению
}
```
### Store ↔ Component Bridge (R3 — решение)
`editorBridge.ts` — module-level singleton для связи Zustand store и React refs CodeMirrorEditor.
```typescript
// src/renderer/utils/editorBridge.ts
import type { EditorState, EditorView } from '@codemirror/state';
let stateCache: Map<string, EditorState> | null = null;
let scrollTopCache: Map<string, number> | null = null;
let activeView: EditorView | null = null;
export const editorBridge = {
/** Вызывается CodeMirrorEditor при mount */
register(sc: Map<string, EditorState>, stc: Map<string, number>, view: EditorView) {
stateCache = sc; scrollTopCache = stc; activeView = view;
},
/** Вызывается CodeMirrorEditor при unmount */
unregister() { stateCache = null; scrollTopCache = null; activeView = null; },
/** Проверка: зарегистрирован ли bridge (HMR guard) */
get isRegistered(): boolean { return stateCache !== null; },
/** Для saveFile() — контент из кешированного state */
getContent(filePath: string): string | null {
return stateCache?.get(filePath)?.doc.toString() ?? null;
},
/** Для saveAllFiles() — контент всех modified файлов */
getAllModifiedContent(modifiedFiles: Record<string, boolean>): Map<string, string> {
const result = new Map<string, string>();
for (const fp of Object.keys(modifiedFiles)) {
if (!modifiedFiles[fp]) continue;
const content = stateCache?.get(fp)?.doc.toString();
if (content !== undefined) result.set(fp, content);
}
return result;
},
/** Для closeEditor() — полный cleanup */
destroy() {
activeView?.destroy();
stateCache?.clear();
scrollTopCache?.clear();
activeView = null;
},
/** Обновить ссылку на view (при tab switch view пересоздаётся) */
updateView(view: EditorView) { activeView = view; },
};
```
Паттерн аналогичен `ConfirmDialog.tsx` (module-level `globalSetState`) и `changeReviewSlice.ts` (module-level state).
**HMR guard**: При HMR модуль перезагружается → refs обнуляются. Компонент CodeMirrorEditor в `useEffect` проверяет `editorBridge.isRegistered` и перерегистрируется при необходимости:
```typescript
useEffect(() => {
editorBridge.register(stateCache.current, scrollTopCache.current, viewRef.current!);
return () => editorBridge.unregister();
}, []); // single registration at mount
```
Store actions проверяют `editorBridge.isRegistered` перед обращением — при false логируют warning и graceful fallback (не крашат).
### EditorState pooling (Map в useRef)
Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор.
```typescript
// CodeMirrorEditor.tsx
const stateCache = useRef(new Map<string, EditorState>());
const scrollTopCache = useRef(new Map<string, number>()); // scroll position per tab
const viewRef = useRef<EditorView | null>(null);
// Переключение таба:
function switchTab(oldTabId: string, newTabId: string) {
// 1. Сохранить state + scroll текущего таба
if (viewRef.current) {
stateCache.current.set(oldTabId, viewRef.current.state);
scrollTopCache.current.set(oldTabId, viewRef.current.scrollDOM.scrollTop);
viewRef.current.destroy();
}
// 2. Восстановить или создать state нового таба
const existingState = stateCache.current.get(newTabId);
viewRef.current = new EditorView({
state: existingState ?? EditorState.create({ doc: content, extensions }),
parent: containerRef.current!,
});
// 3. Восстановить scroll position (EditorState не хранит scrollTop — это свойство DOM)
const savedScrollTop = scrollTopCache.current.get(newTabId);
if (savedScrollTop !== undefined) {
requestAnimationFrame(() => {
viewRef.current?.scrollDOM.scrollTop = savedScrollTop;
});
}
}
// LRU eviction при > 30 states:
if (stateCache.current.size > 30) {
// LRU eviction: вытеснить наименее недавно использованный state (least recently used).
// Трекинг порядка: обновлять `accessOrder: string[]` при каждом switchTab (push tabId в конец,
// удалить предыдущее вхождение). Вытеснять accessOrder[0].
// При eviction:
// 1. Удалить dirty flag из editorModifiedFiles (если был) + очистить draft из localStorage
// 2. Сохранить { content: doc.toString(), cursorPos } для восстановления через EditorState.create()
}
```
### Что в store vs что в local state
| Данные | Где хранить | Почему |
|--------|-------------|--------|
| Дерево файлов, табы, dirty flags | Zustand store | Переживает перемонтирование overlay |
| Содержимое файлов | EditorState (useRef Map) | Без re-render при наборе |
| Scroll position, resize panels | useState | Локальное UI-состояние |
| Контекстное меню state | useState | Эфемерное |
| Поисковый запрос в дереве | useState | Локальное |
| expandedDirs | Zustand store | Сохраняется при re-open |
| Sidebar width | localStorage | Persist между сессиями |
### Гранулярные Zustand-селекторы (обязательно)
```typescript
// Каждый компонент подписывается ТОЛЬКО на свои данные:
const tabList = useStore(s => s.editorOpenTabs, shallow); // TabBar
const activeId = useStore(s => s.editorActiveTabId); // CodeMirrorEditor
const treeLoading = useStore(s => s.editorFileTreeLoading); // FileTreePanel
// FileTreePanel НЕ подписывается на tabs/content
// TabBar НЕ подписывается на tree state
// CodeMirrorEditor НЕ подписывается на tree/tabs
```
---
## IPC API
### Полная таблица каналов
| Канал | Итерация | Направление | Типы запроса/ответа | Описание |
|-------|----------|-------------|---------------------|----------|
| `editor:open` | 1 | renderer -> main | `(projectPath: string)` -> `IpcResult<void>` | Инициализировать editor, установить activeProjectRoot. **Валидация projectPath (SEC-15)**: `path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`/`'C:\\'`, `!isPathWithinRoot(path, claudeDir)` |
| `editor:close` | 1 | renderer -> main | `()` -> `IpcResult<void>` | Cleanup: сбросить activeProjectRoot, остановить watcher (если запущен) |
| `editor:readDir` | 1 | renderer -> main | `(dirPath: string, maxEntries?: number)` -> `IpcResult<ReadDirResult>` | Чтение директории (depth=1, lazy). Default `maxEntries=500`. "Show all" вызывает с `maxEntries=10000` |
| `editor:readFile` | 1 | renderer -> main | `(filePath: string)` -> `IpcResult<ReadFileResult>` | Чтение файла с binary detection |
| `editor:writeFile` | 2 | renderer -> main | `(filePath: string, content: string)` -> `IpcResult<void>` | Atomic write (tmp + rename) |
| `editor:createFile` | 3 | renderer -> main | `(parentDir: string, name: string, content?: string)` -> `IpcResult<void>` | Создание файла с validateFileName |
| `editor:createDir` | 3 | renderer -> main | `(parentDir: string, name: string)` -> `IpcResult<void>` | Создание директории |
| `editor:deleteFile` | 3 | renderer -> main | `(filePath: string)` -> `IpcResult<void>` | Удаление через shell.trashItem() |
| `editor:searchInFiles` | 4 | renderer -> main | `(query: string, options?: { caseSensitive?: boolean })` -> `IpcResult<SearchResult[]>` | Literal search, default case-insensitive (как SessionSearcher), max 100 results. Кнопка "Aa" в UI для toggle |
| `editor:gitStatus` | 5 | renderer -> main | `()` -> `IpcResult<GitFileStatus[]>` | git status через `simple-git`, кеш 5 сек |
| `editor:watchDir` | 5 | renderer -> main | `()` -> `IpcResult<void>` | Запуск file watcher |
| `editor:change` | 5 | main -> renderer | event: `EditorFileChangeEvent` | Файл изменился на диске |
### Типы (src/shared/types/editor.ts)
```typescript
interface FileTreeEntry {
name: string;
path: string; // Абсолютный путь
type: 'file' | 'directory';
size?: number; // Только для файлов
isSensitive?: boolean; // true для .env, .key, credentials и т.д. — показывать с замком
children?: FileTreeEntry[];
}
interface ReadDirResult {
entries: FileTreeEntry[];
truncated: boolean; // > MAX_DIR_ENTRIES
}
interface ReadFileResult {
content: string;
size: number;
mtimeMs: number; // Unix timestamp (stats.mtimeMs) — baseline для conflict detection (итерация 5)
truncated: boolean;
encoding: string;
isBinary: boolean;
}
interface GitFileStatus {
path: string;
status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'renamed' | 'conflict';
// Маппинг из simple-git StatusResult:
// status.modified → 'modified'
// status.not_added → 'untracked'
// status.staged → 'staged'
// status.deleted → 'deleted'
// status.renamed → 'renamed' (with from/to)
// status.conflicted → 'conflict'
renamedFrom?: string; // Только для 'renamed'
}
interface SearchResult {
filePath: string;
line: number;
column: number;
lineContent: string;
matchLength: number;
}
interface EditorFileChangeEvent {
type: 'change' | 'delete' | 'create';
path: string;
}
```
### API транспорт
Editor API доступен ТОЛЬКО через Electron IPC (`window.electronAPI.editor.*`). HTTP/REST endpoint НЕ требуется -- приложение не имеет standalone browser-режима. Все вызовы проходят через preload bridge (`invokeIpcWithResult`), который автоматически разворачивает `IpcResult<T>`.
### Дедупликация IPC-запросов
`Map<string, Promise<ReadFileResult>>` в renderer. Если файл уже загружается -- ждать результат, не создавать новый запрос. Invalidate при save.
---
## Main Process: ProjectFileService
Файл: `src/main/services/editor/ProjectFileService.ts`
Stateless сервис. Каждый метод принимает `projectRoot` как первый аргумент. Паттерн аналогичен `TeamDataService`.
```typescript
class ProjectFileService {
// НЕТ конструктора с rootPath
// Создаётся в module-scope editor.ts (паттерн reviewDecisionStore в review.ts)
async readDir(projectRoot: string, dirPath: string, depth?: number, maxEntries?: number): Promise<ReadDirResult>
async readFile(projectRoot: string, filePath: string): Promise<ReadFileResult>
async writeFile(projectRoot: string, filePath: string, content: string): Promise<void>
async createFile(projectRoot: string, parentDir: string, name: string, content?: string): Promise<void>
async deleteFile(projectRoot: string, filePath: string): Promise<void>
async createDir(projectRoot: string, parentDir: string, name: string): Promise<void>
async fileExists(projectRoot: string, filePath: string): Promise<boolean>
}
```
### Файловые лимиты и константы
```typescript
const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB -- полная загрузка в CM6
const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB -- preview (100 строк)
const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
const MAX_DIR_ENTRIES = 500; // Per directory (не 10,000!)
const MAX_DIR_DEPTH = 15;
const MAX_FILENAME_LENGTH = 255;
const MAX_PATH_LENGTH = 4096;
// Единый набор — используется и в readDir, и в chokidar watcher (iter-5)
const IGNORED_DIRS = ['.git', 'node_modules', '.next', 'dist', '__pycache__', '.cache', '.venv', '.tox', 'vendor'];
const IGNORED_FILES = ['.DS_Store', 'Thumbs.db'];
const BLOCKED_PATHS = ['/dev/', '/proc/', '/sys/', '\\\\.\\'];
```
### Тиерная стратегия readFile
| Размер | Поведение | Константа |
|--------|-----------|-----------|
| < 256 KB | Мгновенная загрузка, полный контент в CM6 | -- |
| 256 KB -- 2 MB | Progress indicator, полный контент в CM6 | `MAX_FILE_SIZE_FULL` |
| 2 MB -- 5 MB | Preview only (первые 100 строк) + warning banner "File too large for editing" | `MAX_FILE_SIZE_PREVIEW` |
| > 5 MB | Предложить открыть в external editor (`shell:openPath`), контент НЕ читается | -- |
Для preview-режима (2-5 MB): `readFile` возвращает `{ content: first100Lines, truncated: true, ... }`. CM6 открывается в `readOnly` режиме.
Дополнительно: детектировать минификацию (строка > 10,000 chars) -- banner "Minified" + предложение line wrapping. Binary detection: `isBinaryFile()` из `isbinaryfile` v5.0.7 (UTF-16 без BOM, encoding hints, надёжнее ручного null-byte scan).
### Atomic write
**Переиспользовать существующий `atomicWriteAsync()`** из `src/main/services/team/atomicWrite.ts` (НЕ писать новый). Он надёжнее:
- `randomUUID()` для tmp-имён (vs `pid.Date.now()` — менее уникально)
- `fsync()` (best-effort) для durability
- `EXDEV` fallback (cross-filesystem: `copyFile` + `unlink`)
- `mkdir({ recursive: true })` для безопасности
**Рефакторинг**: переместить `atomicWriteAsync()` из `src/main/services/team/atomicWrite.ts` в `src/main/utils/atomicWrite.ts` (shared utility). Обновить все импорты в team-сервисах (TeamTaskWriter, TeamDataService, TeamKanbanManager и др.). Или, при высоком blast radius, просто импортировать из `team/atomicWrite.ts` напрямую (допустимый cross-domain import для общей утилиты).
```typescript
// src/main/utils/atomicWrite.ts (перемещено из team/atomicWrite.ts)
// Используется в: ProjectFileService.writeFile(), TeamTaskWriter, TeamDataService, ...
import { atomicWriteAsync } from '@main/utils/atomicWrite';
```
### Регистрация в handlers.ts
`ProjectFileService` создаётся в module-scope внутри `editor.ts` (паттерн `reviewDecisionStore` в review.ts:55). НЕ передаётся через `initializeIpcHandlers()`его сигнатура уже имеет 15+ параметров.
```typescript
// src/main/ipc/editor.ts (module-level)
const projectFileService = new ProjectFileService();
// src/main/ipc/handlers.ts — добавить 3 вызова:
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
// В initializeIpcHandlers():
initializeEditorHandlers(); // без аргументов — сервис в module scope editor.ts
// В registerXxx блок:
registerEditorHandlers(ipcMain);
// В removeIpcHandlers():
removeEditorHandlers(ipcMain);
```
---
## Компоненты
### ProjectEditorOverlay.tsx (~150-200 LOC)
**Ответственность**: Layout shell -- `fixed inset-0 z-50`, header с кнопкой закрытия, split layout (sidebar + main).
- Паттерн: точная копия `ChangeReviewDialog.tsx` (строка 508) -- raw `<div>`, не Radix Dialog
- macOS traffic light padding: `var(--macos-traffic-light-padding-left, 72px)` в header
- `inert` атрибут на фоновый контент пока overlay открыт
- При открытии: фокус на первый файл в дереве (или CM6 если таб открыт)
- При закрытии: вернуть фокус на кнопку "Open in Editor" через `returnFocusRef`
- Escape/X с unsaved changes: ConfirmDialog с тремя кнопками -- "Save All & Close" / "Discard & Close" / "Cancel"
- Кнопка `?` в header: открывает `EditorShortcutsHelp`
### EditorFileTree.tsx (~150-200 LOC)
**Ответственность**: Тонкая обёртка над generic `FileTree<FileTreeEntry>`.
- Предоставляет `renderNodeExtra` с dirty marker + file type icon
- Предоставляет `renderNodeIcon` с иконками по типу файла
- Context menu integration (делегирует `EditorContextMenu`)
- Git status badges через `renderNodeExtra` (итерация 5)
- Пустой проект: "No files found. Create a new file?"
- Sensitive файлы: иконка замка, при клике "Sensitive file, cannot open"
- Max визуальный indent: 12 уровней (`min(level, 12) * 12px`), tooltip с полным путём
- Длинные имена: `truncate` + `title` tooltip
- ARIA: `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`, keyboard navigation (arrow keys)
### Generic FileTree.tsx (common/, ~200-250 LOC)
**Ответственность**: Переиспользуемый generic tree с render-props.
```typescript
interface FileTreeProps<T extends { name: string; path: string; type: 'file' | 'directory' }> {
nodes: TreeNode<T>[];
activeNodePath: string | null;
onNodeClick: (node: TreeNode<T>) => void;
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
collapsedFolders: Record<string, boolean>;
onToggleFolder: (fullPath: string) => void;
}
// TreeNode<T> -- generic обёртка, возвращаемая buildTree<T>():
interface TreeNode<T> {
name: string; // Имя узла (или "src/main" при collapse)
fullPath: string; // Полный путь
isFile: boolean;
data?: T; // Исходный элемент (только для leaf)
children: TreeNode<T>[];
}
```
- `ReviewFileTree`: использует `renderLeafNode` для полного рендеринга (FileStatusIcon, Eye, +/-) с кастом `node.data as FileChangeSummary`
- `EditorFileTree`: использует `renderLeafNode` для dirty marker + file type icon с кастом `node.data as FileTreeEntry`
- `renderLeafNode` заменяет весь leaf-элемент (не просто "extra"), что покрывает сложные сценарии ReviewFileTree (11 пропсов из store)
- Виртуализация через `@tanstack/react-virtual` с итерации 4: `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })`
### EditorTabBar.tsx (~100-130 LOC)
**Ответственность**: Панель вкладок с переключением, закрытием, dirty indicator.
- Modified dot ПЕРЕД текстом (не обрезается при truncate)
- Max-width ~160px на таб, `truncate`, tooltip с полным путём
- Disambiguation: два "index.ts" показывают "(main/utils)" и "(renderer/utils)" через `getDisambiguatedTabLabel()`
- Иконки файлов по типу на вкладках
- Middle-click close, X button close
- ARIA: `role="tablist"`, `role="tab"`, `aria-selected`
### CodeMirrorEditor.tsx (~250-350 LOC)
**Ответственность**: CM6 lifecycle — EditorState pooling, extensions, keybindings, bridge registration, autosave.
- Один EditorView на весь редактор (активный файл)
- `Map<tabId, EditorState>` в useRef
- Extensions через `buildEditorExtensions(options)` — фабрика, компонент не знает о конкретных CM plugins
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- LRU eviction при > 30 states
- `editorBridge.register()` при mount, `editorBridge.unregister()` при unmount (R3)
- Draft autosave в localStorage (30 сек debounce) + recovery при reopen
- Паттерн lifecycle из `MembersJsonEditor.tsx` (строки 27-73)
### EditorStatusBar.tsx (~60-80 LOC)
**Ответственность**: Нижняя полоска: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2] | [LF]`
- Данные из CM6 state (cursor position, language)
- CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6`
### EditorBinaryState.tsx (~50-60 LOC)
**Ответственность**: Заглушка вместо CM6 для бинарных файлов.
- Иконка файла, тип, размер
- Кнопки "Open in System Viewer" (`shell:openPath`) и "Close Tab"
### EditorErrorState.tsx (~50-60 LOC)
**Ответственность**: Заглушка при ошибке чтения.
- AlertTriangle + текст ошибки + [Retry] + [Close Tab]
- ENOENT: "File was deleted. Create new? / Close tab"
- EACCES: "Permission denied"
### EditorErrorBoundary.tsx (~40-50 LOC)
**Ответственность**: React ErrorBoundary, оборачивающий `CodeMirrorEditor`. Ловит runtime-ошибки CM6 (OOM, bad extension, corrupted EditorState) и показывает fallback UI вместо краша всего overlay.
- Паттерн: аналог `DiffErrorBoundary.tsx` (уже в проекте)
- Props: `filePath`, `onRetry` (сбросить EditorState и повторить)
- Fallback UI: AlertTriangle + текст ошибки + [Retry] + [Close Tab]
- `componentDidCatch`: логировать `filePath` + error для дебага
---
## File Tree
### Lazy loading
- Начальная загрузка: только root level (depth=1)
- Expand директории: IPC `editor:readDir` для конкретной папки (depth=1)
- Prefetch при hover (debounced 200ms) -- опционально
- MAX_ENTRIES_PER_DIR = 500; при превышении: "N more files..." + кнопка "Show all"
### Фильтрация и сортировка
- Скрывать на стороне main process: `.git`, `node_modules`, `.next`, `dist`, `__pycache__`, `.cache`, `.venv`, `.tox`, `vendor`, `.DS_Store`, `Thumbs.db`
- Сортировка: директории сначала, затем файлы; внутри группы -- alphabetical
- Локальный fuzzy filter по имени (без IPC)
### Виртуализация (итерация 4)
```typescript
// flattenTree преобразует иерархию в плоский массив для виртуализации
function flattenTree(tree: FileTreeEntry[], expandedDirs: Record<string, boolean>): FlatNode[] { ... }
// В компоненте:
const flatNodes = useMemo(() => flattenTree(tree, expandedDirs), [tree, expandedDirs]);
const virtualizer = useVirtualizer({
count: flatNodes.length,
estimateSize: () => 28,
getScrollElement: () => scrollRef.current,
});
```
Benchmark: 5000+ файлов, все папки раскрыты, FPS скролла >= 55fps.
### Контекстное меню (итерация 3)
- Правый клик на файл: Open, Delete, Copy Path, Reveal in Finder
- Правый клик на директорию: New File, New Directory, Delete, Copy Path, Reveal in Finder
- Правый клик на пустом: New File, New Directory
---
## CodeMirror Integration
### Extensions
Все уже установлены в проекте. Список extensions для editor (собираются в `buildEditorExtensions()`):
```typescript
interface EditorExtensionOptions {
readOnly: boolean;
fileName: string;
onContentChanged?: () => void; // debounced dirty flag
onSave?: () => void; // Cmd+S
tabSize?: number; // default 2
lineWrapping?: boolean; // toggle
}
// Compartments для динамических настроек (toggle без пересоздания EditorView)
// Паттерн из CodeMirrorDiffView.tsx (langCompartment, mergeCompartment, portionCompartment)
// ВАЖНО: Compartments хранить в useRef внутри CodeMirrorEditor, НЕ на уровне модуля:
// const readOnlyCompartment = useRef(new Compartment());
// const lineWrappingCompartment = useRef(new Compartment());
// const tabSizeCompartment = useRef(new Compartment());
// Причина: useRef гарантирует изоляцию если компонент монтируется дважды (React Strict Mode).
// Паттерн из CodeMirrorDiffView.tsx:332-336 (langCompartment/mergeCompartment/portionCompartment в useRef).
//
// R2 ПОДТВЕРЖДЕНИЕ: Compartment — opaque identity token, sharing между EditorState безопасен.
// Подтверждено автором CM6 (Marijn Haverbeke): "Compartments can be shared without issue".
// Каждый EditorState хранит свой Map<Compartment, Extension> в config.
// reconfigure() на одном View НЕ влияет на cached states в пуле.
// EDGE CASE: при unmount+remount компонента — cached states ссылаются на старые Compartments.
// Решение: при remount создать новые Compartments, заново создать EditorState для АКТИВНОГО таба.
// Evicted LRU states: теряют undo history (ожидаемо), cursor через EditorSelection.
function buildEditorExtensions(options: EditorExtensionOptions): Extension[] {
return [
// Языковые
getLanguageExtension(options.fileName), // внутри тоже Compartment (из codemirrorLanguages.ts)
syntaxHighlighting(oneDarkHighlightStyle),
// UI
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
bracketMatching(),
closeBrackets(),
// История
history(),
// Поиск (CM6 built-in, @codemirror/search)
search(),
// Настройки через Compartment (переключаются через view.dispatch без потери undo)
// ВАЖНО: readOnly требует ОБА facet для корректного UX (паттерн из CodeMirrorDiffView.tsx:482-483):
// - EditorState.readOnly — блокирует мутации документа
// - EditorView.editable — убирает contenteditable + cursor (без него курсор мигает в read-only)
readOnlyCompartment.current.of(options.readOnly
? [EditorView.editable.of(false), EditorState.readOnly.of(true)]
: []),
lineWrappingCompartment.current.of(options.lineWrapping ? EditorView.lineWrapping : []),
tabSizeCompartment.current.of(indentUnit.of(' '.repeat(options.tabSize ?? 2))),
// Все keymaps ОБЯЗАТЕЛЬНО через keymap.of() — bare KeyBinding[] не является Extension!
// Паттерн из CodeMirrorDiffView.tsx:492 и MembersJsonEditor.tsx:40
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...closeBracketsKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { options.onSave?.(); return true; } },
]),
// onChange (debounced)
EditorView.updateListener.of(update => {
if (update.docChanged) options.onContentChanged?.();
}),
// Тема
baseEditorTheme, // из codemirrorTheme.ts
];
}
// Toggle line wrapping (итерация 5) — без потери undo/scroll:
// view.dispatch({ effects: lineWrappingCompartment.reconfigure(EditorView.lineWrapping) });
// view.dispatch({ effects: lineWrappingCompartment.reconfigure([]) });
// Refs на compartments хранить в useRef компонента CodeMirrorEditor
```
### Определение языка
Функция `getSyncLanguageExtension(fileName)` извлекается из `CodeMirrorDiffView.tsx` в `src/renderer/utils/codemirrorLanguages.ts`. 16+ языков синхронно + `@codemirror/language-data` async fallback для остальных. Используется `Compartment` для ленивой инжекции.
### Тема
Базовая тема извлекается из `diffTheme` (`CodeMirrorDiffView.tsx` строки 158-198) в `src/renderer/utils/codemirrorTheme.ts`:
```typescript
export const baseEditorTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
fontSize: '13px',
},
'.cm-gutters': {
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
},
'.cm-cursor': { borderLeftColor: 'var(--color-text)' },
'.cm-selectionBackground': { backgroundColor: 'rgba(100, 153, 255, 0.2)' },
// ... остальные базовые стили
});
```
Diff-специфичные стили (`.cm-changedLine`, `.cm-deletedChunk`, `.cm-merge-*`, `.cm-collapsedLines`) выносятся в отдельный `const diffSpecificTheme = EditorView.theme({...})` внутри `CodeMirrorDiffView.tsx`. В `buildExtensions()` diff-view использует `[baseEditorTheme, diffSpecificTheme]`, а editor -- только `[baseEditorTheme]`. Light theme работает автоматически через CSS-переменные.
### EditorView lifecycle
Один EditorView, переключение через EditorState pooling. При tab switch ~3-5ms для 100KB файла. Undo history, cursor, selection сохраняются в EditorState.
---
## Keyboard Shortcuts
| Shortcut | Действие | Итерация | Конфликт |
|----------|---------|----------|----------|
| `Cmd+S` | Сохранить активный файл | 2 | — (CM6 keymap) |
| `Cmd+Shift+S` | Сохранить все | 2 | — |
| `Cmd+W` | Закрыть активный tab | 3 | `useKeyboardShortcuts.ts:155` |
| `Cmd+P` | Quick Open (fuzzy search файлов) | 4 | — |
| `Cmd+F` | Поиск в файле (CM6 search) | 2 | `useKeyboardShortcuts.ts:241` |
| `Cmd+Shift+F` | Поиск по содержимому файлов | 4 | — |
| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | 4 | `useKeyboardShortcuts.ts:177` |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | 4 | `useKeyboardShortcuts.ts:81` |
| `Cmd+B` | Toggle file tree sidebar | 4 | `useKeyboardShortcuts.ts:271` |
| `Cmd+G` | Go to line (CM6 gotoLine) | 4 | — |
| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | 2 | — |
| `Escape` | Закрыть overlay (с confirm при unsaved) | 1 | — |
### Scope Isolation (R1 — решение)
6 из 12 шорткатов конфликтуют с глобальными в `useKeyboardShortcuts.ts`. Решение:
**Approach A: Guard в глобальном handler** (надёжность 8/10)
```typescript
// useKeyboardShortcuts.ts — добавить guard
const editorOpen = useStore(s => s.editorProjectPath !== null);
// В handler (bubble phase, window.addEventListener('keydown')):
if (editorOpen) {
// Early return для конфликтующих shortcuts:
// Cmd+W, Cmd+B, Cmd+F, Cmd+Shift+[/], Ctrl+Tab
const isEditorConflict = (e.metaKey && ['w','b','f'].includes(e.key))
|| (e.metaKey && e.shiftKey && ['[',']'].includes(e.key))
|| (e.ctrlKey && e.key === 'Tab');
if (isEditorConflict) return;
}
```
**Safety net: `stopPropagation` в CM6** — все editor keybindings с `stopPropagation: true`:
```typescript
keymap.of([
{ key: 'Mod-f', run: openSearchPanel, stopPropagation: true },
{ key: 'Mod-s', run: () => { onSave?.(); return true; }, stopPropagation: true },
// ...
]);
```
**Паттерн подтверждён**: `ChangeReviewDialog` уже использует capture-phase handler с guard (строки 379-408).
Замечания:
- `Cmd+[` / `Cmd+]` НЕ используются для табов -- это indent/outdent в CM6 и VS Code
- `Cmd+S` перехватывается через CodeMirror keymap (не глобальный listener) -- нет конфликта с другими горячими клавишами
- Sidebar width persist в localStorage
---
## CSS-переменные
### Уже имеющиеся (100% достаточно для MVP)
- Surfaces: `--color-surface`, `--color-surface-raised`, `--color-surface-sidebar`
- Borders: `--color-border`, `--color-border-subtle`, `--color-border-emphasis`
- Text: `--color-text`, `--color-text-secondary`, `--color-text-muted`
- Code: `--code-bg`, `--code-border`, `--code-line-number`, `--code-filename`
- Syntax: `--syntax-string`, `--syntax-comment`, `--syntax-keyword` и все остальные
- Scrollbar: `--scrollbar-thumb`, `--scrollbar-thumb-hover`
- Cards: `--card-bg`, `--card-border`, `--card-header-bg`
### Рекомендуемые дополнения (добавить в `:root` в `index.css`)
```css
--editor-tab-active-bg: var(--color-surface);
--editor-tab-inactive-bg: var(--color-surface-sidebar);
--editor-tab-modified-dot: #f59e0b;
--editor-tab-border: var(--color-border);
--editor-statusbar-bg: var(--color-surface-sidebar);
--editor-statusbar-text: var(--color-text-muted);
--editor-sidebar-resize-handle: rgba(148, 163, 184, 0.15);
--editor-sidebar-resize-handle-hover: rgba(148, 163, 184, 0.3);
```

View file

@ -0,0 +1,176 @@
# Риски, бенчмарки, полный список файлов
## Риски
| # | Риск | Вероятность | Импакт | Итерация | Митигация |
|---|------|------------|--------|----------|-----------|
| 1 | Path traversal через IPC | Средняя | Критический | 1+ | `validateFilePath()` на КАЖДОМ handler + module-level projectRoot |
| 2 | Symlink escape из projectRoot | Высокая | Критический | 1 | `fs.realpath()` + re-check на каждом entry в readDir |
| 3 | node_modules/огромные директории -- OOM | Высокая | Высокий | 1 | IGNORED_DIRS фильтр + MAX_DIR_ENTRIES=500 + виртуализация (итерация 4) |
| 4 | CM6 тормозит на файлах >2MB | Низкая | Средний | 1 | Hard limit 2MB + тиерная стратегия + external editor fallback |
| 5 | TOCTOU race condition при save | Высокая | Высокий | 2 | Atomic write (tmp + rename) + post-read verify |
| 6 | Race condition: агент и пользователь редактируют один файл | Высокая | Высокий | 5 | mtime check + conflict dialog (overwrite / cancel / diff) |
| 7 | Unsaved data loss при crash | Средняя | Средний | 2 | Draft autosave в localStorage (30 сек debounce, max 10 drafts x 500KB). Recovery banner при reopen |
| 8 | Device file DoS (/dev/zero) | Средняя | Высокий | 1 | `fs.lstat()` + `isFile()` + block /dev/ /proc/ /sys/ |
| 9 | Credential leakage (.env, .key) | Высокая | Высокий | 1 | `validateFilePath()` + визуальная пометка + блокировка чтения |
| 10 | ReDoS в searchInFiles | Средняя | Средний | 4 | Только literal search + timeout через AbortController |
| 11 | Memory leak: 20+ EditorView | Высокая | Критический | 2 | EditorState pooling + LRU eviction |
| 12 | Zustand keystroke storm | Высокая | Высокий | 2 | Content вне store + debounced dirty flag |
| 13 | XSS через имена файлов | Низкая | Средний | 1 | React JSX + validateFileName() при создании |
| 14 | Запись в .git/ | Средняя | Высокий | 2 | `isGitInternalPath()` блокирует write |
| 15 | ~~review.ts без валидации пути~~ | ~~Существует~~ | ~~Критический~~ | **ИСПРАВЛЕНО** | `validateFilePath()` добавлен в handleSaveEditedFile (hotfix применён) |
---
## Тест-стратегия
### Unit-тесты (Vitest)
~15 файлов, покрывают: сервисы (ProjectFileService, FileSearchService, GitStatusService), store slices, утилиты (fileTreeBuilder, tabLabelDisambiguation, codemirrorLanguages, atomicWrite), IPC wrapper. Запуск: `pnpm test`.
### Integration-тесты (Vitest + happy-dom)
Для компонентов использующих CM6 — happy-dom НЕ поддерживает `contenteditable` полностью. Стратегия:
- **CodeMirrorEditor**: тестировать через mock EditorView. Проверять lifecycle (mount → register bridge, unmount → unregister), tab switch (stateCache save/restore), dirty flag propagation
- **editorSlice + editorBridge**: интеграционный тест — store action вызывает bridge, bridge возвращает mock content
- **IPC handlers (editor.ts)**: тестировать с mock fs + mock ProjectFileService. Проверять security guards (path traversal, .git/ write block, device paths)
### Manual smoke-тесты (каждая итерация)
Обязательный чеклист перед мёрджем каждого PR:
- [ ] Открыть editor, навигировать по дереву, открыть файл — подсветка работает
- [ ] Редактировать файл, Cmd+S — сохранение без ошибок
- [ ] Unsaved changes при закрытии — confirmation dialog
- [ ] ChangeReviewDialog по-прежнему работает корректно (regression)
- [ ] Горячие клавиши НЕ конфликтуют с глобальными при закрытом editor
### Benchmarks (manual, один раз после iter-4)
Запускать вручную через DevTools Performance tab + React DevTools Profiler. Результаты фиксировать в PR description.
```
Benchmark 1: EditorView memory
Открыть 25 файлов x 200KB
Измерить: performance.memory.usedJSHeapSize
Порог: < 150MB
Benchmark 2: Tab switch latency
Переключить таб (500KB файл с syntax highlighting)
Измерить: time from click to contentful paint
Порог: < 50ms
Benchmark 3: File tree render
5000+ файлов, все папки раскрыты (с виртуализацией)
Измерить: FPS при скролле
Порог: >= 55fps
Benchmark 4: readDir latency
Директория с 5000 файлами
Измерить: time from click to tree displayed
Порог: < 200ms
Benchmark 5: Keystroke re-renders
React DevTools Profiler при наборе текста
Порог: FileTreePanel и TabBar рендерятся 0 раз при наборе
```
---
## Стратегия отката
Каждая итерация — отдельный PR. При проблемах — revert PR целиком.
| Итерация | Fallback при провале | Минимально жизнеспособный результат |
|----------|---------------------|-------------------------------------|
| PR 0 | Revert PR. Рефакторинги механические (извлечение функций), не трогают review logic. При проблеме — revert + дублировать код в editor | — |
| Iter 1 | Read-only browser без CM6 — просто дерево + `<pre>` с raw text | Кнопка "Open in Editor" → файловый браузер |
| Iter 2 | Оставить read-only из iter-1, открывать файлы в external editor (`shell:openPath`) | Read-only + external editor fallback |
| Iter 3 | Один таб (последний открытый файл). CRUD через terminal/external | Single-tab editor |
| Iter 4 | Без Quick Open, без search — ручная навигация по дереву. Без виртуализации (работает до ~2000 файлов) | Editor без search/shortcuts |
| Iter 5 | Без git badges, без file watcher — ручной F5 refresh. Без conflict detection — last-write-wins | Полный editor без live features |
**Критическая точка невозврата**: нет. Каждый PR изолирован. Даже если iter-5 провалится, iter-1-4 дают полноценный editor без git/watcher.
---
## Полный список файлов
### Новые файлы (~36)
| # | Файл | Итерация | Описание |
|---|------|----------|----------|
| 1 | `src/shared/types/editor.ts` | 1 | Все типы editor |
| 2 | `src/main/services/editor/ProjectFileService.ts` | 1 | Stateless файловый сервис |
| 3 | `src/main/services/editor/index.ts` | 1 | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
| 4 | `src/main/services/editor/FileSearchService.ts` | 4 | Search in files |
| 5 | `src/main/services/editor/GitStatusService.ts` | 5 | git status через simple-git (~80-100 LOC) |
| 6 | `src/main/services/editor/EditorFileWatcher.ts` | 5 | FileWatcher через chokidar v4 (~50-70 LOC) |
| 7 | `src/main/services/editor/conflictDetection.ts` | 5 | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
| 8 | `src/main/ipc/editor.ts` | 1 | IPC handlers |
| 9 | `src/main/ipc/ipcWrapper.ts` | 1 | Общий `createIpcWrapper()` |
| 10 | `src/main/utils/atomicWrite.ts` | 2 | Перемещение `atomicWriteAsync()` из `team/atomicWrite.ts` (randomUUID, fsync, EXDEV fallback) |
| 11 | `src/renderer/utils/fileTreeBuilder.ts` | 1 | buildTree (рефакторинг) |
| 12 | `src/renderer/utils/codemirrorLanguages.ts` | 1 | Языковой маппинг (рефакторинг) |
| 13 | `src/renderer/utils/codemirrorTheme.ts` | 1 | Базовая тема CM (рефакторинг) |
| 14 | `src/renderer/utils/tabLabelDisambiguation.ts` | 3 | Disambiguation дублей |
| 15 | `src/renderer/store/slices/editorSlice.ts` | 1 | Zustand slice (Группа 1: tree), расширяется в итерации 2-3 |
| 16 | `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | 4 | Горячие клавиши |
| 17 | `src/renderer/components/common/FileTree.tsx` | 1 | Generic FileTree с render-props |
| 18 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | 1 | Full-screen overlay |
| 19 | `src/renderer/components/team/editor/EditorFileTree.tsx` | 1 | Обёртка над FileTree |
| 20 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | 1 | CM6 wrapper (~250-350 LOC: pooling + LRU + bridge + dirty + autosave) |
| 21 | `src/renderer/components/team/editor/EditorTabBar.tsx` | 2 | Панель вкладок |
| 22 | `src/renderer/components/team/editor/EditorToolbar.tsx` | 2 | Toolbar |
| 23 | `src/renderer/components/team/editor/EditorStatusBar.tsx` | 2 | Status bar |
| 24 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | 1 | Empty state |
| 25 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | 1 | Binary файлы |
| 26 | `src/renderer/components/team/editor/EditorErrorState.tsx` | 1 | Ошибки чтения |
| 27 | `src/renderer/components/team/editor/EditorErrorBoundary.tsx` | 1 | React ErrorBoundary для CM6 (аналог DiffErrorBoundary) |
| 29 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | 3 | Context menu |
| 30 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input |
| 31 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog |
| 32 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F |
| 33 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb |
| 34 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal |
| 35 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов |
| 36 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи |
| 37 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) |
### Модификации существующих файлов (~18)
| # | Файл | Итерация | Изменение |
|---|------|----------|-----------|
| 1 | `src/preload/constants/ipcChannels.ts` | 1-5 | +12 констант EDITOR_* (включая EDITOR_CLOSE) |
| 2 | `src/preload/index.ts` | 1-5 | Секция `editor: { ... }` |
| 3 | `src/shared/types/api.ts` | 1-5 | `EditorAPI` interface |
| 4 | `src/main/ipc/review.ts` | 1 | Замена wrapReviewHandler на import из ipcWrapper |
| 5 | `src/main/utils/pathValidation.ts` | 1 | +validateFileName, +isDevicePath, +isGitInternalPath |
| 6 | `src/renderer/store/types.ts` | 1 | +EditorSlice в AppState |
| 7 | `src/renderer/store/index.ts` | 1 | +createEditorSlice |
| 8 | `src/renderer/components/team/TeamDetailView.tsx` | 1 | Кнопка "Open in Editor" + overlay state |
| 9 | `src/renderer/components/team/review/ReviewFileTree.tsx` | 1 | Рефакторинг: generic FileTree + fileTreeBuilder |
| 10 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | 1 | Рефакторинг: импорт из codemirrorLanguages/Theme |
| 11 | `src/main/ipc/handlers.ts` | 1 | +initializeEditorHandlers() + registerEditorHandlers(ipcMain) + removeEditorHandlers(ipcMain) |
| 12 | `src/renderer/api/httpClient.ts` | 1 | Stub для editor: EditorAPI (throw "not available in browser mode") |
| 13 | `src/main/ipc/teams.ts` | follow-up | Миграция wrapTeamHandler → createIpcWrapper (40+ замен, отдельный PR) |
| 14 | `src/shared/types/index.ts` | 1 | +`export type * from './editor'` (barrel re-export, паттерн как team/review/terminal) |
| 15 | `src/main/index.ts` | 1 (расш. 5) | `mainWindow.on('closed')``cleanupEditorState()` (базовый reset в iter-1, watcher cleanup в iter-5) |
| 16 | `src/renderer/index.css` | 2 | +editor CSS-переменные |
| 17 | `src/renderer/hooks/useKeyboardShortcuts.ts` | 4 | Guard `editorOpen` для 6 конфликтующих shortcuts (R1) |
### Тесты (новые, ~15)
| # | Файл | Итерация |
|---|------|----------|
| 1 | `test/main/services/editor/ProjectFileService.test.ts` | 1 |
| 2 | `test/main/ipc/editor.test.ts` | 1 |
| 3 | `test/main/ipc/ipcWrapper.test.ts` | 1 |
| 4 | `test/main/utils/atomicWrite.test.ts` | 2 |
| 5 | `test/renderer/utils/fileTreeBuilder.test.ts` | 1 |
| 6 | `test/renderer/utils/codemirrorLanguages.test.ts` | 1 |
| 7 | `test/renderer/store/editorSlice.test.ts` | 1 (расширяется в 2-3) |
| 8 | `test/renderer/utils/tabLabelDisambiguation.test.ts` | 3 |
| 9 | `test/renderer/components/team/editor/EditorContextMenu.test.ts` | 3 |
| 10 | `test/main/services/editor/FileSearchService.test.ts` | 4 |
| 11 | `test/renderer/hooks/useEditorKeyboardShortcuts.test.ts` | 4 |
| 12 | `test/renderer/components/team/editor/fileIcons.test.ts` | 4 |
| 13 | `test/main/services/editor/GitStatusService.test.ts` | 5 |
| 14 | `test/main/services/editor/EditorFileWatcher.test.ts` | 5 |
| 15 | `test/main/services/editor/conflictDetection.test.ts` | 5 |

View file

@ -0,0 +1,104 @@
# PR 0: Обязательные рефакторинги (R1-R4)
> Перед итерацией 1. Отдельный PR.
## Цель
Обязательные рефакторинги -- без них будет дублирование кода. Выполняются ДО написания нового кода. Тесты `ReviewFileTree` и `CodeMirrorDiffView` должны проходить после рефакторинга (zero behavior change).
## Почему отдельный PR
R1 затрагивает production `ReviewFileTree` (используется в `ChangeReviewDialog`), R3 затрагивает production `CodeMirrorDiffView`. Объединение рефакторинга production-кода + 15 новых файлов в одну итерацию — чрезмерный blast radius (28 файлов). Разделение:
- **PR 0 ("Refactoring")**: R1-R4 + тесты. Мёрдж только после проверки что ChangeReviewDialog работает корректно.
- **PR 1 ("Walking Skeleton")**: Новые editor-файлы. Зависит от PR 0.
## Рефакторинги
| # | Что извлечь | Откуда | Куда | LOC |
|---|-------------|--------|------|-----|
| R1 | `buildTree()` + `collapse()` + сортировка | `ReviewFileTree.tsx:42-83` | `src/renderer/utils/fileTreeBuilder.ts` | ~50 |
| R2 | `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` | `CodeMirrorDiffView.tsx:64-128` | `src/renderer/utils/codemirrorLanguages.ts` | ~70 |
| R3 | Базовая тема CM (без diff-стилей) | `CodeMirrorDiffView.tsx:158-198` | `src/renderer/utils/codemirrorTheme.ts` | ~40 |
| R4 | `wrapReviewHandler<T>()` | `review.ts:133-145` | `src/main/ipc/ipcWrapper.ts` | ~15 |
## Детали каждого рефакторинга
### R1: `buildTree<T>()` — Generic tree builder
**NB**: `ReviewFileTree` работает с `FileChangeSummary` (имеет `status`, `additions`, `deletions`), а editor использует `FileTreeEntry` (имеет `size`, `children`). `buildTree<T>()` должен быть generic по типу node, принимая `getPath: (item: T) => string` и `isDirectory: (item: T) => boolean` как параметры.
```typescript
// src/renderer/utils/fileTreeBuilder.ts
function buildTree<T>(
items: T[],
getPath: (item: T) => string,
isDirectory: (item: T) => boolean
): TreeNode<T>[]
```
### R2: `getSyncLanguageExtension()` — Языковой маппинг
Извлечь из `CodeMirrorDiffView.tsx:64-128`. 16+ языков синхронно + `@codemirror/language-data` async fallback.
### R3: `baseEditorTheme` — Базовая тема
**NB**: `diffTheme` — один `EditorView.theme({...})` на 125 строк. Рефакторинг:
1. Извлечь строки 158-198 в `baseEditorTheme = EditorView.theme({...})` в `codemirrorTheme.ts`
2. В `CodeMirrorDiffView.tsx` создать `const diffSpecificTheme = EditorView.theme({...})` со строками 199-283
3. В `buildExtensions()` заменить `diffTheme` на `[baseEditorTheme, diffSpecificTheme]`
### R4: `createIpcWrapper()` — Общий IPC wrapper
**NB**: `teams.ts` имеет аналогичный `wrapTeamHandler` (40+ вызовов), но его миграция — отдельный follow-up PR после итерации 1. Blast radius слишком высокий (1755 строк) для совмещения с основной фичей. В итерации 1 R4 применяется ТОЛЬКО к `review.ts` + новому `editor.ts`.
```typescript
// src/main/ipc/ipcWrapper.ts
export function createIpcWrapper(logPrefix: string) {
const log = createLogger(logPrefix);
return async function wrap<T>(op: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
try { return { success: true, data: await fn() }; }
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
log.error(`handler error [${op}]:`, msg);
return { success: false, error: msg };
}
};
}
// review.ts:
const wrapHandler = createIpcWrapper('IPC:review');
// editor.ts:
const wrapHandler = createIpcWrapper('IPC:editor');
```
## После рефакторинга
- `ReviewFileTree.tsx` импортирует `buildTree`, `TreeNode` из `fileTreeBuilder.ts`
- `CodeMirrorDiffView.tsx` импортирует из `codemirrorLanguages.ts` и `codemirrorTheme.ts`
- `review.ts` импортирует `createIpcWrapper` из `ipcWrapper.ts`
- `teams.ts` — миграция `wrapTeamHandler``createIpcWrapper` в отдельном follow-up PR (40+ замен, высокий blast radius)
## Уровень риска по рефакторингам
| R# | Что трогает | Уровень риска | Почему |
|----|-------------|--------------|--------|
| R1 | ReviewFileTree (дерево файлов) | Низкий | Извлечение чистой функции buildTree(). Diff-viewer, hunks, approve/reject — не затрагиваются |
| R2 | CodeMirrorDiffView (языки) | Около нуля | Чистые stateless функции, просто выносим в отдельный файл |
| R3 | CodeMirrorDiffView (тема) | Низкий | Разрезка CSS-объекта на base + diff-specific. Визуально проверить |
| R4 | review.ts (IPC wrapper) | Около нуля | 15 LOC try-catch factory, тривиальная замена |
**Что НЕ затрагивается**: ChangeReviewDialog logic, diff rendering, hunks, approve/reject, комментарии, changeReviewSlice state.
## Критерии готовности
- [ ] `pnpm typecheck` проходит
- [ ] `pnpm test` проходит (zero behavior change)
- [ ] Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
- [ ] Новые unit-тесты для `codemirrorLanguages.ts` — проверка маппинга расширений на языки
- [ ] Manual smoke-тест: открыть ChangeReviewDialog → файловое дерево рендерится корректно, diff подсвечивается
## Оценка
- **Надёжность решения: 9/10** — все 4 рефакторинга механические (извлечение функций без изменения поведения). R2/R4 — около нуля риска, R1/R3 — низкий.
- **Уверенность: 9/10** — код review workflow не затрагивается, трогается только утилитарная часть (дерево файлов, языки, тема, wrapper).

View file

@ -0,0 +1,116 @@
# Итерация 1: Walking Skeleton (read-only файловый браузер)
> Зависит от: [PR 0 (Рефакторинги)](iter-0-refactoring.md)
## Цель
Минимальный end-to-end вертикальный срез -- кнопка "Open in Editor" на TeamDetailView открывает полноэкранный overlay с деревом файлов слева и содержимым файла с подсветкой синтаксиса (read-only) справа.
## Новые npm-зависимости
- `@codemirror/search` (`pnpm add @codemirror/search`) — встроенный Cmd+F поиск в файле
- `isbinaryfile` v5.0.7 (`pnpm add isbinaryfile`) — binary detection (33M downloads/нед, zero deps, умнее null-byte scan: UTF-16, BOM, encoding hints)
## IPC каналы
| Канал | Описание |
|-------|----------|
| `editor:open` | Инициализировать editor, установить activeProjectRoot в module-level state |
| `editor:close` | Cleanup: сброс activeProjectRoot, остановка watcher |
| `editor:readDir` | Рекурсивное чтение директории (depth=1, lazy) |
| `editor:readFile` | Чтение содержимого файла с binary detection |
## Новые файлы
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/shared/types/editor.ts` | `FileTreeEntry`, `ReadDirResult`, `ReadFileResult` |
| 2 | `src/main/services/editor/ProjectFileService.ts` | Stateless сервис: `readDir`, `readFile` с полной валидацией |
| 3 | `src/main/services/editor/index.ts` | Barrel export: `{ ProjectFileService }` (расширяется в итерациях 4-5) |
| 4 | `src/main/ipc/editor.ts` | IPC handlers с module-level `activeProjectRoot` |
| 5 | `src/main/ipc/ipcWrapper.ts` | Общий `createIpcWrapper()` (рефакторинг из review.ts) |
| 6 | `src/renderer/store/slices/editorSlice.ts` | Минимальный slice: Группа 1 (tree state + actions) |
| 7 | `src/renderer/utils/fileTreeBuilder.ts` | Generic `buildTree<T>()` (рефакторинг из ReviewFileTree) |
| 8 | `src/renderer/utils/codemirrorLanguages.ts` | `getSyncLanguageExtension()` (рефакторинг) |
| 9 | `src/renderer/utils/codemirrorTheme.ts` | `baseEditorTheme` (рефакторинг) |
| 10 | `src/renderer/components/common/FileTree.tsx` | Generic FileTree<T> с render-props |
| 11 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Full-screen overlay |
| 12 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Обёртка над generic FileTree |
| 13 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Read-only CM6 view (один EditorView, без pooling пока) |
| 14 | `src/renderer/components/team/editor/EditorEmptyState.tsx` | Нет открытых файлов |
| 15 | `src/renderer/components/team/editor/EditorBinaryState.tsx` | Заглушка для бинарных файлов |
| 16 | `src/renderer/components/team/editor/EditorErrorState.tsx` | Заглушка для ошибок чтения |
| 17 | `src/renderer/components/team/editor/EditorErrorBoundary.tsx` | React ErrorBoundary для CM6 (аналог DiffErrorBoundary) |
## Изменения в существующих файлах
| # | Файл | Изменение |
|---|------|-----------|
| 1 | `src/shared/types/api.ts` | `EditorAPI` interface + `editor: EditorAPI` в `ElectronAPI` |
| 2 | `src/shared/types/index.ts` | +`export type * from './editor'` (barrel re-export, паттерн как team/review/terminal) |
| 3 | `src/preload/constants/ipcChannels.ts` | `EDITOR_OPEN`, `EDITOR_CLOSE`, `EDITOR_READ_DIR`, `EDITOR_READ_FILE` |
| 4 | `src/preload/index.ts` | Секция `editor: { ... }` в `electronAPI` |
| 5 | `src/main/ipc/handlers.ts` | `initializeEditorHandlers` + `registerEditorHandlers` |
| 6 | `src/main/ipc/review.ts` | Заменить `wrapReviewHandler` на import из `ipcWrapper.ts` |
| 7 | `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" + state для overlay |
| 8 | `src/renderer/components/team/review/ReviewFileTree.tsx` | Рефакторинг: использовать generic FileTree + fileTreeBuilder |
| 9 | `src/renderer/components/team/review/CodeMirrorDiffView.tsx` | Рефакторинг: импорт из codemirrorLanguages/Theme |
| 10 | `src/main/utils/pathValidation.ts` | Добавить `validateFileName()`, `isDevicePath()`, `isGitInternalPath()`. Экспортировать `matchesSensitivePattern()` (приватная) для `isSensitive` в readDir. Экспортировать `isPathWithinRoot()` (приватная, строка ~30) — нужна для SEC-15 в `editor:open` handler уже в iter-1, а также для SEC-14 write-handler guard в iter-2 |
| 11 | `src/main/index.ts` | Добавить базовый cleanup в `mainWindow.on('closed')`: вызвать `cleanupEditorState()` (экспорт из editor.ts, сбрасывает `activeProjectRoot = null`). Без этого при Cmd+Q на macOS state "утечёт" и `editor:open` откажет при следующем открытии окна. Полный watcher cleanup — итерация 5, но базовый reset нужен с итерации 1 |
| 12 | `src/renderer/api/httpClient.ts` | Stub для `editor: EditorAPI` — throw "Editor is not available in browser mode" (паттерн как `review`, `terminal`, `teams`) |
| 13 | `src/renderer/store/types.ts` | `EditorSlice` в AppState |
| 14 | `src/renderer/store/index.ts` | `createEditorSlice` |
## Security-требования
1. **SEC-15**: `editor:open` handler валидирует `projectPath` ДО установки `activeProjectRoot`: `path.isAbsolute()`, `fs.stat().isDirectory()`, `!== '/'`/`'C:\\'`, `!isPathWithinRoot(path, claudeDir)`. Без этого злонамеренный renderer может передать `"/"`, делая ВСЕ пути валидными
2. `ProjectFileService.readDir()`: для каждого entry проверять containment через `isPathWithinAllowedDirectories()` (экспортирована из pathValidation.ts). Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать entries за пределами projectRoot (SEC-2). **НЕ вызывать `validateFilePath()` целиком** — она блокирует sensitive файлы, а readDir должен их ПОКАЗЫВАТЬ с пометкой `isSensitive: true`. Для пометки использовать новую экспортируемую функцию `matchesSensitivePattern()` из pathValidation.ts (сейчас приватная — нужно экспортировать) (SEC-6)
3. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
4. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
5. Sensitive файлы: показывать с замком в дереве, "Sensitive file, cannot open" при клике (SEC-6)
## Performance-требования
- MAX_ENTRIES_PER_DIR = 500; при превышении -- "N more files..."
- readFile тиерная стратегия: <256KB мгновенно, 256KB-2MB progress, 2MB-5MB preview, >5MB external
- Binary detection: `isbinaryfile` (v5.0.7) — `isBinaryFile(filePath)` вместо ручного null-byte scan
- Дедупликация IPC: `Map<string, Promise<ReadFileResult>>` для readFile
## UX-требования
- Focus management: при открытии -- фокус на первый файл. При закрытии -- вернуть фокус на кнопку. `inert` на фон
- ARIA: file tree сразу с `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`
- Пустой проект: "No files found" + кнопка Create (неактивна до итерации 3)
- Binary файлы: `EditorBinaryState.tsx` с кнопкой "Open in System Viewer"
- Max indent 12 уровней, tooltip на глубоких узлах
## State management
Создать минимальный `editorSlice` уже на итерации 1 с полями `editorProjectPath`, `editorFileTree`, `editorFileTreeLoading`, `editorFileTreeError`, `openEditor()`, `closeEditor()`, `loadFileTree()`, `expandDirectory()`. Это избавит от болезненной миграции useState → Zustand на итерации 2. Табы и dirty-состояние добавляются в slice на итерации 2.
## Тестирование
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `ProjectFileService` -- чтение директории с mock fs, проверка security (reject paths outside projectRoot), исключение node_modules, symlink escape | `test/main/services/editor/ProjectFileService.test.ts` |
| 2 | `editorSlice` -- open/close editor, loadFileTree, expandDirectory | `test/renderer/store/editorSlice.test.ts` |
| 3 | `EditorFileTree` -- snapshot тесты рендеринга | — |
| 4 | `fileTreeBuilder.ts` -- unit тесты `buildTree()` (с generic типами для FileChangeSummary и FileTreeEntry) | `test/renderer/utils/fileTreeBuilder.test.ts` |
| 5 | `ipcWrapper.ts` -- unit тесты createIpcWrapper | `test/main/ipc/ipcWrapper.test.ts` |
| 6 | Manual: открыть TeamDetailView -> "Open in Editor" -> дерево загружается -> клик по файлу -> подсветка синтаксиса | — |
## Критерии готовности
- [ ] Кнопка видна на TeamDetailView рядом с путём проекта
- [ ] Overlay открывается по клику, закрывается по Escape или X
- [ ] Дерево файлов загружается для projectPath команды
- [ ] Клик по файлу показывает содержимое с синтаксической подсветкой
- [ ] Binary файлы показывают заглушку
- [ ] Попытка прочитать файл за пределами проекта -- отказ
- [ ] `pnpm typecheck` проходит
- [ ] Рефакторинги R1-R4 выполнены, тесты ReviewFileTree и CodeMirrorDiffView проходят
## Оценка
- **Надёжность решения: 8/10** -- CodeMirror 6 проверен в продакшене, все зависимости в проекте, паттерны повторяют ChangeReviewDialog.
- **Уверенность: 9/10** -- самый понятный этап, минимум неизвестных.

View file

@ -0,0 +1,107 @@
# Итерация 2: Editable CodeMirror + сохранение файлов
> Зависит от: [Итерация 1](iter-1-walking-skeleton.md)
## Цель
Переключить CodeMirror из read-only в редактируемый режим. Cmd+S для сохранения. Индикатор unsaved changes. Status bar.
## IPC каналы
| Канал | Описание |
|-------|----------|
| `editor:writeFile` | Запись файла (atomic write через tmp + rename) |
## Новые файлы
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/main/utils/atomicWrite.ts` | Перемещение существующего `atomicWriteAsync()` из `src/main/services/team/atomicWrite.ts` (shared utility). **H2**: Blast radius — ~10 source файлов + ~4 тестовых файла (TeamTaskWriter, TeamDataService, TeamKanbanManager, TeamAgentToolsInstaller, и их тесты). Обновить все импорты |
| 2 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Панель вкладок (один файл пока, подготовка к multi-tab) |
| 3 | `src/renderer/components/team/editor/EditorStatusBar.tsx` | Ln:Col, язык, отступы |
| 4 | `src/renderer/components/team/editor/EditorToolbar.tsx` | Save, Undo, Redo |
| 5 | `src/renderer/utils/editorBridge.ts` | Module-level singleton: Store ↔ CM6 refs bridge (R3). Компонент вызывает `register()` при mount, store actions используют `getContent()`/`destroy()` |
## Изменения в существующих файлах
| # | Файл | Изменение |
|---|------|-----------|
| 1 | `src/shared/types/editor.ts` | Типы для write request/response |
| 2 | `src/shared/types/api.ts` | `writeFile` в `EditorAPI` |
| 3 | `src/main/services/editor/ProjectFileService.ts` | Метод `writeFile()` с atomic write |
| 4 | `src/main/ipc/editor.ts` | Handler `editor:writeFile` |
| 5 | `src/preload/index.ts` | `editor.writeFile` |
| 6 | `src/preload/constants/ipcChannels.ts` | `EDITOR_WRITE_FILE` |
| 7 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция TabBar, StatusBar |
| 8 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Убрать readOnly, EditorState pooling (Map<tabId, EditorState>), Cmd+S keymap |
| 9 | `src/renderer/store/slices/editorSlice.ts` | Расширить: +Группа 2 (tabs) + Группа 3 (dirty/save) |
| 10 | `src/renderer/index.css` | +8 editor CSS-переменных (--editor-tab-active-bg, --editor-tab-modified-dot и др.) |
## Security-требования
1. `writeFile`: `validateFilePath()` ДО записи. **+ SEC-14**: `isPathWithinRoot(normalizedPath, activeProjectRoot)` для блокировки `~/.claude` writes. `Buffer.byteLength(content, 'utf8') <= 2MB`. Atomic write. Запрет записи в `.git/`. `activeProjectRoot` из module-level state (SEC-9, SEC-12)
2. Файл удалён извне при save: ENOENT -> inline-ошибка "File was deleted. Create new? / Close tab" (не падать)
## Performance-требования
- НЕ хранить modified content в Zustand. Контент только в EditorState CM. В store: `editorModifiedFiles: Record<string, boolean>` (dirty flags — Record вместо Set, т.к. Zustand не отслеживает мутации Set)
- Dirty flag через debounced `EditorView.updateListener` (300ms)
- Гранулярные Zustand-селекторы: FileTreePanel не подписывается на tabs/content
- EditorState pooling: один EditorView, Map<tabId, EditorState> в useRef
- LRU eviction при > 30 states. При eviction: удалить dirty flag из `editorModifiedFiles` (предотвращает stale dirty indicator), очистить draft из localStorage
## Autosave (draft recovery)
Минимальный autosave для защиты от потери данных при crash/kill:
```typescript
// В CodeMirrorEditor.tsx — debounced 30 секунд после последнего изменения
const AUTOSAVE_DELAY = 30_000;
// Сохранять draft в localStorage:
// key: `editor-draft:${filePath}`
// value: JSON.stringify({ content: doc.toString(), timestamp: Date.now() })
// Очищать при успешном saveFile() или при closeTab()
// При openFile() — проверить наличие draft:
// if (draft exists && draft.timestamp > file.mtimeMs) → banner "Recovered unsaved changes. [Apply] [Discard]"
// Edge case: если draft.timestamp < file.mtimeMs файл был изменён извне ПОСЛЕ draft draft устарел, удалить молча
// Edge case: если file.mtimeMs === 0 или undefined (новый файл) — применять draft безусловно
```
Лимиты: max 10 drafts, max 500KB per draft. При превышении — вытеснять oldest. Не сохранять draft для read-only/preview файлов.
## UX-требования
- Status bar: `[Ln 42, Col 15] | [TypeScript] | [UTF-8] | [Spaces: 2]`
- Unsaved changes при закрытии overlay: три кнопки ("Save All & Close" / "Discard & Close" / "Cancel")
- Dirty indicator (точка) на вкладке ПЕРЕД текстом
- `hasUnsavedChanges()` в slice
- `closeTab()` с dirty state: показать confirm dialog ("Save / Discard / Cancel") перед закрытием. При "Save" — сохранить через IPC, при "Discard" — закрыть + удалить draft, при "Cancel" — отмена. Закрытие overlay с dirty tabs — аналогично через "Save All & Close" / "Discard & Close" / "Cancel"
- Draft recovery banner при обнаружении несохранённого draft
## Тестирование
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `ProjectFileService.writeFile` -- запись с mock fs, reject для файлов вне проекта, atomic write | `test/main/services/editor/ProjectFileService.test.ts` (расширение) |
| 2 | `editorSlice` -- open/close файлы, dirty state, save | `test/renderer/store/editorSlice.test.ts` (расширение) |
| 3 | `atomicWrite` -- unit тесты | `test/main/utils/atomicWrite.test.ts` |
| 4 | EditorState pooling -- save/restore state при switch tab | — |
| 5 | Draft autosave — сохранение в localStorage, recovery при reopen, cleanup при save/close | — (manual + unit для localStorage logic) |
| 6 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
## Критерии готовности
- [ ] Файл редактируется в CodeMirror (не read-only)
- [ ] Cmd+S сохраняет файл через atomic write
- [ ] Dirty indicator на вкладке
- [ ] Status bar показывает позицию курсора и язык
- [ ] При закрытии overlay с unsaved changes -- confirmation dialog
- [ ] Draft autosave: после 30 сек без сохранения — draft в localStorage, recovery при reopen
- [ ] Benchmark: 0 re-render FileTreePanel/TabBar при наборе текста
## Оценка
- **Надёжность решения: 7/10** -- atomic write и EditorState pooling добавляют сложность.
- **Уверенность: 8/10** -- паттерны известны, но dirty tracking через CM6 updateListener требует тестирования.

View file

@ -0,0 +1,83 @@
# Итерация 3: Multi-tab + создание/удаление файлов
> Зависит от: [Итерация 2](iter-2-editable-save.md)
## Цель
Поддержка нескольких открытых файлов во вкладках. Контекстное меню: создать файл/папку, удалить. Tab management.
## Новые npm-зависимости
`@radix-ui/react-context-menu` (`pnpm add @radix-ui/react-context-menu`) — для нативного контекстного меню. Проверить текущие `@radix-ui/*` версии в package.json и использовать совместимую.
## IPC каналы
| Канал | Описание |
|-------|----------|
| `editor:createFile` | Создать файл (validateFileName + валидация parentDir) |
| `editor:createDir` | Создать директорию |
| `editor:deleteFile` | Удалить файл через `shell.trashItem()` (безопасно) |
## Новые файлы
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/renderer/components/team/editor/EditorContextMenu.tsx` | Context menu (New File, New Folder, Delete, Reveal in Finder) |
| 2 | `src/renderer/components/team/editor/NewFileDialog.tsx` | Inline-input для имени файла/папки |
| 3 | `src/renderer/utils/tabLabelDisambiguation.ts` | `getDisambiguatedTabLabel()` для дублей "index.ts" |
## Изменения в существующих файлах
| # | Файл | Изменение |
|---|------|-----------|
| 1 | `src/shared/types/editor.ts` | Типы для create/delete |
| 2 | `src/shared/types/api.ts` | `createFile`, `createDir`, `deleteFile` в EditorAPI |
| 3 | `src/main/services/editor/ProjectFileService.ts` | `createFile()`, `createDir()`, `deleteFile()` |
| 4 | `src/main/ipc/editor.ts` | 3 новых handler |
| 5 | `src/preload/index.ts` | 3 новых метода |
| 6 | `src/preload/constants/ipcChannels.ts` | `EDITOR_CREATE_FILE`, `EDITOR_CREATE_DIR`, `EDITOR_DELETE_FILE` |
| 7 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Multi-tab: массив, переключение, close, middle-click close |
| 8 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Right-click context menu, refresh после create/delete |
| 9 | `src/renderer/store/slices/editorSlice.ts` | Tab management actions, file operations |
## Security-требования
1. `createFile`: `validateFileName()` -- запрет `.`, `..`, control chars, path separators, NUL, length > 255. Валидировать и `parentDir`, и `path.join(parentDir, name)` (SEC-7)
2. `deleteFile`: `shell.trashItem()`, НЕ `fs.unlink()`. `validateFilePath()` обязательна
3. Confirmation dialog перед удалением
4. `createFile`, `createDir`, `deleteFile`: `isGitInternalPath()` блокирует операции внутри `.git/` (SEC-12, аналог writeFile из iter-2)
## Performance-требования
- Tab closing: `stateCache.delete(tabId)` (явная очистка памяти). closeAllTabs: `stateCache.clear()`
- Debounce обновления дерева после create/delete (500ms), не перечитывать после каждой операции
## UX-требования
- Disambiguation tab labels: два "index.ts" -> "(main/utils)" и "(renderer/utils)"
- Длинные имена: max-width ~160px, `truncate`, tooltip. Modified dot ПЕРЕД текстом
- ARIA для tab bar: `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"`
## Тестирование
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `ProjectFileService.createFile/deleteFile` с mock fs | `test/main/services/editor/ProjectFileService.test.ts` (расширение) |
| 2 | `editorSlice` -- multi-tab actions (open, close, reorder) | `test/renderer/store/editorSlice.test.ts` (расширение) |
| 3 | `tabLabelDisambiguation.ts` -- unit тесты | `test/renderer/utils/tabLabelDisambiguation.test.ts` |
| 4 | `EditorContextMenu` -- рендеринг, клики | `test/renderer/components/team/editor/EditorContextMenu.test.ts` |
| 5 | Manual: несколько файлов -> вкладки -> создать файл -> удалить файл | — |
## Критерии готовности
- [ ] Несколько файлов открыты одновременно
- [ ] Вкладки переключаются, закрываются (X, middle-click)
- [ ] Right-click -> New File, New Folder, Delete
- [ ] Создание файла добавляет в дерево + автоматически открывает
- [ ] Удаление через Trash с confirmation
- [ ] Disambiguation labels для дублирующихся имён
## Оценка
- **Надёжность решения: 7/10** -- file operations с правильной валидацией и trash -- надёжный подход.
- **Уверенность: 8/10** -- паттерны файловых операций отработаны.

View file

@ -0,0 +1,103 @@
# Итерация 4: Горячие клавиши, поиск, UX polish
> Зависит от: [Итерация 3](iter-3-multi-tab-crud.md)
## Цель
Клавиатурная навигация, Quick Open (Cmd+P), поиск по файлам (Cmd+Shift+F), breadcrumb, иконки файлов, виртуализация дерева.
## IPC каналы
| Канал | Описание |
|-------|----------|
| `editor:searchInFiles` | Literal string search, max 100 results, max 1MB/файл |
## Новые файлы
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | Cmd+P: fuzzy search через `cmdk` |
| 2 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | Cmd+Shift+F: результаты поиска |
| 3 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | Breadcrumb навигация (кликабельный) |
| 4 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | Модальное окно shortcuts (кнопка ?) |
| 5 | `src/renderer/components/team/editor/fileIcons.ts` | Маппинг расширений на lucide-react иконки/цвета |
| 6 | `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | Все горячие клавиши редактора. CM6 keybindings с `stopPropagation: true` |
| 7 | `src/main/services/editor/FileSearchService.ts` | Search in files (literal, с лимитами) |
## Изменения в существующих файлах
| # | Файл | Изменение |
|---|------|-----------|
| 1 | `src/shared/types/editor.ts` | Типы SearchResult |
| 2 | `src/shared/types/api.ts` | `searchInFiles` в EditorAPI |
| 3 | `src/main/ipc/editor.ts` | Handler `editor:searchInFiles` |
| 4 | `src/preload/index.ts` | `editor.searchInFiles` |
| 5 | `src/preload/constants/ipcChannels.ts` | `EDITOR_SEARCH_IN_FILES` |
| 6 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | QuickOpen, SearchInFiles, Breadcrumb, shortcuts |
| 7 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Виртуализация через react-virtual + иконки файлов |
| 8 | `src/renderer/components/team/editor/EditorTabBar.tsx` | Иконки файлов на вкладках |
## Security-требования
1. `searchInFiles`: ТОЛЬКО literal string search, НЕ regex. Default case-insensitive (`line.toLowerCase().includes(query.toLowerCase())` — ReDoS-безопасно). Опция `caseSensitive?: boolean` в параметрах. Max 1000 файлов, max 1MB/файл. Каждый файл валидируется через `validateFilePath()`. AbortController timeout 5s (SEC-8). **Cancellation**: предыдущий поиск отменяется AbortController при новом запросе (debounce 300ms на renderer перед IPC вызовом)
## Performance-требования
- File tree виртуализация: `@tanstack/react-virtual` -- `flattenTree()` + `useVirtualizer({ estimateSize: () => 28 })`
- Quick Open: кешировать flat file list при открытии editor. Invalidate по file watcher event или F5
- Search in files: запускать с AbortController timeout. На renderer: debounce 300ms + отмена предыдущего IPC запроса при новом вводе (хранить `abortControllerRef` в SearchInFilesPanel)
## Keyboard Scope Isolation (R1)
**Обязательный шаг**: добавить guard в `useKeyboardShortcuts.ts` для 6 конфликтующих shortcuts:
```typescript
// В useKeyboardShortcuts.ts:
const editorOpen = useStore(s => s.editorProjectPath !== null);
// В handler — early return для конфликтов при editorOpen === true
```
Конкретные конфликты: `Cmd+W` (:155), `Cmd+B` (:271), `Cmd+F` (:241), `Cmd+Shift+[/]` (:177), `Ctrl+Tab` (:81).
Плюс в `useEditorKeyboardShortcuts.ts` — все CM6 keybindings с `stopPropagation: true` как safety net.
**Keyboard scope для диалогов внутри editor**: Escape в QuickOpenDialog/SearchInFilesPanel закрывает ДИАЛОГ, не overlay. Реализация: диалоги вызывают `e.stopPropagation()` на Escape, overlay слушает Escape только когда нет открытых диалогов (state-guard `quickOpenVisible || searchPanelVisible`).
## Изменения в существующих файлах (доп.)
| # | Файл | Изменение |
|---|------|-----------|
| 9 | `src/renderer/hooks/useKeyboardShortcuts.ts` | Guard `editorOpen` → early return для 6 конфликтующих shortcuts (R1) |
## UX-требования
- `Cmd+Shift+[`/`]` для табов (НЕ `Cmd+[/]` -- это indent/outdent!)
- `Cmd+B` toggle sidebar, width persist в localStorage
- `Cmd+G` go to line (CM6 gotoLine)
- EmptyState показывает шпаргалку shortcuts
- Кнопка `?` в header overlay
- Breadcrumb: каждый сегмент кликабелен -- открывает папку в дереве
## Тестирование
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `FileSearchService` -- поиск по mock файлам, лимиты | `test/main/services/editor/FileSearchService.test.ts` |
| 2 | `useEditorKeyboardShortcuts` -- обработка горячих клавиш | `test/renderer/hooks/useEditorKeyboardShortcuts.test.ts` |
| 3 | `fileIcons.ts` -- маппинг расширений | `test/renderer/components/team/editor/fileIcons.test.ts` |
| 4 | Виртуализация: benchmark 5000+ файлов, FPS >= 55fps | — |
| 5 | Manual: Cmd+P, Cmd+Shift+F, навигация клавиатурой | — |
## Критерии готовности
- [ ] Cmd+P открывает quick open с fuzzy search
- [ ] Cmd+Shift+F показывает результаты поиска по содержимому
- [ ] Все горячие клавиши из таблицы работают
- [ ] Breadcrumb-навигация для текущего файла
- [ ] Иконки файлов по типу в дереве и вкладках
- [ ] File tree виртуализирован, скролл плавный
## Оценка
- **Надёжность решения: 7/10** -- виртуализация и search добавляют сложность, но библиотеки проверены.
- **Уверенность: 7/10** -- много нового UI, но каждый компонент изолирован.

View file

@ -0,0 +1,154 @@
# Итерация 5: Git status, file watching, расширенные возможности
> Зависит от: [Итерация 4](iter-4-search-shortcuts.md)
## Цель
Git status в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Line wrap toggle.
## Новые npm-зависимости
- `simple-git` v3.32+ (`pnpm add simple-git`) — обёртка над git CLI с TypeScript типами, parsed StatusResult, встроенным timeout/abort. 7.9M downloads/нед, dual ESM/CJS, не native module
- `chokidar` v4.0.3 (`pnpm add chokidar@4`) — file watcher (117M downloads/нед). Решает все проблемы raw `fs.watch`: нормализация событий, recursive на Linux, ENOSPC handling, symlinks. Dual CJS/ESM, Node 14+
## IPC каналы
| Канал | Описание |
|-------|----------|
| `editor:gitStatus` | Git status через `simple-git` (v3.32+), кеш 5 сек |
| `editor:watchDir` | Запуск file watcher (opt-in, НЕ по умолчанию) |
| `editor:change` | Event: файл изменился на диске (main -> renderer) |
## Новые файлы
| # | Файл | Описание |
|---|------|----------|
| 1 | `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher через `chokidar` v4 (~50-70 LOC). Burst coalescing, ENOSPC, recursive Linux — всё встроено. Фильтрация node_modules/.git/dist через `ignored` option |
| 2 | `src/main/services/editor/GitStatusService.ts` | Git status через `simple-git` с `StatusResult` маппингом, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~80-100 LOC) |
| 3 | `src/main/services/editor/conflictDetection.ts` | Утилита mtime check: сравнение mtime до/после save, conflict resolution (~40 LOC) |
| 4 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | M/U/A бейджи в дереве |
## Изменения в существующих файлах
| # | Файл | Изменение |
|---|------|-----------|
| 1 | `src/shared/types/editor.ts` | `GitFileStatus`, `EditorFileChangeEvent` |
| 2 | `src/shared/types/api.ts` | `gitStatus`, `onEditorChange` в EditorAPI |
| 3 | `src/main/ipc/editor.ts` | Handlers для git status и file watcher |
| 4 | `src/preload/index.ts` | `editor.gitStatus`, `editor.onEditorChange` (НЕ `onFileChange` — конфликт с существующим `ElectronAPI.onFileChange`) |
| 5 | `src/preload/constants/ipcChannels.ts` | `EDITOR_GIT_STATUS`, `EDITOR_WATCH_DIR`, `EDITOR_CHANGE` |
| 6 | `src/renderer/components/team/editor/EditorFileTree.tsx` | Git status badges |
| 7 | `src/renderer/components/team/editor/CodeMirrorEditor.tsx` | Conflict detection (mtime check) при сохранении |
| 8 | `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | File watcher подписка, auto-refresh, conflict modal |
| 9 | `src/renderer/store/slices/editorSlice.ts` | Git status data, file watcher state |
| 10 | `src/renderer/store/index.ts` | В `initializeNotificationListeners()` добавить подписку `if (api.editor?.onEditorChange)` → обновление дерева/табов при внешних изменениях (guard обязателен — паттерн из всех существующих subscriptions) |
| 11 | `src/main/index.ts` | `mainWindow.on('closed')``cleanupEditorState()`. `shutdownServices()``cleanupEditorState()` |
## Security-требования
1. `editor:gitStatus`: `cwd = activeProjectRoot` (валидный). Не передавать full paths от git без валидации
2. `editor:change`: пути в events могут утечь через symlink -- валидировать перед передачей в renderer (SEC-2)
## Watcher lifecycle cleanup (macOS: window closed but app alive)
- `editor:open` — если `activeProjectRoot !== null`, сначала остановить предыдущий watcher и сбросить state (идемпотентный reset). Guard: `if (activeProjectRoot !== null) throw new Error('Another editor is already open')`
- `mainWindow.on('closed')` в `src/main/index.ts` — вызвать `cleanupEditorState()` (экспорт из `editor.ts`): сброс `activeProjectRoot`, остановка watcher. Аналог существующего cleanup для `notificationManager`, `ptyTerminalService`
- `shutdownServices()` — добавить `cleanupEditorState()` рядом с `removeIpcHandlers()`
## Performance-требования (R4/R5)
- File watcher opt-in: по умолчанию ВЫКЛЮЧЕН. Toggle "Watch for external changes". По умолчанию ручной refresh (F5)
### chokidar конфигурация
```typescript
// src/main/services/editor/EditorFileWatcher.ts
import { watch, type FSWatcher } from 'chokidar';
let watcher: FSWatcher | null = null;
function startWatching(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void) {
watcher = watch(projectRoot, {
ignored: /(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor)/,
ignoreInitial: true,
followSymlinks: false,
depth: 20,
});
watcher.on('change', path => onChange({ type: 'change', path }));
watcher.on('add', path => onChange({ type: 'create', path }));
watcher.on('unlink', path => onChange({ type: 'delete', path }));
}
function stopWatching() {
watcher?.close();
watcher = null;
}
```
- **chokidar v4** решает все проблемы raw `fs.watch`: нормализация событий macOS, recursive на Linux, ENOSPC handling, debounce
- **macOS**: FSEvents через chokidar (надёжность 9/10)
- **Linux**: inotify через chokidar с автоматическим fallback (надёжность 8/10, было 6/10 с raw fs.watch)
- Git status кешировать на 5 секунд. Invalidate по file watcher event
### simple-git конфигурация
```typescript
// src/main/services/editor/GitStatusService.ts
import { simpleGit, StatusResult, SimpleGit } from 'simple-git';
// Создать инстанс с --no-optional-locks + timeout
const createGit = (projectRoot: string): SimpleGit =>
simpleGit({
baseDir: projectRoot,
timeout: { block: 10_000 }, // 10s (паттерн из GitDiffFallback.ts)
}).env('GIT_OPTIONAL_LOCKS', '0'); // эквивалент --no-optional-locks
// Маппинг StatusResult → GitFileStatus[]
function mapStatus(result: StatusResult): GitFileStatus[] {
const files: GitFileStatus[] = [];
for (const p of result.modified) files.push({ path: p, status: 'modified' });
for (const p of result.not_added) files.push({ path: p, status: 'untracked' });
for (const p of result.staged) files.push({ path: p, status: 'staged' });
for (const p of result.deleted) files.push({ path: p, status: 'deleted' });
for (const p of result.conflicted) files.push({ path: p, status: 'conflict' });
for (const r of result.renamed) files.push({ path: r.to, status: 'renamed', renamedFrom: r.from });
return files;
}
```
- **`GIT_OPTIONAL_LOCKS=0`** — предотвращает `.git/index.lock` конфликты (критично для фоновых запросов!)
- **`timeout.block: 10_000`** — SIGINT после 10 сек без вывода
- **Парсинг не нужен**`simple-git` делает полный парсинг porcelain вывода включая renamed, conflicts, ahead/behind
- Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` для проверки наличия `.git`
- Graceful degradation:
- Нет git → скрыть git бейджи, "Git not available" в status bar
- Не git-repo → скрыть git бейджи
- Timeout → "Git status unavailable" + кнопка retry
## UX-требования
- File changed on disk while open: banner в табе "File changed on disk. [Reload] [Keep mine] [Show diff]" (НЕ перезаписывать молча)
- File deleted on disk while open: banner "File no longer exists on disk. [Close tab]"
- Conflict detection при save: mtime check. Если изменился -- dialog "Overwrite / Cancel / Show diff"
- Line wrap toggle в toolbar
## Тестирование
| # | Что тестировать | Файл |
|---|----------------|------|
| 1 | `GitStatusService` -- маппинг `simple-git` StatusResult → GitFileStatus[], кеш, graceful degradation | `test/main/services/editor/GitStatusService.test.ts` |
| 2 | `EditorFileWatcher` -- chokidar watcher lifecycle (start/stop), event mapping, cleanup | `test/main/services/editor/EditorFileWatcher.test.ts` |
| 3 | `conflictDetection` -- mtime check логика | `test/main/services/editor/conflictDetection.test.ts` |
| 4 | Manual: изменить файл в внешнем редакторе -> conflict banner | — |
## Критерии готовности
- [ ] Git status бейджи (M/U/A/C) в файловом дереве (C = conflict для UU/AA/DD)
- [ ] Auto-refresh при изменениях на диске (при включённом watcher)
- [ ] Conflict detection при сохранении
- [ ] Line wrap toggle
## Оценка
- **Надёжность решения: 9/10** (было 8/10) -- `simple-git` + `chokidar` убирают ~300 LOC ручного кода: парсинг porcelain, fs.watch нормализация, ENOSPC fallback. Всё покрыто проверенными пакетами.
- **Уверенность: 9/10** -- simple-git 7.9M + chokidar 117M downloads/нед. Оба с TypeScript, проверены в production.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,613 @@
# Plan: In-App Project Editor -- Iteration Plan
## Контекст и предпосылки
На странице деталей команды (`TeamDetailView.tsx`) рядом с путём проекта (строки 761-769 в `TeamDetailView.tsx`, используется `FolderOpen` иконка и `formatProjectPath()`) добавляется кнопка "Open in Editor", которая открывает полноэкранный оверлей с файловым деревом, CodeMirror-редактором, вкладками и файловыми операциями.
### Существующие паттерны, на которые опираемся
1. **Fullscreen overlay**: `ChangeReviewDialog.tsx` -- полноэкранный `fixed inset-0 z-50` компонент с хедером, левой панелью (ReviewFileTree) и правой панелью (ContinuousScrollView). Это точный архитектурный прототип.
2. **File tree**: `ReviewFileTree.tsx` -- дерево файлов с `buildTree()`, collapse/expand, активный элемент. Будет адаптирован для файлового браузера (не review).
3. **CodeMirror**: уже установлен в проекте (`@codemirror/*` ~20 пакетов), используется в `CodeMirrorDiffView.tsx`. Функция `getSyncLanguageExtension()` уже мапит расширения на языковые пакеты. Тема `diffTheme` использует CSS-переменные проекта.
4. **IPC-паттерн**: module-level state + `initialize/register/remove` тройка + `wrapHandler<T>()` для IpcResult. Ближайший пример: `review.ts`.
5. **Preload bridge**: `invokeIpcWithResult<T>()` для IpcResult, прямой `ipcRenderer.invoke()` для остальных. Группировка методов через sub-объект (как `review: ReviewAPI`).
6. **Path security**: `validateFilePath()` из `pathValidation.ts` -- проверяет путь на sensitive patterns и sandbox.
7. **Store**: Zustand slices с паттерном `data/selectedId/loading/error`.
---
## Итерация 1: Walking Skeleton (файловое дерево + read-only просмотр)
### Цель
Минимальный end-to-end вертикальный срез: кнопка "Open in Editor" на TeamDetailView открывает полноэкранный оверлей, где слева -- дерево файлов проекта, справа -- содержимое выбранного файла (read-only, с подсветкой синтаксиса через CodeMirror).
### Зависимости (npm)
Никаких новых -- все CodeMirror-пакеты и lucide-react иконки уже установлены.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:readDir` | renderer -> main | Рекурсивное чтение директории (возвращает дерево) |
| `editor:readFile` | renderer -> main | Чтение содержимого файла по абсолютному пути |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/shared/types/editor.ts` | Типы: `EditorTreeNode`, `EditorFileContent`, запросы/ответы |
| `src/main/services/editor/ProjectFileService.ts` | Сервис: чтение директорий (рекурсивно с лимитами) и файлов. Использует `validateFilePath()` для security |
| `src/main/ipc/editor.ts` | IPC handlers: `editor:readDir`, `editor:readFile`. Паттерн: module-level state + `wrapEditorHandler()` |
| `src/preload/constants/ipcChannels.ts` | Добавить `EDITOR_READ_DIR`, `EDITOR_READ_FILE` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Главный fullscreen overlay (по образцу `ChangeReviewDialog.tsx`) |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Компонент дерева файлов (адаптация `ReviewFileTree.tsx` для filesystem -- без review-статусов) |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Read-only CodeMirror view (адаптация `CodeMirrorDiffView.tsx` без merge mode) |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/api.ts` | Добавить `EditorAPI` интерфейс + `editor: EditorAPI` в `ElectronAPI` |
| `src/preload/index.ts` | Добавить `editor:` группу в `electronAPI` объект |
| `src/main/ipc/handlers.ts` | Добавить `initialize/register/removeEditorHandlers` |
| `src/renderer/components/team/TeamDetailView.tsx` | Кнопка "Open in Editor" рядом с projectPath (строка ~770), state для open/close оверлея |
### Важные решения
- **Security**: `ProjectFileService` ОБЯЗАН использовать `validateFilePath(filePath, projectRoot)` для каждого запроса. Путь должен быть внутри projectRoot (sandbox). Нельзя читать файлы вне проекта.
- **Лимиты**: readDir рекурсия ограничена глубиной (max 10 уровней) и количеством файлов (max 5000 nodes). Исключаются `node_modules`, `.git`, `dist`, `build`, `__pycache__`, `.next`.
- **Read-only**: на этой итерации CodeMirror создаётся с `EditorState.readOnly.of(true)`.
- **Lazy loading дерева**: первый вызов readDir возвращает только верхний уровень. При раскрытии папки -- повторный вызов для поддиректории (ленивая загрузка). Или: полное дерево сразу, но с лимитом глубины и ignored patterns.
### Тестирование
- **Unit**: `ProjectFileService` -- чтение директории с mock fs, проверка security (reject paths outside projectRoot), проверка исключения node_modules.
- **Unit**: `EditorFileTree` -- snapshot тесты рендеринга дерева.
- **Manual**: открыть TeamDetailView, нажать "Open in Editor", убедиться что дерево загружается, клик по файлу показывает содержимое с подсветкой.
### Критерии готовности
- Кнопка видна на TeamDetailView рядом с путём проекта
- Оверлей открывается по клику, закрывается по Escape или X
- Дерево файлов загружается для projectPath команды
- Клик по файлу показывает содержимое с синтаксической подсветкой
- Попытка прочитать файл за пределами проекта -- отказ
- `pnpm typecheck` проходит
### Надёжность решения: 8/10
### Уверенность: 9/10
---
## Итерация 2: Editable CodeMirror + сохранение файлов
### Цель
Переключить CodeMirror из read-only в редактируемый режим. Добавить Cmd+S для сохранения. Показывать индикатор unsaved changes.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:writeFile` | renderer -> main | Запись содержимого файла на диск |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Панель вкладок (один файл пока, но подготовка к multi-tab) |
| `src/renderer/components/team/editor/useEditorState.ts` | Хук для управления состоянием открытых файлов, dirty flags, save |
| `src/renderer/store/slices/editorSlice.ts` | Zustand slice: openFiles, activeFilePath, dirtyFiles, loading/error |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Добавить типы для write request/response |
| `src/shared/types/api.ts` | Добавить `writeFile` в `EditorAPI` |
| `src/main/services/editor/ProjectFileService.ts` | Метод `writeFile(projectRoot, filePath, content)` с validation |
| `src/main/ipc/editor.ts` | Handler `editor:writeFile` |
| `src/preload/index.ts` | Добавить `editor.writeFile` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_WRITE_FILE` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция EditorTabBar, переключение read-only -> editable |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Убрать readOnly, добавить onChange callback, Cmd+S keymap |
| `src/renderer/store/index.ts` | Подключить editorSlice |
| `src/renderer/store/types.ts` | Расширить AppState типом editorSlice |
### Важные решения
- **Cmd+S**: перехватывается через CodeMirror keymap extension (не глобальный listener), чтобы не конфликтовать с другими горячими клавишами.
- **Dirty flag**: отслеживается через сравнение текущего содержимого с оригинальным (при загрузке). Точка в названии вкладки для dirty файлов.
- **Confirm on close**: если есть unsaved changes -- `confirm()` через существующий `ConfirmDialog`.
- **Backup**: перед записью -- никакого backup на этой итерации (файл просто перезаписывается). В будущем можно добавить.
- **Concurrency**: если файл изменился на диске пока был открыт -- пока не обрабатываем (это итерация 4-5).
### Тестирование
- **Unit**: `ProjectFileService.writeFile` -- запись с mock fs, reject для файлов вне проекта.
- **Unit**: `editorSlice` -- открытие/закрытие файлов, dirty state, сохранение.
- **Unit**: `useEditorState` -- хук тестирование с Zustand store.
- **Manual**: открыть файл, отредактировать, Cmd+S, убедиться что файл записался, dirty индикатор сбрасывается.
### Критерии готовности
- Файл редактируется в CodeMirror (не read-only)
- Cmd+S сохраняет файл
- Dirty indicator (точка) на вкладке
- При закрытии с unsaved changes -- confirmation dialog
- Сохранение отказывает для файлов вне projectRoot
### Надёжность решения: 7/10
### Уверенность: 8/10
---
## Итерация 3: Multi-tab + создание/удаление файлов
### Цель
Поддержка нескольких открытых файлов во вкладках. Контекстное меню на файловом дереве: создать файл, создать папку, удалить файл. Переименование -- вне scope.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:createFile` | renderer -> main | Создать файл (с опциональным начальным содержимым) |
| `editor:createDir` | renderer -> main | Создать директорию |
| `editor:deleteFile` | renderer -> main | Удалить файл (в Trash через Electron shell.trashItem) |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/EditorContextMenu.tsx` | Context menu для дерева файлов (New File, New Folder, Delete, Reveal in Finder) |
| `src/renderer/components/team/editor/NewFileDialog.tsx` | Маленький inline-input для ввода имени нового файла/папки |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Типы для create/delete запросов |
| `src/shared/types/api.ts` | Расширить `EditorAPI` методами `createFile`, `createDir`, `deleteFile` |
| `src/main/services/editor/ProjectFileService.ts` | Методы `createFile`, `createDir`, `deleteFile`. deleteFile использует `shell.trashItem()` (безопасное удаление) |
| `src/main/ipc/editor.ts` | 3 новых handler |
| `src/preload/index.ts` | 3 новых метода в editor |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_CREATE_FILE`, `EDITOR_CREATE_DIR`, `EDITOR_DELETE_FILE` |
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Multi-tab: массив вкладок, переключение, close (X), close other tabs, middle-click close |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Right-click context menu, refresh после create/delete |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Управление массивом открытых файлов, переключение активной вкладки |
| `src/renderer/store/slices/editorSlice.ts` | Массив openTabs, activeTabId, actions: openFile, closeFile, switchTab, reorderTabs |
### Важные решения
- **Удаление через Trash**: используем `shell.trashItem()` (Electron API) вместо `fs.unlink()`. Это безопасно -- пользователь может восстановить файл из корзины.
- **Confirm on delete**: обязательный ConfirmDialog перед удалением.
- **Tab ordering**: drag-and-drop для вкладок через `@dnd-kit` (уже установлен в проекте).
- **Имя нового файла**: валидация -- запрет на `.`, `..`, `/` в начале, запрет на спецсимволы.
- **Refresh дерева**: после create/delete автоматически перечитываем поддерево. Не нужен FileWatcher -- явный refresh.
### Тестирование
- **Unit**: `ProjectFileService.createFile/deleteFile` с mock fs.
- **Unit**: `editorSlice` -- multi-tab actions (open, close, reorder).
- **Unit**: `EditorContextMenu` -- рендеринг, клики.
- **Manual**: открыть несколько файлов, переключаться между вкладками, создать файл, удалить файл.
### Критерии готовности
- Можно открыть несколько файлов одновременно
- Вкладки переключаются, закрываются
- Правый клик по дереву -- New File, New Folder, Delete
- Создание файла добавляет его в дерево
- Удаление -- через Trash с confirmation
### Надёжность решения: 7/10
### Уверенность: 8/10
---
## Итерация 4: Горячие клавиши, поиск, UX polish
### Цель
Клавиатурная навигация (Cmd+P quick open, Cmd+W close tab, Cmd+Shift+[ / ] switch tabs). Поиск по содержимому файлов через Cmd+Shift+F. Breadcrumb навигация. Иконки файлов по типу.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:searchInFiles` | renderer -> main | Поиск по содержимому файлов (grep-like) |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/renderer/components/team/editor/QuickOpenDialog.tsx` | Cmd+P dialog: fuzzy search по именам файлов (по образцу `cmdk` -- уже установлен) |
| `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | Панель результатов поиска (заменяет или дополняет file tree) |
| `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | Breadcrumb навигация по пути текущего файла |
| `src/renderer/components/team/editor/fileIcons.ts` | Маппинг расширений на lucide-react иконки и цвета |
| `src/renderer/hooks/useEditorKeyboardShortcuts.ts` | Хук для всех горячих клавиш редактора |
| `src/main/services/editor/FileSearchService.ts` | Сервис: search in files с лимитами (grep-like, max 100 results) |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | Типы для search request/response |
| `src/shared/types/api.ts` | `searchInFiles` в EditorAPI |
| `src/main/ipc/editor.ts` | Handler `editor:searchInFiles` |
| `src/preload/index.ts` | `editor.searchInFiles` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_SEARCH_IN_FILES` |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | Интеграция QuickOpen, SearchInFiles, Breadcrumb, keyboard shortcuts |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Иконки файлов по типу |
| `src/renderer/components/team/editor/EditorTabBar.tsx` | Иконки файлов на вкладках |
### Важные решения
- **Quick Open**: использовать `cmdk` (уже в зависимостях, v1.0.4) для fuzzy search по именам файлов. Список файлов загружается при открытии оверлея.
- **Search in Files**: серверная сторона делает простой grep по файлам с Node.js (readline + regex). Не используем external tools типа ripgrep -- держим zero-dependency. Лимит: 100 результатов, max 10MB на файл.
- **Горячие клавиши**: Cmd+P (quick open), Cmd+W (close tab), Cmd+S (save), Cmd+Shift+F (search), Cmd+Shift+[ / ] (switch tabs), Cmd+\ (toggle file tree).
- **Breadcrumb**: кликабельный -- каждый сегмент пути открывает эту папку в дереве.
### Тестирование
- **Unit**: `FileSearchService` -- поиск по mock файлам, лимиты.
- **Unit**: `useEditorKeyboardShortcuts` -- обработка горячих клавиш.
- **Unit**: `fileIcons.ts` -- маппинг расширений.
- **Manual**: Cmd+P, Cmd+Shift+F, навигация клавиатурой.
### Критерии готовности
- Cmd+P открывает quick open с fuzzy search
- Cmd+Shift+F показывает результаты поиска по содержимому
- Все основные горячие клавиши работают
- Breadcrumb-навигация для текущего файла
- Иконки файлов по типу в дереве и вкладках
### Надёжность решения: 7/10
### Уверенность: 7/10
---
## Итерация 5: Git status, file watching, расширенные возможности
### Цель
Показывать git status (modified/untracked/staged) в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Minimap. Line numbers toggle.
### IPC каналы (новые)
| Канал | Направление | Описание |
|-------|-------------|----------|
| `editor:gitStatus` | renderer -> main | Получить git status для директории (modified, staged, untracked) |
| `editor:watchDir` | renderer -> main | Запустить file watcher для проекта (возвращает cleanup) |
| `editor:change` | main -> renderer | Event: файл изменился на диске |
### Новые файлы
| Файл | Описание |
|------|----------|
| `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher адаптация (~60 LOC) для отслеживания изменений в projectRoot |
| `src/main/services/editor/GitStatusService.ts` | Сервис: вызывает git status --porcelain и парсит вывод |
| `src/renderer/components/team/editor/GitStatusBadge.tsx` | Бейджи M/U/A рядом с файлами в дереве |
### Изменения в существующих файлах
| Файл | Изменение |
|------|-----------|
| `src/shared/types/editor.ts` | `GitFileStatus`, `EditorFileChangeEvent` |
| `src/shared/types/api.ts` | `gitStatus`, `onEditorFileChange` в EditorAPI |
| `src/main/ipc/editor.ts` | Handlers для git status и file watcher events |
| `src/preload/index.ts` | `editor.gitStatus`, `editor.onFileChange` |
| `src/preload/constants/ipcChannels.ts` | `EDITOR_GIT_STATUS`, `EDITOR_WATCH_DIR`, `EDITOR_CHANGE` |
| `src/renderer/components/team/editor/EditorFileTree.tsx` | Git status badges (M = modified, U = untracked, A = staged) |
| `src/renderer/components/team/editor/EditorCodeView.tsx` | Line wrapping toggle, conflict detection при сохранении |
| `src/renderer/components/team/editor/ProjectEditorOverlay.tsx` | File watcher подписка, auto-refresh дерева, conflict modal при concurrent edit |
| `src/renderer/store/slices/editorSlice.ts` | Git status data, file watcher state |
### Важные решения
- **Git status**: вызываем child_process с git status --porcelain -u в projectRoot. Парсим вывод. Кешируем на 5 секунд. Не используем libgit2 -- слишком тяжёлый.
- **File watcher**: используем существующий chokidar-подобный паттерн (как `FileWatcher` в проекте). Debounce 200ms. При получении события -- refresh дерева и уведомление если открытый файл изменился.
- **Conflict detection**: при сохранении -- проверить mtime файла. Если изменился после последнего чтения -- показать conflict dialog (перезаписать / отменить / diff).
- **Minimap**: CodeMirror не имеет встроенного minimap. Можно использовать @replit/codemirror-minimap или пропустить. Решение: пропустить minimap (слишком специфичная dependency), вместо этого добавить line wrap toggle и go-to-line (Cmd+G).
### Тестирование
- **Unit**: `GitStatusService` -- парсинг git status --porcelain вывода.
- **Unit**: `EditorFileWatcher` -- debounce, event types.
- **Unit**: conflict detection логика.
- **Manual**: изменить файл в внешнем редакторе, убедиться что отображается conflict.
### Критерии готовности
- Git status бейджи в файловом дереве
- Auto-refresh при изменениях на диске
- Conflict detection при сохранении файла, изменённого извне
- Go-to-line (Cmd+G)
- Line wrap toggle
### Надёжность решения: 6/10
### Уверенность: 7/10
---
## Сводная таблица файлов по итерациям
### Итерация 1 (7 новых, 4 изменения)
**Новые:** `shared/types/editor.ts`, `main/services/editor/ProjectFileService.ts`, `main/ipc/editor.ts`, `renderer/components/team/editor/ProjectEditorOverlay.tsx`, `renderer/components/team/editor/EditorFileTree.tsx`, `renderer/components/team/editor/EditorCodeView.tsx`
**Изменения:** `shared/types/api.ts`, `preload/index.ts`, `preload/constants/ipcChannels.ts`, `main/ipc/handlers.ts`, `renderer/components/team/TeamDetailView.tsx`
### Итерация 2 (3 новых, ~8 изменений)
**Новые:** `renderer/components/team/editor/EditorTabBar.tsx`, `renderer/hooks/useEditorState.ts`, `renderer/store/slices/editorSlice.ts`
**Изменения:** `shared/types/editor.ts`, `shared/types/api.ts`, `main/services/editor/ProjectFileService.ts`, `main/ipc/editor.ts`, `preload/index.ts`, `preload/constants/ipcChannels.ts`, `renderer/components/team/editor/*`, `renderer/store/index.ts`, `renderer/store/types.ts`
### Итерация 3 (2 новых, ~8 изменений)
**Новые:** `renderer/components/team/editor/EditorContextMenu.tsx`, `renderer/components/team/editor/NewFileDialog.tsx`
**Изменения:** многие файлы из итерации 2
### Итерация 4 (6 новых, ~8 изменений)
**Новые:** `QuickOpenDialog.tsx`, `SearchInFilesPanel.tsx`, `EditorBreadcrumb.tsx`, `fileIcons.ts`, `useEditorKeyboardShortcuts.ts`, `main/services/editor/FileSearchService.ts`
### Итерация 5 (3 новых, ~7 изменений)
**Новые:** `EditorFileWatcher.ts`, `GitStatusService.ts`, `GitStatusBadge.tsx`
---
## Риски и предупреждения
1. **Безопасность (критичный риск)**: каждый файловый IPC handler ОБЯЗАН валидировать что запрашиваемый путь находится внутри `projectRoot`. Path traversal (`../../etc/passwd`) -- главный вектор атаки. Используем существующий `validateFilePath()` из `src/main/utils/pathValidation.ts` (НЕ писать свой).
2. **Большие проекты**: дерево файлов может содержать тысячи файлов. Обязательны excluded patterns (`node_modules`, `.git`) и лимиты. Для поиска по файлам -- лимит на размер файла.
3. **Race conditions при сохранении**: если агент Claude параллельно редактирует тот же файл -- потеря данных. Итерация 5 добавляет mtime-проверку, но полноценный lock отсутствует.
4. **Memory**: CodeMirror для очень больших файлов (10MB+) может потреблять много памяти. Лимит на размер читаемого файла: **2MB** (не 5MB -- снижено после security review; IPC сериализация удваивает потребление памяти).
5. **ProseMirror vs CodeMirror**: в requirements указан ProseMirror, но в проекте уже глубоко интегрирован CodeMirror (20+ пакетов, diff view, языковые пакеты). Рекомендация: использовать CodeMirror (не ProseMirror). ProseMirror ориентирован на rich-text, а CodeMirror -- на код. CodeMirror 6 = тот же автор (Marijn Haverbeke), уже в проекте, zero additional dependencies.
---
## Архитектурные решения после ревизии
> Добавлено после ревизии. Влияет на каждую итерацию.
### Обязательные рефакторинги ДО или ВО ВРЕМЯ итерации 1
1. **Извлечь `buildTree()` в `src/renderer/utils/fileTreeBuilder.ts`** (из `ReviewFileTree.tsx`).
Иначе будет дублирование при создании `EditorFileTree`. Рефакторинг не ломает Review -- это extract-and-import.
2. **Извлечь `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` в `src/renderer/utils/codemirrorLanguages.ts`** (из `CodeMirrorDiffView.tsx`).
Аналогично -- extract-and-import, `CodeMirrorDiffView` начинает импортировать из утилиты.
3. **Извлечь базовую тему CM в `src/renderer/utils/codemirrorTheme.ts`** (из `diffTheme` в `CodeMirrorDiffView.tsx`).
Общие стили (`&`, `.cm-gutters`, `.cm-scroller`, `.cm-content`, `.cm-cursor`, `.cm-selectionBackground`) -- в общую тему.
Diff-специфичные (`.cm-changedLine`, `.cm-deletedChunk` и т.д.) -- остаются в `CodeMirrorDiffView.tsx`.
4. **Извлечь `wrapHandler` в `src/main/ipc/ipcWrapper.ts`** (из `review.ts`).
`createIpcWrapper('IPC:editor')` вместо копирования `wrapReviewHandler`.
5. **Имя сервиса: `ProjectFileService`** (не `FileEditorService`). Stateless, без `rootPath` в конструкторе.
Каждый метод принимает `projectRoot` как первый аргумент. Паттерн: `TeamDataService`.
### Изменения в итерациях по результатам ревизии
**Итерация 1:**
- `EditorFileTree.tsx` использует generic `FileTree` из `fileTreeBuilder.ts` + render-prop для иконок
- `EditorCodeView.tsx` использует extracted `codemirrorLanguages.ts` и `codemirrorTheme.ts`
- `ProjectFileService` -- stateless, `readDir(projectRoot, dirPath)`, `readFile(projectRoot, filePath)`
- Security: `validateFilePath()` из `pathValidation.ts`, НЕ свой `assertInsideRoot()`
- НЕ создавать editorSlice на итерации 1 -- state для read-only просмотра можно держать в useState
**Итерация 2:**
- `editorSlice.ts` создаётся с чёткими секциями-группами (tree / tabs / content-save)
- `buildEditorExtensions(options)` -- фабрика extensions, компонент не знает о конкретных CM plugins
- `useEditorState.ts` -> убрать. Логика целиком в slice. Хук `useEditorState` дублирует slice.
**Итерация 3:**
- Tab management actions (`openFile`, `closeTab`, `setActiveTab`) уже в slice с итерации 2
- `EditorContextMenu.tsx` -- ОК, отдельный компонент
- `NewFileDialog.tsx` -- ОК, inline input
**Итерация 4:**
- `FileSearchService.ts` -- отдельный сервис в main, ОК (SRP)
- `useEditorKeyboardShortcuts.ts` -- ОК, отдельный хук
- `fileIcons.ts` -- ОК, чистая утилита
**Итерация 5:**
- `GitStatusService.ts` -- отдельный сервис, ОК
- `EditorFileWatcher.ts` -- повторяет паттерн FileWatcher (~60 LOC), ОК
- mtime conflict detection -- необходима проверка и в `saveFile` (slice), и в `writeFile` (service)
---
## UX Review
> Добавлено после UX-ревью. Дополнения и исправления по итерациям.
### Дополнения к итерации 1 (Walking Skeleton)
1. **Focus management:** При открытии overlay -- фокус на первый файл в дереве. При закрытии -- вернуть фокус на кнопку "Open in Editor" (паттерн `returnFocusRef`). Добавить `inert` на фоновый контент.
2. **ARIA:** File tree сразу с `role="tree"`, `role="treeitem"`, `aria-expanded`, `role="group"`. Не откладывать accessibility на потом.
3. **Пустой проект:** Если `readDir` возвращает 0 видимых файлов -- показать "No files found" + "Create a new file?" (кнопка неактивна до итерации 3).
4. **Binary файлы:** Уже на итерации 1 (read-only) нужна проверка бинарности. Добавить `isBinary` в `ReadFileResult` и `EditorBinaryState.tsx` -- "This file is binary. Open in system viewer?"
5. **Глубокая вложенность:** Max визуальный indent = 12 уровней. Tooltip с полным путём на глубоких узлах.
### Дополнения к итерации 2 (Editing + Save)
1. **Status bar:** Добавить `EditorStatusBar.tsx` -- `[Ln 42, Col 15] | [TypeScript] | [Spaces: 2]`. Данные из CM6 state. CSS: `bg-surface-sidebar border-t border-border text-text-muted text-xs h-6`.
2. **Unsaved changes при закрытии overlay:** Не только при закрытии tab, но и при Escape/X для overlay. Три кнопки: "Save All & Close" / "Discard & Close" / "Cancel". Добавить `hasUnsavedChanges()` в slice.
3. **Файл удалён извне:** При `saveFile` с ENOENT -- inline-ошибка "File was deleted. Create new? / Close tab". Не падать.
### Дополнения к итерации 3 (Multi-tab + file ops)
1. **Disambiguation tab labels:** Два таба "index.ts" -- нужно показать "(main/utils)" и "(renderer/utils)". Утилита `getDisambiguatedTabLabel()` в `src/renderer/utils/tabLabelDisambiguation.ts`.
2. **Длинные имена файлов:** Табы с max-width ~160px, `truncate`, tooltip. Modified dot ПЕРЕД текстом (не обрезается при truncate).
3. **ARIA для tab bar:** `role="tablist"`, `role="tab"`, `aria-selected`, `role="tabpanel"`.
### Исправления к итерации 4 (Hotkeys, search, UX polish)
1. **Keyboard shortcuts -- конфликт:** `Cmd+[` / `Cmd+]` это indent/outdent в CM6 и VS Code! Переключение табов: `Cmd+Shift+[` / `Cmd+Shift+]` (VS Code convention). Добавить `Ctrl+Tab` / `Ctrl+Shift+Tab`.
2. **Cmd+B toggle sidebar:** Добавить в список горячих клавиш. Sidebar width persist в localStorage.
3. **Cmd+G go to line:** Добавить. CM6 уже поддерживает через `gotoLine` command.
4. **Discoverability:** Кнопка `?` в header overlay (как в ChangeReviewDialog). EmptyState показывает шпаргалку shortcuts.
### Дополнения к итерации 5 (Git, file watching)
1. **File changed on disk while open in tab:** При обнаружении изменения -- banner в табе: "File changed on disk. [Reload] [Keep mine] [Show diff]". Не перезаписывать молча.
2. **File deleted on disk while open in tab:** Banner: "File no longer exists on disk. [Close tab]". Не показывать ошибку при попытке сохранить -- показать предупреждение.
---
## Security Review -- дополнения по итерациям
> Полный аудит безопасности описан в `plan-architecture.md` секция 18. Здесь -- конкретные требования для каждой итерации.
### Итерация 1: Security-critical
1. **`ProjectFileService.readDir()`**: Валидировать КАЖДЫЙ entry через `validateFilePath()`. Для symlinks -- `fs.realpath()` + повторная проверка containment. Молча пропускать symlinks, ведущие за пределы projectRoot (см. SEC-2 в plan-architecture.md).
2. **`ProjectFileService.readFile()`**: Проверить `fs.lstat()` -> `isFile()` ДО чтения. Проверить `stats.size <= 2MB`. Блокировать пути `/dev/`, `/proc/`, `/sys/`. После чтения -- post-read verify через `fs.realpath()` (TOCTOU mitigation).
3. **projectRoot**: Хранить в module-level state `editor.ts`, НЕ принимать от renderer при каждом IPC вызове. Устанавливать через `editor:open(projectPath)` с валидацией.
4. **Sensitive файлы**: `validateFilePath()` уже блокирует `.env`, `.ssh`, `credentials.json` и т.д. В readDir: показывать с пометкой "locked", при клике -- "Sensitive file, cannot open".
### Итерация 2: Security-critical
1. **`ProjectFileService.writeFile()`**:
- `validateFilePath()` ДО записи
- `Buffer.byteLength(content, 'utf8') <= 2MB` ДО записи
- Atomic write: tmp файл в той же директории + `rename()`
- Запрет записи в `.git/` директорию
- Post-write verify не нужна (atomic rename -- одна операция)
2. **`editor:writeFile` IPC handler**: Параметр `filePath` от renderer валидируется через `validateFilePath(filePath, activeProjectRoot)`. `activeProjectRoot` из module-level state.
### Итерация 3: Security-critical
1. **`editor:createFile`**: Валидация имени файла через `validateFileName()`:
- Запрет `.` и `..` как имени
- Запрет control characters (`\x00-\x1f`)
- Запрет path separators (`/`, `\`, `:`)
- Запрет NUL bytes
- Max длина 255 символов
- Запрет sensitive паттернов (`.env`, `*.key`) при СОЗДАНИИ (опционально -- можно разрешить)
2. **`editor:deleteFile`**: Использовать `shell.trashItem()`, НЕ `fs.unlink()`. Валидация пути через `validateFilePath()`.
3. **Валидация parentDir**: При `createFile(parentDir, name)` -- валидировать и `parentDir`, и `path.join(parentDir, name)`.
### Итерация 4: Security-critical
1. **`editor:searchInFiles`**:
- ТОЛЬКО literal string search, НЕ regex от пользователя
- Max 1000 файлов для поиска, max 1MB на файл
- Запустить в `worker_thread` или с AbortController timeout
- Каждый файл для поиска валидировать через `validateFilePath()`
### Итерация 5: Medium security
1. **`editor:gitStatus`**: Выполняет `child_process.exec('git status')` -- убедиться что `cwd` установлен в projectRoot и что projectRoot валиден.
2. **`editor:watchDir`**: FileWatcher на projectRoot -- ОК, но при получении событий не передавать полные пути файлов в renderer без валидации.
3. **`editor:change` events (main->renderer)**: Пути файлов в events -- потенциальная утечка информации если watcher случайно поймает файл за пределами проекта (через symlink).
### ВНИМАНИЕ: Существующая уязвимость (не связана с editor)
**`review:saveEditedFile`** в `src/main/ipc/review.ts` записывает файл без валидации пути. См. SEC-11 в plan-architecture.md. Необходим отдельный hotfix НЕЗАВИСИМО от editor-фичи.
---
## Performance Review -- дополнения по итерациям
> Полный аудит в `plan-architecture.md` секция 19. Здесь -- конкретные performance-требования для каждой итерации.
### Итерация 1: Performance-critical
1. **EditorView lifecycle (CRITICAL):** НЕ использовать `Map<tabId, EditorView>` + CSS show/hide (как описано в plan-architecture секция 6.5). Использовать **EditorState pooling**: `Map<tabId, EditorState>` в useRef + один активный EditorView. При переключении таба: `savedStates.set(oldId, view.state)` -> `view.destroy()` -> `new EditorView({ state: savedStates.get(newId) })`. Паттерн initialState уже используется в CodeMirrorDiffView.tsx (строки 699-705).
2. **readDir лимиты:** MAX_ENTRIES_PER_DIR = 500 (не 10,000). При превышении -- "N more files..." + кнопка "Show all". Только root level при открытии, expand = depth=1 для конкретной папки.
3. **readFile тиерная стратегия:** <256KB мгновенно | 256KB-2MB с progress | 2MB-5MB preview only (100 строк) | >5MB external editor. Детектировать минификацию (строка >10,000 chars) и binary (null bytes в первых 8KB).
4. **Дедупликация IPC:** `Map<string, Promise<ReadFileResult>>` для readFile. Если файл уже загружается -- ждать результат, не создавать новый запрос.
### Итерация 2: Performance-critical
1. **НЕ хранить modified content в Zustand (CRITICAL):** `editorModifiedContents: Record<string, string>` из секции 2.1 plan-architecture -- УБРАТЬ. Контент живёт только в EditorState CodeMirror. В Zustand: `editorModifiedFiles: Set<string>` (только dirty flags). Dirty flag обновляется debounced (300ms) через EditorView.updateListener (паттерн из CodeMirrorDiffView строки 517-527).
2. **Гранулярные Zustand селекторы (обязательно):**
```typescript
const tabList = useStore(s => s.editorOpenTabs, shallow);
const activeId = useStore(s => s.editorActiveTabId);
// FileTreePanel НЕ подписывается ни на content, ни на tabs
// TabBar НЕ подписывается на tree state
```
3. **LRU eviction EditorState:** При >30 states в кеше -- вытеснять oldest, сохраняя `{ content: string, cursorPos: number }` (без undo). При возврате к вытесненному табу -- восстановить через `EditorState.create()`.
### Итерация 3: Performance-medium
1. **Tab closing -- memory cleanup:** При closeTab: `stateCache.delete(tabId)`. При closeAllTabs: `stateCache.clear()`. Явно вызывать -- не полагаться на GC.
2. **Concurrent file operations:** При createFile/deleteFile -- дебаунсить обновление дерева (500ms), не перечитывать после каждой операции.
### Итерация 4: Performance-critical
1. **File tree виртуализация (HIGH):** Перейти на `@tanstack/react-virtual` (уже в проекте -- DateGroupedSessions.tsx, ChatHistory.tsx, NotificationsView.tsx). `flattenTree(tree, expandedDirs) -> FlatNode[]` + `useVirtualizer({ count, estimateSize: () => 28 })`. Рендерить только видимые ноды.
2. **Search in files -- main process:** Запускать в worker_thread или с AbortController (timeout 5s). Limit: 100 результатов, max 1MB на файл. НЕ читать binary файлы для поиска.
3. **Quick Open (Cmd+P):** Кешировать flat file list при открытии editor. НЕ перечитывать на каждое открытие Cmd+P. Invalidate по F5 или file watcher event.
### Итерация 5: Performance-medium
1. **File watcher -- opt-in:** НЕ включать по умолчанию. Toggle "Watch for external changes". По умолчанию -- ручной refresh (F5). При включении: `fs.watch({ recursive: true })` с фильтрацией (node_modules/.git/dist) и debounce 200ms.
2. **Git status -- кеширование:** Результат `git status --porcelain` кешировать на 5 секунд (как в плане). При file watcher event -- invalidate и перечитать.
### Benchmarks для CI/Manual
```
Benchmark 1: EditorView memory
Открыть 25 файлов x 200KB
Измерить: performance.memory.usedJSHeapSize
Порог: < 150MB
Benchmark 2: Tab switch latency
Переключить таб (500KB файл с syntax highlighting)
Измерить: time from click to contentful paint
Порог: < 50ms
Benchmark 3: File tree render
5000+ файлов, все папки раскрыты (с виртуализацией)
Измерить: FPS при скролле
Порог: >= 55fps
Benchmark 4: readDir latency
Директория с 5000 файлами
Измерить: time from click to tree displayed
Порог: < 200ms
Benchmark 5: Keystroke re-renders
React DevTools Profiler при наборе текста
Порог: FileTreePanel и TabBar рендерятся 0 раз
```
---

View file

@ -0,0 +1,723 @@
# Анализ переиспользования кодовой базы для In-App Project Editor
## 1. Переиспользуемые компоненты
### 1.1 ReviewFileTree -- высокий потенциал извлечения
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ReviewFileTree.tsx`
Это самый важный компонент для переиспользования. Внутри него:
- **`buildTree(files)` функция (строки 42-83)** -- построение дерева из плоского списка путей. Алгоритм: разбивает пути по `/`, строит иерархию `TreeNode`, коллапсирует одноуровневые папки (как в VS Code). Это **универсальный** алгоритм, не привязанный к review.
- **`TreeItem` компонент (строки 147-264)** -- рекурсивный рендеринг узла дерева с иконками, отступами, коллапсом папок.
- **`ReviewFileTree` (строки 297-376)** -- корневой компонент с auto-expand и auto-scroll.
**Проблема:** Сейчас `ReviewFileTree` жестко привязан к review-контексту:
- `TreeItem` принимает `hunkDecisions`, `fileDecisions`, `fileChunkCounts`, `viewedSet` -- всё review-специфичное
- `FileStatusIcon` рендерит статусы review (accepted/rejected/mixed/pending)
- Строки +/- в каждом файле (`linesAdded`, `linesRemoved`)
**Рекомендация:** Извлечь **generic FileTree** из `ReviewFileTree`. Структура:
1. Выделить `buildTree()` и `TreeNode` в утилиту `src/renderer/utils/fileTreeBuilder.ts`
2. Создать generic `FileTree` компонент с `renderItem` callback (render-prop для кастомизации правой части каждого файлового элемента)
3. `ReviewFileTree` становится тонкой обёрткой вокруг `FileTree` с review-специфичным `renderItem`
4. `EditorFileTree` -- вторая обёртка для редактора (показывает иконки по типу файла, dirty-маркер)
**Оценка надёжности: 8/10** -- buildTree проверен в продакшене, алгоритм коллапса протестирован.
**Оценка уверенности: 9/10** -- это чистый extract-and-wrap рефакторинг.
### 1.2 CodeMirrorDiffView -- частичное переиспользование
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/CodeMirrorDiffView.tsx`
Этот компонент содержит ценную инфраструктуру:
- **`getSyncLanguageExtension(fileName)` (строки 64-123)** -- маппинг расширений файлов на CodeMirror language extensions. 16+ языков. **Должен быть извлечён в общую утилиту.**
- **`getAsyncLanguageDesc(fileName)` (строки 126-128)** -- async fallback через `@codemirror/language-data`.
- **`diffTheme` (строки 158-283)** -- тема CodeMirror на CSS-переменных. Частично переиспользуема для обычного редактора (базовые стили `.cm-gutters`, `.cm-content`, `.cm-scroller`).
- **`langCompartment` паттерн** -- Compartment для ленивой инжекции языка. Полностью переиспользуем.
- **`buildExtensions()` (строки 477-688)** -- настройка расширений. Для редактора нужна упрощённая версия (без merge/diff, без hunk navigation).
**Что НЕ переиспользуется:** Вся diff/merge логика (`unifiedMergeView`, `mergeCompartment`, chunk navigation, merge toolbar) -- это 60%+ кода компонента.
**Рекомендация:** Создать `CodeMirrorEditor` компонент (без diff) рядом или вместо fork'а `CodeMirrorDiffView`:
1. Извлечь `getLanguageExtension()` в `src/renderer/utils/codemirrorLanguages.ts`
2. Извлечь базовую тему в `src/renderer/utils/codemirrorTheme.ts`
3. Новый `CodeMirrorEditor` использует эти утилиты + `@codemirror/autocomplete` (уже в `package.json`!)
**Оценка надёжности: 7/10** -- ядро проверено, но отделение от diff-логики требует внимания.
**Оценка уверенности: 8/10** -- чётко понятно что извлекать.
### 1.3 ChangeReviewDialog -- паттерн layout
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ChangeReviewDialog.tsx`
Это **полноэкранный overlay** (не Radix Dialog!). Паттерн (строки 507-676):
```
fixed inset-0 z-50 flex flex-col bg-surface
├── Header (border-b, bg-surface-sidebar, macOS traffic-light padding)
├── Toolbar (border-b)
└── Content (flex flex-1 overflow-hidden)
├── Sidebar (w-64, overflow-y-auto, border-r, bg-surface-sidebar)
└── Main content area (flex-1)
```
**Что переиспользуется:**
- Layout паттерн: header + sidebar + content
- macOS traffic-light padding (`--macos-traffic-light-padding-left`, `WebkitAppRegion: 'drag'`)
- Escape-to-close (строки 346-353)
- Loading/Error/Empty states (строки 586-673)
**Рекомендация:** Создать `FullScreenPanel` layout-компонент, который предоставляет:
- Header slot с macOS-safe padding
- Optional sidebar slot
- Content slot
- Escape-to-close behaviour
- Loading/Error/Empty state handling
Или проще -- просто скопировать layout-паттерн в `ProjectEditor`, а рефакторить в общий компонент потом.
**Оценка надёжности: 7/10**
**Оценка уверенности: 7/10** -- зависит от того, насколько сильно отличается layout редактора.
### 1.4 DiffErrorBoundary -- прямое переиспользование
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/DiffErrorBoundary.tsx`
Специализированный error boundary для diff-view. Нужен **аналогичный** для CodeMirror editor. Можно обобщить:
- Переименовать в `EditorErrorBoundary`
- Убрать diff-специфичные пропы (`oldString`, `newString`)
- Добавить generic error info display
**Оценка надёжности: 9/10**
**Оценка уверенности: 9/10**
### 1.5 UI примитивы
Прямое переиспользование без изменений:
| Компонент | Путь | Применение |
|-----------|------|-----------|
| `ErrorBoundary` | `src/renderer/components/common/ErrorBoundary.tsx` | Обёртка всего редактора |
| `CopyablePath` | `src/renderer/components/common/CopyablePath.tsx` | Путь к файлу в header |
| `CopyButton` | `src/renderer/components/common/CopyButton.tsx` | Копирование содержимого |
| `ConfirmDialog` | `src/renderer/components/common/ConfirmDialog.tsx` | "Save before close?" |
| `Tooltip` | `src/renderer/components/ui/tooltip.tsx` | Тултипы на кнопках toolbar |
| `Button` | `src/renderer/components/ui/button.tsx` | Кнопки toolbar |
| `Dialog` | `src/renderer/components/ui/dialog.tsx` | Мелкие модалки (settings) |
| `Tabs` | `src/renderer/components/ui/tabs.tsx` | Табы открытых файлов |
### 1.6 Компоненты review, которые НЕ стоит переиспользовать
- `ReviewToolbar` -- слишком review-специфичен (accept/reject/apply counters)
- `ContinuousScrollView` -- scroll-spy для diff-review, не подходит для редактора
- `FileSectionDiff` / `FileSectionHeader` -- привязаны к diff workflow
- `ViewedProgressBar` -- review-only
- `ConflictDialog` -- review-only
---
## 2. Существующие IPC каналы
### 2.1 Уже есть -- файловые операции
| Канал | Файл | Что делает | Применимость |
|-------|------|-----------|-------------|
| `review:saveEditedFile` | `src/main/ipc/review.ts` | Сохраняет файл на диск (`filePath`, `content`) | **УЯЗВИМОСТЬ: нет валидации пути!** НЕ переиспользовать без исправления (см. SEC-11). Для editor -- отдельный канал с валидацией |
| `review:getFileContent` | `src/main/ipc/review.ts` | Читает файл + original + modified | Частично -- нужна упрощённая версия |
| `read-mentioned-file` | `src/main/ipc/utility.ts` | Читает файл по абсолютному пути с валидацией | Можно использовать, но ограничен `maxTokens` |
| `shell:openPath` | `src/main/ipc/utility.ts` | Открывает файл в системном приложении | "Open in external editor" |
| `shell:showInFolder` | `src/main/ipc/utility.ts` | Показывает файл в Finder | "Reveal in Finder" |
### 2.2 Чего НЕТ -- нужно создать
Для полноценного редактора проекта нужны **новые IPC каналы**:
1. **`editor:listDirectory(dirPath)`** -- рекурсивный listing файлов (с ignore-паттернами: `.git`, `node_modules`, etc.)
2. **`editor:readFile(filePath)`** -- чтение файла без ограничений `maxTokens` (в отличие от `read-mentioned-file`)
3. **`editor:saveFile(filePath, content)`** -- можно переиспользовать `review:saveEditedFile`, но лучше отдельный канал с более широкой валидацией
4. **`editor:createFile(filePath, content?)`** -- создание нового файла
5. **`editor:deleteFile(filePath)`** -- удаление файла (с `confirm` на renderer стороне)
6. **`editor:renameFile(oldPath, newPath)`** -- переименование
7. **`editor:watchDirectory(dirPath)`** -- подписка на изменения в директории (для обновления file tree)
**Паттерн регистрации** (из `src/main/ipc/review.ts`):
```typescript
// Module-level state + guard
let service: EditorService | null = null;
function getService(): EditorService { ... }
// Forward-compatible config object
export interface EditorHandlerDeps { ... }
export function initializeEditorHandlers(deps: EditorHandlerDeps): void { ... }
export function registerEditorHandlers(ipcMain: IpcMain): void { ... }
export function removeEditorHandlers(ipcMain: IpcMain): void { ... }
```
**Каналы в `ipcChannels.ts`** -- плоские `export const`, НЕ объект (подтверждено в MEMORY.md).
**Оценка надёжности: 8/10** -- паттерн отработан на 20+ модулях.
**Оценка уверенности: 9/10**
---
## 3. Zustand-паттерн для Editor Slice
### 3.1 Существующий паттерн slice'ов
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/types.ts`
18 slice'ов, объединённых через intersection type. Каждый slice:
```typescript
export interface SomeSlice {
// Data
someData: T[];
selectedId: string | null;
loading: boolean;
error: string | null;
// Actions
fetchData: () => Promise<void>;
selectItem: (id: string | null) => void;
}
export const createSomeSlice: StateCreator<AppState, [], [], SomeSlice> = (set, get) => (
// initial state + actions
});
```
### 3.2 Рекомендуемая структура EditorSlice
```typescript
export interface EditorSlice {
// State
editorProjectPath: string | null; // Текущий проект
editorFileTree: FileTreeNode[]; // Дерево файлов
editorFileTreeLoading: boolean;
editorOpenFiles: OpenFile[]; // Открытые файлы (табы)
editorActiveFilePath: string | null; // Активный файл
editorDirtyFiles: Set<string>; // Файлы с несохранёнными изменениями
editorError: string | null;
// File content cache (path -> content)
editorFileContents: Record<string, string>;
editorFileContentsLoading: Record<string, boolean>;
// Actions
openEditor: (projectPath: string) => Promise<void>;
closeEditor: () => void;
loadFileTree: (dirPath: string) => Promise<void>;
openFile: (filePath: string) => Promise<void>;
closeFile: (filePath: string) => void;
setActiveFile: (filePath: string) => void;
updateFileContent: (filePath: string, content: string) => void;
saveFile: (filePath: string) => Promise<void>;
saveAllDirty: () => Promise<void>;
}
```
**Важно:** Следовать правилу из CLAUDE.md -- "Store over Props": дочерние компоненты читают из store напрямую через `useStore()`.
**Куда добавить:**
1. `src/renderer/store/slices/editorSlice.ts` -- новый slice
2. Добавить `EditorSlice` в `AppState` type в `types.ts`
3. Добавить `...createEditorSlice(...args)` в `store/index.ts`
**Оценка надёжности: 9/10**
**Оценка уверенности: 9/10**
### 3.3 Ближайший аналог -- `changeReviewSlice`
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/changeReviewSlice.ts`
Этот slice ближе всего к будущему `editorSlice`:
- `fileContents: Record<string, FileChangeWithContent>` -- кеш содержимого файлов
- `fileContentsLoading: Record<string, boolean>` -- состояние загрузки per-file
- `editedContents: Record<string, string>` -- несохранённые изменения
- `saveEditedFile(filePath)` -- сохранение на диск
- `discardFileEdits(filePath)` -- отмена изменений
- Debounced persistence
---
## 4. CSS/Theme -- переиспользование
### 4.1 Существующие CSS-переменные
**Файл:** `/Users/belief/dev/projects/claude/claude_team/src/renderer/index.css`
Полностью подходят для редактора:
| Категория | Переменные | Применение в редакторе |
|-----------|-----------|----------------------|
| Surfaces | `--color-surface`, `--color-surface-raised`, `--color-surface-sidebar` | Фон редактора, sidebar, header |
| Borders | `--color-border`, `--color-border-subtle`, `--color-border-emphasis` | Разделители панелей |
| Text | `--color-text`, `--color-text-secondary`, `--color-text-muted` | Текст в file tree, status bar |
| Code | `--code-bg`, `--code-border`, `--code-line-number`, `--code-filename` | Фон редактора, номера строк |
| Syntax | `--syntax-string`, `--syntax-comment`, `--syntax-keyword` и т.д. | Подсветка синтаксиса |
| Inline code | `--inline-code-bg`, `--inline-code-text` | Инлайн код в markdown |
| Scrollbar | `--scrollbar-thumb`, `--scrollbar-thumb-hover` | Скроллбар в file tree |
| Card | `--card-bg`, `--card-border`, `--card-header-bg` | Панели, headers |
| Skeleton | `--skeleton-base`, `--skeleton-base-light` | Loading state |
### 4.2 Тема CodeMirror
`diffTheme` в `CodeMirrorDiffView.tsx` (строки 158-283) уже использует CSS-переменные:
```typescript
'&': {
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, SFMono-Regular, ...',
fontSize: '13px',
},
'.cm-gutters': {
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
...
}
```
Нужно извлечь **базовую тему** (без diff-стилей `.cm-changedLine`, `.cm-deletedChunk` и т.д.) -- примерно 40% от текущей темы.
### 4.3 Light theme
Поддержка есть через `:root.light` override'ы в `index.css`. Если `diffTheme` использует CSS-переменные (а он использует), то light theme заработает автоматически.
---
## 5. CodeMirror vs ProseMirror
### 5.1 CodeMirror 6 -- уже в проекте
**Из `package.json`:**
```json
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp", "@codemirror/lang-css", "@codemirror/lang-go",
"@codemirror/lang-html", "@codemirror/lang-java", "@codemirror/lang-javascript",
"@codemirror/lang-json", "@codemirror/lang-less", "@codemirror/lang-markdown",
"@codemirror/lang-php", "@codemirror/lang-python", "@codemirror/lang-rust",
"@codemirror/lang-sass", "@codemirror/lang-sql", "@codemirror/lang-xml",
"@codemirror/lang-yaml",
"@codemirror/language", "@codemirror/language-data",
"@codemirror/merge", "@codemirror/state",
"@codemirror/theme-one-dark", "@codemirror/view"
```
Это **16 языковых пакетов** + `@codemirror/language-data` (ещё ~30 языков async). Плюс `@codemirror/autocomplete` уже установлен.
### 5.2 Рекомендация: ТОЛЬКО CodeMirror 6
**Однозначно CodeMirror 6, НЕ ProseMirror.** Причины:
1. **Уже 20+ пакетов CodeMirror в зависимостях** -- нулевой overhead по bundle size
2. **Работающая инфраструктура**: `getSyncLanguageExtension()`, `getAsyncLanguageDesc()`, тема, Compartment-паттерн -- всё протестировано в production
3. **`@codemirror/autocomplete`** уже установлен -- автодополнение из коробки
4. **CodeMirror = код-редактор**, ProseMirror = rich text / WYSIWYG. Для проектного редактора нужен именно код-редактор
5. **ProseMirror добавил бы ~150-200KB** в bundle + совершенно новая экосистема плагинов
**Не нужно добавлять НИКАКИХ новых зависимостей** для базового редактора. Всё есть.
**Оценка надёжности: 10/10** -- CodeMirror 6 зрелый, используется в VSCode, Chrome DevTools
**Оценка уверенности: 10/10** -- ProseMirror для code editing = антипаттерн
---
## 6. Anti-patterns и риски
### 6.1 Размер компонентов
**Проблема:** `ChangeReviewDialog.tsx` -- **677 строк**. `CodeMirrorDiffView.tsx` -- **809 строк**. Оба на грани допустимого.
**Рекомендация для Editor:**
- `ProjectEditor.tsx` -- max 150 строк (layout shell, делегирует всё дочерним)
- `EditorFileTree.tsx` -- max 200 строк
- `EditorTabBar.tsx` -- max 100 строк
- `EditorCodePane.tsx` -- max 150 строк (обёртка вокруг CodeMirror)
- `EditorToolbar.tsx` -- max 100 строк
- Хуки (`useEditorKeyboard`, `useEditorFileOps`) -- по 50-100 строк
### 6.2 Performance с большими файлами
**Проблема:** CodeMirror 6 virtual scrolling работает, но:
- Файлы >5MB могут замедлить парсинг языка
- `readFile` через IPC сериализует содержимое как JSON string -- большие файлы замедляют IPC
**Рекомендация:**
- Лимит чтения: ~2MB (показывать "File too large, open externally")
- `EditorView.scrollPastEnd` -- чтобы пользователь мог скроллить ниже конца файла
- Lazy language loading через Compartment (уже реализовано в `CodeMirrorDiffView`)
### 6.3 Dirty state и unsaved changes
**Проблема:** `changeReviewSlice` хранит `editedContents` как `Record<string, string>` -- весь контент файла в памяти per-dirty-file. При 10+ грязных файлах это может быть гигабайт RAM.
**Рекомендация:**
- Хранить ТОЛЬКО для активного файла + 2-3 соседних табов (LRU cache)
- Для остальных -- хранить `EditorState` объект CodeMirror (он уже в памяти CM)
- При переключении табов -- сохранять `EditorState` (включая undo history), не строку
### 6.4 File watching race conditions
**Проблема:** Если пользователь редактирует файл в нашем редакторе, а CLI-агент одновременно меняет его через `review:saveEditedFile` -- конфликт.
**Рекомендация:**
- `mtime` check перед записью (как `review:checkConflict`)
- Уведомление "File changed on disk" с выбором (reload / keep mine / show diff)
### 6.5 Missing error boundaries
**Проблема:** `ErrorBoundary` в `common/` -- один на всё приложение. `DiffErrorBoundary` -- только для diff. Если CodeMirror крашится в editor mode, нужен отдельный boundary.
**Рекомендация:** Обернуть `CodeMirrorEditor` в специализированный `EditorErrorBoundary` (можно обобщить `DiffErrorBoundary`).
### 6.6 IPC parameter validation
**Проблема (CRITICAL):** В `review.ts` IPC handler `handleSaveEditedFile` **НЕ валидирует путь** -- прямой `writeFile()` без `validateFilePath()`. Это существующая уязвимость (см. секцию 10.3).
**Рекомендация:**
- **ВСЕ** IPC handlers, работающие с файлами, ОБЯЗАНЫ вызывать `validateFilePath()` из `src/main/utils/pathValidation.ts`
- Для editor: выделенный module-level `activeProjectRoot`, не принимаемый от renderer при каждом вызове
- Дополнительно: `validateFileName()` для создания файлов, `isDevicePath()` для блокировки device files, запрет записи в `.git/`
- Подробный чеклист -- в `plan-architecture.md` секция 18
---
## 7. Итоговая архитектурная рекомендация
### Что ИЗВЛЕЧЬ из существующего кода (рефакторинг):
1. `buildTree()` + `TreeNode` --> `src/renderer/utils/fileTreeBuilder.ts`
2. `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` --> `src/renderer/utils/codemirrorLanguages.ts`
3. Базовая CM тема (без diff) --> `src/renderer/utils/codemirrorTheme.ts`
4. `ReviewFileTree` --> generic `FileTree` + `ReviewFileTree` wrapper
### Что СОЗДАТЬ с нуля:
1. `src/renderer/store/slices/editorSlice.ts`
2. `src/main/ipc/editor.ts` + handler'ы
3. `src/preload/constants/ipcChannels.ts` -- добавить `EDITOR_*` каналы
4. `src/preload/index.ts` -- добавить `editor` API
5. `src/renderer/components/editor/` -- компоненты редактора
6. `src/main/services/editor/EditorService.ts` -- сервис файловых операций
### Что ПЕРЕИСПОЛЬЗОВАТЬ напрямую:
- Все UI примитивы из `components/ui/`
- `ErrorBoundary`, `ConfirmDialog`, `CopyablePath`, `CopyButton`
- CSS-переменные (100% готовы)
- CodeMirror 6 пакеты (все 20+ уже в зависимостях)
- `wrapHandler<T>()` паттерн для IPC
- Zustand slice pattern
---
## 8. Архитектурная ревизия: дополнения к reuse-анализу
> Добавлено после ревизии. Конкретизирует что именно извлекать и как.
### 8.1 Обязательные рефакторинги перед реализацией
Эти рефакторинги -- не optional. Без них будет дублирование кода, нарушающее DRY:
| Что извлечь | Откуда | Куда | Строки |
|-------------|--------|------|--------|
| `buildTree()` + `collapse()` + сортировка | `ReviewFileTree.tsx:42-83` | `src/renderer/utils/fileTreeBuilder.ts` | ~50 LOC |
| `getSyncLanguageExtension()` + `getAsyncLanguageDesc()` | `CodeMirrorDiffView.tsx:64-128` | `src/renderer/utils/codemirrorLanguages.ts` | ~70 LOC |
| Базовая тема CM (без diff-стилей) | `CodeMirrorDiffView.tsx:158-198` | `src/renderer/utils/codemirrorTheme.ts` | ~40 LOC |
| `wrapReviewHandler<T>()` | `review.ts:133-145` | `src/main/ipc/ipcWrapper.ts` | ~15 LOC |
**Порядок:** Рефакторинги 1-4 выполняются ПЕРЕД написанием нового кода итерации 1.
`ReviewFileTree.tsx` и `CodeMirrorDiffView.tsx` начинают импортировать из новых утилит.
Тесты этих компонентов должны продолжать проходить (zero behavior change).
### 8.2 Расхождения между файлами планов (исправлены)
| Расхождение | plan-architecture.md | plan-iterations.md | Решение |
|-------------|---------------------|-------------------|---------|
| Имя сервиса | `FileEditorService` | `ProjectFileService` | `ProjectFileService` |
| Stateful/Stateless | `constructor(rootPath)` | Не указано | Stateless, `projectRoot` как аргумент |
| Security | Свой `assertInsideRoot()` | `validateFilePath()` | `validateFilePath()` из `pathValidation.ts` |
| editorSlice в итерации 1 | Да | Нет (хук `useEditorState`) | Нет slice в итерации 1, useState достаточно |
| `useEditorState.ts` хук | Не упомянут | Создаётся в итерации 2 | Убран, вся логика в slice |
| Overlay name | `CodeEditorOverlay` | `ProjectEditorOverlay` | `ProjectEditorOverlay` (лучше отражает scope) |
### 8.3 Review FileTree: конкретный план generic extraction
Текущий `ReviewFileTree.tsx` (~377 строк) содержит:
- `TreeNode` тип -- generic (name, fullPath, isFile, children, file?)
- `buildTree()` -- generic (принимает `files` с `.relativePath`)
- `collapse()` -- generic (одноуровневый collapse)
- `TreeItem` -- review-specific (FileStatusIcon, +/- lines, viewedSet, hunkDecisions)
- `getFileStatus()` -- review-specific
- `ReviewFileTree` -- review-specific (reads from store: hunkDecisions, fileDecisions)
**Plan для generic `FileTree`:**
```
src/renderer/utils/fileTreeBuilder.ts:
- export type TreeNode<T = unknown> = { name, fullPath, isFile, data?: T, children }
- export function buildTree<T>(items: T[], getRelativePath: (item: T) => string): TreeNode<T>[]
- export function sortTreeNodes<T>(nodes: TreeNode<T>[]): TreeNode<T>[]
src/renderer/components/common/FileTree.tsx:
- Generic FileTree<T> component
- Props: nodes, activeNodePath, onNodeClick, renderNodeExtra?, renderNodeIcon?
- Internal: TreeItem (renders folder/file, delegation через render-props)
- Handles: collapsedFolders, toggleFolder, auto-expand ancestors, auto-scroll
src/renderer/components/team/review/ReviewFileTree.tsx:
- Thin wrapper around FileTree<FileChangeSummary>
- Provides renderNodeExtra with FileStatusIcon + +/- lines
- Reads hunkDecisions/fileDecisions from store
src/renderer/components/team/editor/EditorFileTree.tsx:
- Thin wrapper around FileTree<FileTreeEntry>
- Provides renderNodeExtra with dirty marker
- Provides renderNodeIcon with file type icons
- Context menu integration
```
### 8.4 SOLID compliance checklist
- [x] SRP: FileTreePanel -- UI only, data loading in slice
- [x] SRP: CodeMirrorEditor -- lifecycle only, extensions in builder
- [x] OCP: FileTree -- generic with render-props
- [x] LSP: FileTreeNode extends FileTreeEntry (no field duplication)
- [x] ISP: EditorSlice split into 4 documented groups
- [x] DIP: Extensions via factory, not hardcoded in component
- [x] DRY: buildTree, language detection, theme, wrapHandler -- all extracted
- [x] Clean Architecture: dependency flow verified, no backward deps
---
---
## 9. UX Review: дополнения к reuse-анализу
> Добавлено после UX-ревью. Что ещё нужно переиспользовать/создать для качественного UX.
### 9.1 Дополнительные компоненты для переиспользования
| Компонент | Путь | Применение в редакторе |
|-----------|------|----------------------|
| `KeyboardShortcutsHelp` | `review/KeyboardShortcutsHelp.tsx` | Модальное окно со списком shortcuts (кнопка `?` в header) |
| `confirm()` imperative API | `common/ConfirmDialog.tsx` | "Save before close?" при Escape с unsaved changes |
### 9.2 Новые утилиты, вызванные UX-требованиями
| Утилита | Путь | Зачем |
|---------|------|-------|
| `tabLabelDisambiguation.ts` | `src/renderer/utils/` | Показ "(main/utils)" для дублей `index.ts` в табах |
| `binaryDetector.ts` | `src/main/utils/` | Определение бинарных файлов (null bytes в первых 8KB) |
### 9.3 Новые компоненты, вызванные UX-требованиями
| Компонент | Описание |
|-----------|----------|
| `EditorStatusBar.tsx` | Нижняя полоска: Ln:Col, язык, отступы, кодировка |
| `EditorBinaryState.tsx` | Заглушка для бинарных файлов вместо CM6 |
| `EditorErrorState.tsx` | Заглушка для файлов с ошибкой чтения (EACCES, ENOENT) |
| `EditorShortcutsHelp.tsx` | Модальное окно shortcuts (или переиспользовать `KeyboardShortcutsHelp`) |
### 9.4 CSS-переменные -- что уже есть, чего не хватает
**Уже есть (полностью достаточно):**
- `--color-surface`, `--color-surface-sidebar`, `--color-surface-raised` -- для background
- `--color-border`, `--color-border-subtle`, `--color-border-emphasis` -- для разделителей
- `--color-text`, `--color-text-secondary`, `--color-text-muted` -- для текста
- `--code-*`, `--syntax-*` -- для CodeMirror
- `--scrollbar-*` -- для скроллбара
- `--card-*` -- для панелей
**Не хватает (рекомендация: добавить в `:root` в `index.css`):**
```css
/* Editor-specific */
--editor-tab-active-bg: var(--color-surface);
--editor-tab-inactive-bg: var(--color-surface-sidebar);
--editor-tab-modified-dot: #f59e0b; /* amber для modified indicator */
--editor-tab-border: var(--color-border);
--editor-statusbar-bg: var(--color-surface-sidebar);
--editor-statusbar-text: var(--color-text-muted);
--editor-sidebar-resize-handle: rgba(148, 163, 184, 0.15);
--editor-sidebar-resize-handle-hover: rgba(148, 163, 184, 0.3);
```
Это обеспечит консистентность с остальными CSS-переменными проекта и лёгкую кастомизацию.
### 9.5 Accessibility -- что переиспользовать из существующего
`ReviewFileTree.tsx` (строка 232) имеет `aria-label` на expand/collapse. Это МИНИМУМ. При извлечении generic `FileTree` нужно сразу добавить:
- `role="tree"` на корневой `<ul>`
- `role="treeitem"` + `aria-expanded` на каждой папке
- `role="group"` на вложенных `<ul>`
- `role="treeitem"` + `aria-selected` на файлах
- Keyboard navigation (arrow keys) -- в `FileTree`, не в обёртках
Это не "nice to have" -- это требование WCAG 2.1 Level A для tree view.
---
---
## 10. Security Review: дополнения к reuse-анализу
> Полный аудит безопасности описан в `plan-architecture.md` секция 18. Здесь -- что из существующего кода переиспользовать для безопасности, и обнаруженные проблемы в текущем коде.
### 10.1 Переиспользуемые security-утилиты
| Утилита | Путь | Что делает | Как использовать |
|---------|------|-----------|-----------------|
| `validateFilePath()` | `src/main/utils/pathValidation.ts` | Path traversal, symlink escape, sensitive patterns | КАЖДЫЙ IPC handler ОБЯЗАН вызывать |
| `SENSITIVE_PATTERNS` | `src/main/utils/pathValidation.ts` | Regex-массив: `.env`, `.ssh`, `*.key`, `*.pem` и т.д. | Автоматически через `validateFilePath()` |
| `resolveRealPathIfExists()` | `src/main/utils/pathValidation.ts` | `fs.realpathSync.native()` с обработкой ENOENT | Автоматически через `validateFilePath()` |
| `isPathWithinAllowedDirectories()` | `src/main/utils/pathValidation.ts` | Containment check с cross-platform support | Автоматически через `validateFilePath()` |
| `isPathContained()` | `src/main/ipc/validation.ts` | Простая containment check (normalize + startsWith) | НЕ использовать отдельно -- `validateFilePath` полнее |
### 10.2 Чего НЕ хватает в существующих утилитах (нужно создать для editor)
| Утилита | Описание | Зачем |
|---------|----------|-------|
| `validateFileName(name)` | Валидация имени файла при создании | Запрет `.`, `..`, control chars, path separators, NUL, length > 255 |
| `isDevicePath(path)` | Проверка на `/dev/`, `/proc/`, `/sys/` | Блокировка device files до `fs.readFile()` |
| `isGitInternalPath(path)` | Проверка на `.git/` в пути | Запрет записи в `.git/` (чтение -- ОК) |
| `atomicWriteFile(path, content)` | Atomic write через tmp + rename | Защита от corrupt при crash/disk full |
Рекомендация: добавить в `src/main/utils/pathValidation.ts` (validateFileName, isDevicePath, isGitInternalPath) и `src/main/utils/atomicWrite.ts` (atomicWriteFile).
### 10.3 Обнаруженная уязвимость в review.ts (Critical, existing!)
**При анализе `review.ts` (секция 2.1 reuse-анализа) обнаружена уязвимость:**
`handleSaveEditedFile` (строка 254 `review.ts`) принимает `filePath` от renderer и передаёт в `ReviewApplierService.saveEditedFile()` (строка 320 `ReviewApplierService.ts`), который вызывает `writeFile(filePath, content, 'utf8')` **БЕЗ КАКОЙ-ЛИБО ВАЛИДАЦИИ ПУТИ**.
Текущий код:
```typescript
// review.ts:254
async function handleSaveEditedFile(_event, filePath, content) {
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
// УЯЗВИМОСТЬ: filePath НЕ проверяется через validateFilePath()
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(filePath, content);
// ...
});
}
// ReviewApplierService.ts:320
async saveEditedFile(filePath: string, content: string) {
// УЯЗВИМОСТЬ: прямая запись без валидации
await writeFile(filePath, content, 'utf8');
return { success: true };
}
```
**Импакт**: Скомпрометированный renderer может записать произвольный файл куда угодно в ФС.
**Решение**: Добавить `validateFilePath(filePath, projectRoot)` в `handleSaveEditedFile`. Нужен hotfix НЕЗАВИСИМО от editor-фичи.
### 10.4 Security-паттерн для editor IPC (обязательный)
```typescript
// src/main/ipc/editor.ts -- каждый handler ОБЯЗАН следовать этому паттерну:
let activeProjectRoot: string | null = null; // module-level, set by editor:open
async function handleEditorReadFile(
_event: IpcMainInvokeEvent,
filePath: string // от renderer
): Promise<IpcResult<ReadFileResult>> {
return wrapHandler('readFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, activeProjectRoot);
if (!validation.valid) throw new Error(validation.error!);
// 2. Device path block
if (isDevicePath(validation.normalizedPath!)) throw new Error('Device files blocked');
// 3. File type check
const stats = await fs.lstat(validation.normalizedPath!);
if (!stats.isFile()) throw new Error('Not a regular file');
// 4. Size check
if (stats.size > MAX_FILE_SIZE) throw new Error('File too large');
// 5. Read
const content = await fs.readFile(validation.normalizedPath!, 'utf8');
// 6. Post-read TOCTOU verify
const realPath = await fs.realpath(validation.normalizedPath!);
const postValidation = validateFilePath(realPath, activeProjectRoot);
if (!postValidation.valid) throw new Error('Path changed during read');
return { content, size: stats.size, truncated: false, encoding: 'utf-8' };
});
}
```
---
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/ReviewFileTree.tsx` - FileTree logic to extract (buildTree algorithm, TreeNode type, collapse/expand)
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/review/CodeMirrorDiffView.tsx` - CodeMirror infrastructure to extract (language detection, theme, Compartment pattern)
- `/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/changeReviewSlice.ts` - Pattern to follow for editorSlice (fileContents cache, editedContents, saveEditedFile)
- `/Users/belief/dev/projects/claude/claude_team/src/main/ipc/review.ts` - IPC handler pattern to follow (wrapHandler, module-level state, deps injection) + EXISTING VULNERABILITY in saveEditedFile
- `/Users/belief/dev/projects/claude/claude_team/src/main/utils/pathValidation.ts` - Security validation to REUSE (not rewrite) -- validateFilePath, SENSITIVE_PATTERNS, symlink resolution
---
## Performance-Critical Reuse Notes
> Дополнение после Performance Review (plan-architecture.md секция 19). Конкретные performance-аспекты при переиспользовании кода.
### CodeMirrorDiffView -- что НЕ копировать
**`editorViewMapRef` из ChangeReviewDialog (строка 91)** хранит `Map<string, EditorView>` для всех видимых файлов в continuous scroll view. Это допустимо для review (10-50 файлов одновременно), но **НЕДОПУСТИМО** для editor с 20+ табами.
Для editor использовать **EditorState pooling**:
```typescript
// ПРАВИЛЬНО для editor:
const stateCache = useRef(new Map<string, EditorState>());
const viewRef = useRef<EditorView | null>(null);
// При переключении таба:
stateCache.current.set(oldTabId, viewRef.current!.state);
viewRef.current!.destroy();
viewRef.current = new EditorView({
state: stateCache.current.get(newTabId)!,
parent: containerRef.current!,
});
```
Паттерн `initialState` из CodeMirrorDiffView (строка 56, 699-705) -- это именно то, что нужно.
### changeReviewSlice -- что НЕ копировать
**`editedContents: Record<string, string>`** (строка 74) хранит полный текст каждого редактированного файла в Zustand. В review это терпимо (изменения применяются и сбрасываются). Для editor каждый keystroke вызывает `set()` с новым Record -- все Zustand-подписчики перерисовываются.
Для editor **контент живёт только в EditorState**, не в Zustand. В store хранить:
```typescript
editorModifiedFiles: Set<string> // dirty flags, не содержимое
```
### @tanstack/react-virtual -- использовать для FileTree
Уже в проекте. Примеры:
- `DateGroupedSessions.tsx` -- виртуализация списка сессий
- `ChatHistory.tsx` -- виртуализация чата
- `NotificationsView.tsx` -- виртуализация уведомлений
Для FileTree (итерация 4): `flattenTree() -> FlatNode[]` + `useVirtualizer()`.
### MembersJsonEditor -- правильный lifecycle паттерн
`MembersJsonEditor.tsx` (строки 27-73) -- **образцовый** паттерн для editor:
1. `EditorState.create()` с extensions
2. `new EditorView({ state, parent })` -- один раз при mount
3. `view.destroy()` -- в cleanup useEffect
4. Обновление doc через `view.dispatch({ changes: ... })` -- при prop change
5. `onChangeRef.current = onChange` -- для callback без re-create view
Этот паттерн масштабировать до EditorState pooling (Map вместо одного state).

View file

@ -0,0 +1,147 @@
# Требуемый ресёрч
> Зафиксировано после 4-агентного ревью плана. Каждый пункт — пробел в знаниях, без закрытия которого реализация рискованна.
## R1: Scope isolation горячих клавиш
**Проблема**: 6 из 12 шорткатов (`Cmd+W`, `Cmd+B`, `Cmd+F`, `Cmd+Shift+[/]`, `Ctrl+Tab`) конфликтуют с глобальными в `useKeyboardShortcuts.ts`.
**Нужно выяснить**:
- Как CM6 keymaps (через `keymap.of()`) взаимодействуют с глобальным `window.addEventListener('keydown')`?
- Останавливает ли CM6 propagation события?
- Какой паттерн используют VS Code / другие Electron-editors для scope isolation?
- Варианты: guard `isEditorOpen` в глобальном хуке? KeyboardEvent stack? Priority system?
**Статус**: COMPLETED
**Результат**:
- Глобальный handler в `useKeyboardShortcuts.ts` использует `window.addEventListener('keydown')` в bubble phase (строка 278)
- CM6 использует bubble phase на `.cm-content`, вызывает `preventDefault()` но НЕ `stopPropagation()` по умолчанию
- CM6 поддерживает `stopPropagation: true` опцию per keybinding
- `ChangeReviewDialog` уже использует capture-phase handler с guard (строки 379-408)
- **Рекомендация: Approach A** — Guard в глобальном handler с флагом `editorOverlayOpen` в store (~20-30 LOC, надёжность 8/10)
- В `useKeyboardShortcuts.ts`: `const editorOpen = useStore(s => s.editorProjectPath !== null);` → early return для конфликтующих shortcuts
- Плюс: добавить `stopPropagation: true` к CM6 keybindings как safety net
- Конкретные конфликты: `Cmd+W` (строка 155), `Cmd+B` (271), `Cmd+F` (241), `Cmd+Shift+[/]` (177), `Ctrl+Tab` (81)
---
## R2: CM6 Compartment + EditorState pooling
**Проблема**: План хранит Compartments в `useRef` (один экземпляр) и использует для 30+ EditorState в пуле. CM6 может не поддерживать sharing одного Compartment между разными EditorState.
**Нужно выяснить**:
- Документация CM6: привязан ли Compartment к конкретному EditorState?
- Что происходит при `compartment.of(X)` в extensions для разных EditorState?
- Что происходит при `dispatch({ effects: compartment.reconfigure(Y) })` если другой state в кэше использует тот же Compartment?
- Паттерн из CodeMirrorDiffView: один View + один State — работает, но не pooling.
- Альтернатива: Compartment-per-state в Map (рядом с EditorState).
**Статус**: COMPLETED
**Результат**:
- **Compartment — opaque identity token** без внутреннего state. Подтверждено Marijn Haverbeke (автор CM6): "Compartments can be shared without issue"
- Каждый EditorState имеет свой `Map<Compartment, Extension>` в config
- `reconfigure()` на одном View не влияет на cached states в пуле
- **Рекомендация: Option A** — Общие Compartment-инстансы для всех states (надёжность 9/10)
- useRef Compartments безопасны для sharing: `readOnlyCompartment.current.of(...)` в extensions для каждого нового EditorState
- При unmount+remount: кешированные states ссылаются на старые Compartments → при remount создать новые Compartments, заново создать EditorState для активного таба
- LRU eviction теряет undo history (ожидаемо), cursor сохраняется через EditorSelection
---
## R3: Store ↔ Component ref bridge (closeEditor + saveAllFiles)
**Проблема**: `closeEditor()` и `saveAllFiles()` в Zustand action должны работать с `stateCache` и `viewRef` из useRef компонента CodeMirrorEditor. Zustand actions не имеют доступа к React refs.
**Нужно выяснить**:
- Как существующий код решает аналогичные проблемы (например, terminal cleanup)?
- Варианты: (a) global ref registry; (b) store хранит cleanup callback через `registerCleanup(fn)`; (c) компонент слушает `editorProjectPath === null` в useEffect и делает cleanup сам; (d) zustand subscribe в компоненте.
- Какой вариант минимально инвазивен и надёжен при unmount?
**Статус**: COMPLETED
**Результат**:
Существующие паттерны в кодовой базе:
- `MembersJsonEditor` — компонент владеет lifecycle полностью
- `CodeMirrorDiffView` — внешний ref holder
- `changeReviewSlice` — module-level state (строки 5-12)
- `ConfirmDialog` — singleton-регистрация с module-level `globalSetState`
- `ChangeReviewDialog` — компонент оркестрирует
**Рекомендация: Hybrid C+D** — `editorBridge.ts` module-level singleton (надёжность 9/10):
```typescript
// src/renderer/utils/editorBridge.ts (module-level)
let stateCache: Map<string, EditorState> | null = null;
let scrollTopCache: Map<string, number> | null = null;
let viewRef: EditorView | null = null;
export const editorBridge = {
register(sc, stc, v) { stateCache = sc; scrollTopCache = stc; viewRef = v; },
unregister() { stateCache = null; scrollTopCache = null; viewRef = null; },
getContent(filePath) { return stateCache?.get(filePath)?.doc.toString() ?? null; },
getAllModifiedContent(modifiedFiles) { /* iterate stateCache */ },
destroy() { viewRef?.destroy(); stateCache?.clear(); scrollTopCache?.clear(); },
};
```
- Компонент вызывает `register()` при mount, `unregister()` при unmount
- Store actions (closeEditor, saveAllFiles) используют `editorBridge.getContent()` / `editorBridge.destroy()`
- `saveAllFiles`: компонент итерирует и вызывает `store.saveFile()` для каждого (паттерн ChangeReviewDialog)
- `discardChanges`: store делает IPC read, компонент применяет через `view.dispatch({ changes })`
---
## R4: fs.watch recursive на Linux + watcher reliability
**Проблема**: `fs.watch({ recursive: true })` экспериментальный на Linux в Node.js 18. Может тихо не работать. Нет fallback.
**Нужно выяснить**:
- Какую версию Node.js использует Electron 40? Поддерживается ли `recursive: true` на Linux?
- Существующий `FileWatcher.ts` в проекте: использует ли он `recursive: true`? Есть ли fallback?
- Альтернативы: chokidar (но добавляет зависимость), polling, non-recursive watch + manual traversal.
- `max_user_watches` лимит inotify — как обойти?
- macOS FSEvents: coalescing events — как существующий FileWatcher решает это?
**Статус**: COMPLETED
**Результат**:
- **Electron 40 = Node.js 24 (LTS)**`recursive: true` работает на macOS (FSEvents, надёжность 9/10) и Linux (inotify, надёжность 6/10)
- Существующий `FileWatcher.ts` (1098 строк) — зрелый watcher с debounce (строки 1060-1074), recovery (424-457), catch-up scan (992-1051)
- **НЕ добавлять chokidar** — использовать паттерны из FileWatcher.ts
- macOS: fs.watch recursive reliable (FSEvents), burst coalescing для git checkout сценариев
- Linux: `ENOSPC` → fallback на polling (5-10 секунд интервал)
- `max_user_watches` лимит inotify: при ENOSPC не падать, а деградировать до polling
- **Рекомендация**: EditorFileWatcher ~250-300 LOC (вместо первоначальных ~60 LOC), включая:
- Burst coalescing (200ms debounce + batch)
- ENOSPC fallback to polling
- Фильтрация: node_modules/.git/dist
- Graceful stop/restart
---
## R5: Git CLI availability & performance
**Проблема**: `git status --porcelain` без проверки наличия git. На больших монорепо — десятки секунд. Non-git проекты не обработаны.
**Нужно выяснить**:
- Есть ли в проекте утилита проверки наличия git? (проверить существующие git-related сервисы)
- `git status --porcelain` performance: `--untracked-files=no` ускоряет? `--ignore-submodules`?
- Timeout стратегия: AbortSignal + child_process?
- Graceful degradation: что показывать если git недоступен / не git-repo?
- `.git/index.lock` — как обрабатывать concurrent git operations?
**Статус**: COMPLETED
**Результат**:
- **`isGitRepo()`** уже есть в `GitDiffFallback.ts` (строки 118-133) — переиспользовать
- Оптимальная команда: `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules`
- `--no-optional-locks` — критично, предотвращает `.git/index.lock` конфликты
- `-z` — NUL-separated вывод (безопасный парсинг путей с пробелами)
- `--ignore-submodules` — ускорение на монорепо
- Timeout: 10 секунд (паттерн из GitDiffFallback.ts), AbortSignal
- Добавить `'conflict'` статус в `GitFileStatus` для merge conflicts (`UU`, `AA`, `DD` коды)
- Graceful degradation: проверить git available → проверить is repo → timeout handling
- Нет git: скрыть git бейджи, показать "Git not available" в status bar
- Не git-repo: скрыть git бейджи
- Timeout: показать "Git status unavailable" + кнопка retry
- **GitStatusService ~200-250 LOC**, EditorFileWatcher **~250-300 LOC**

View file

@ -0,0 +1,171 @@
# Wireframes (черновик, пересмотреть позже)
> Статус: DRAFT — требует пересмотра и финализации перед реализацией.
## 1. Main state (файл открыт)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ [←] Open in Editor — /Users/belief/project [?] [×] │
├──────────────────────┬──────────────────────────────────────────────────────┤
│ 🔍 Filter files... │ [● index.ts] [ App.tsx ] [ utils.ts] [×] │
│ │ ─────────────────────────────────────────────────── │
│ ▼ src/ │ src / renderer / components / App.tsx │
│ ▼ renderer/ │ ─────────────────────────────────────────────────── │
│ ▼ components/ │ 1 │ import React from 'react'; │
│ ▸ chat/ │ 2 │ import { useStore } from '../store'; │
│ ▸ common/ │ 3 │ │
│ ● App.tsx │ 4 │ export const App = () => { │
│ index.ts │ 5 │ const theme = useStore(s => s.theme); │
│ ▸ hooks/ │ 6 │ return ( │
│ ▸ store/ │ 7 │ <div className={theme}>
│ ▸ utils/ │ 8 │ <Router />
│ ▸ main/ │ 9 │ </div>
│ ▸ shared/ │ 10 │ ); │
│ ▸ test/ │ 11 │ }; │
│ ▸ docs/ │ 12 │ │
│ package.json │ │
│ tsconfig.json │ │
│ ├──────────────────────────────────────────────────────│
│ │ Ln 5, Col 12 │ TypeScript │ UTF-8 │ Spaces: 2 │ LF │
└──────────────────────┴──────────────────────────────────────────────────────┘
```
## 2. Empty state (нет открытых файлов)
```
┌──────────────────────┬──────────────────────────────────────────────────────┐
│ 🔍 Filter files... │ │
│ │ │
│ ▼ src/ │ No file is open │
│ ▸ main/ │ │
│ ▸ renderer/ │ Keyboard Shortcuts │
│ ▸ shared/ │ ───────────────── │
│ ▸ test/ │ ⌘P Quick Open │
│ package.json │ ⌘S Save File │
│ │ ⌘⇧F Search in Files │
│ │ ⌘W Close Tab │
│ │ ⌘B Toggle Sidebar │
│ │ ⌘G Go to Line │
│ │ Esc Close Editor │
│ │ │
└──────────────────────┴──────────────────────────────────────────────────────┘
```
## 3. Unsaved changes confirm dialog
```
┌──────────────────────────────────────────────────┐
│ │
│ ⚠ You have unsaved changes in 3 files: │
│ │
│ • index.ts │
│ • App.tsx │
│ • utils.ts │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │
│ │ Save All & │ │ Discard & │ │ Cancel │ │
│ │ Close │ │ Close │ │ │ │
│ └──────────────┘ └──────────────┘ └────────┘ │
│ │
└──────────────────────────────────────────────────┘
```
## 4. Context menu на директории
```
│ ▼ components/ │
│ ▸ chat/ │
│ ▸ common/ ─────┤
│ App.tsx │ New File... │
│ index.ts │ New Folder... │
│ ▸ hooks/ │────────────────────│
│ │ Delete │
│ │ Copy Path │
│ │ Reveal in Finder │
│ └────────────────────┘
```
## 5. Quick Open (Cmd+P)
```
┌──────────────────────────────────────────────────┐
│ 🔍 app.tsx │
├──────────────────────────────────────────────────┤
│ ● App.tsx src/renderer/components │
│ AppShell.tsx src/renderer/components │
│ api.ts src/shared/types │
│ atomicWrite.ts src/main/utils │
│ │
│ 4 results │
└──────────────────────────────────────────────────┘
```
## 6. Search in Files (Cmd+Shift+F)
```
┌──────────────────────┬──────────────────────────────────────────────────────┐
│ SEARCH IN FILES │ [ index.ts ] [● App.tsx ] │
│ 🔍 useStore [Aa] │ ─────────────────────────────────────────────────── │
│ ──────────────────── │ 1 │ import React from 'react'; │
│ 12 results in 8 files│ 5 │ const theme = useStore(s => s.theme); ◄── │
│ │ │
│ ▼ src/renderer/ │ │
│ App.tsx:5 │ │
│ const theme = │ │
│ useStore(s => │ │
│ store/index.ts:12 │ │
│ export const │ │
│ useStore = ... │ │
│ ▼ src/main/ │ │
│ ... │ │
└──────────────────────┴──────────────────────────────────────────────────────┘
```
## 7. Binary / Error / Large file states
```
Binary file:
┌──────────────────────────────────────────────────┐
│ │
│ 📄 logo.png │
│ PNG Image • 245 KB │
│ │
│ [Open in System Viewer] [Close Tab] │
│ │
└──────────────────────────────────────────────────┘
Error state:
┌──────────────────────────────────────────────────┐
│ │
│ ⚠ Cannot read file │
│ Permission denied (EACCES) │
│ │
│ [Retry] [Close Tab] │
│ │
└──────────────────────────────────────────────────┘
Large file (2-5MB):
┌──────────────────────────────────────────────────┐
│ ⚠ File too large for editing (3.2 MB) │
│ Showing first 100 lines (read-only preview) │
│──────────────────────────────────────────────────│
│ 1 │ // This is a large generated file... │
│ 2 │ ... │
│ 100 │ ... │
│──────────────────────────────────────────────────│
│ [Open in External Editor] │
└──────────────────────────────────────────────────┘
```
## 8. Git status badges + conflict banner
```
File tree with git status:
│ ▼ src/ │
│ ▼ renderer/ │
│ M App.tsx │ ← M = modified (amber)
│ U newFile.ts │ ← U = untracked (green)
│ A staged.ts │ ← A = staged (blue)
│ index.ts │
Conflict banner (file changed on disk while open):
┌──────────────────────────────────────────────────────────────┐
│ ⚠ File changed on disk [Reload] [Keep Mine] [Show Diff] │
├──────────────────────────────────────────────────────────────┤
│ 1 │ import React from 'react'; │
│ 2 │ ... │
└──────────────────────────────────────────────────────────────┘
```

View file

@ -57,7 +57,12 @@ export default defineConfig({
// CJS format so bundled deps can use __dirname/require.
// Use .cjs extension since package.json has "type": "module".
format: 'cjs',
entryFileNames: '[name].cjs'
entryFileNames: '[name].cjs',
// Set UV_THREADPOOL_SIZE before any module code runs.
// Must be in the banner because ESM→CJS hoists imports above top-level code.
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
}
}
}

View file

@ -338,7 +338,7 @@ export default defineConfig([
},
parserOptions: {
projectService: false,
project: './tsconfig.test.json',
project: './tsconfig.json',
},
},
rules: {

View file

@ -1,20 +0,0 @@
{
"$schema": "https://unpkg.com/knip@next/schema.json",
"entry": [
"src/main/index.ts",
"src/main/standalone.ts",
"src/preload/index.ts",
"src/renderer/main.tsx",
"electron.vite.config.ts",
"vite.standalone.config.ts"
],
"project": ["src/**/*.{ts,tsx}!"],
"ignore": ["tsconfig*.json"],
"paths": {
"@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"],
"@preload/*": ["./src/preload/*"],
"@shared/*": ["./src/shared/*"]
},
"ignoreBinaries": ["pkg"]
}

View file

@ -46,14 +46,13 @@
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
"standalone": "tsx src/main/standalone.ts",
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
"standalone:build": "electron-vite build && vite build --config docker/vite.standalone.config.ts",
"standalone:start": "node dist-standalone/index.cjs",
"prepare": "husky",
"postinstall": "electron-rebuild -f -o node-pty"
"postinstall": "electron-rebuild -f -o node-pty || echo 'node-pty rebuild failed (terminal will be disabled)'"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"src/**/*.{json,css}": [
@ -82,6 +81,7 @@
"@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2",
"@codemirror/merge": "^6.12.0",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.15",
@ -92,6 +92,7 @@
"@fastify/static": "^9.0.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
@ -101,7 +102,9 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
@ -111,6 +114,7 @@
"fastify": "^5.7.4",
"highlight.js": "^11.11.1",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"node-diff3": "^3.2.0",
@ -121,6 +125,7 @@
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"simple-git": "^3.32.3",
"ssh-config": "^5.0.4",
"ssh2": "^1.17.0",
"tailwind-merge": "^3.5.0",
@ -242,14 +247,48 @@
"publish": [
{
"provider": "github",
"releaseType": "draft"
"releaseType": "release"
}
]
},
"packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"node-pty"
]
},
"knip": {
"entry": [
"src/main/index.ts",
"src/main/standalone.ts",
"src/preload/index.ts",
"src/renderer/main.tsx",
"electron.vite.config.ts",
"docker/vite.standalone.config.ts"
],
"project": [
"src/**/*.{ts,tsx}!"
],
"ignore": [
"tsconfig*.json"
],
"paths": {
"@main/*": [
"./src/main/*"
],
"@renderer/*": [
"./src/renderer/*"
],
"@preload/*": [
"./src/preload/*"
],
"@shared/*": [
"./src/shared/*"
]
},
"ignoreBinaries": [
"pkg"
]
}
}

View file

@ -71,6 +71,9 @@ importers:
'@codemirror/merge':
specifier: ^6.12.0
version: 6.12.0
'@codemirror/search':
specifier: ^6.6.0
version: 6.6.0
'@codemirror/state':
specifier: ^6.5.4
version: 6.5.4
@ -101,6 +104,9 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -128,9 +134,15 @@ importers:
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
chokidar:
specifier: ^4.0.3
version: 4.0.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -158,6 +170,9 @@ importers:
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
isbinaryfile:
specifier: ^6.0.0
version: 6.0.0
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@18.3.1)
@ -188,6 +203,9 @@ importers:
remark-parse:
specifier: ^11.0.0
version: 11.0.0
simple-git:
specifier: ^3.32.3
version: 3.32.3
ssh-config:
specifier: ^5.0.4
version: 5.0.4
@ -531,6 +549,9 @@ packages:
'@codemirror/merge@6.12.0':
resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
@ -1061,6 +1082,12 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
@ -1340,6 +1367,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@ -1428,6 +1468,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.16':
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
@ -2133,6 +2186,9 @@ packages:
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
@ -2547,6 +2603,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
@ -3734,6 +3794,10 @@ packages:
resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==}
engines: {node: '>= 18.0.0'}
isbinaryfile@6.0.0:
resolution: {integrity: sha512-2FN2B8MAqKv6d5TaKsLvMrwMcghxwHTpcKy0L5mhNbRqjNqo2++SpCqN6eG1lCC1GmTQgvrYJYXv2+Chvyevag==}
engines: {node: '>= 24.0.0'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@ -4752,6 +4816,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@ -4989,6 +5057,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git@3.32.3:
resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
@ -6046,6 +6117,12 @@ snapshots:
'@lezer/highlight': 1.2.3
style-mod: 4.1.3
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
@ -6552,6 +6629,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@kwsites/promise-deferred@1.1.1': {}
'@lezer/common@1.5.1': {}
'@lezer/cpp@1.1.5':
@ -6846,6 +6931,20 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-context@1.1.2(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1
@ -6926,6 +7025,32 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
aria-hidden: 1.2.6
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@ -7624,6 +7749,8 @@ snapshots:
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-web-links@0.12.0': {}
'@xterm/xterm@6.0.0': {}
abbrev@1.1.1: {}
@ -8202,6 +8329,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@2.0.0: {}
chownr@3.0.0: {}
@ -9689,6 +9820,8 @@ snapshots:
isbinaryfile@5.0.7: {}
isbinaryfile@6.0.0: {}
isexe@2.0.0: {}
isexe@3.1.5: {}
@ -10931,6 +11064,8 @@ snapshots:
dependencies:
picomatch: 2.3.1
readdirp@4.1.2: {}
real-require@0.2.0: {}
refa@0.12.1:
@ -11238,6 +11373,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-git@3.32.3:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.3
transitivePeerDependencies:
- supports-color
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.3

View file

@ -2681,6 +2681,25 @@
"supports_vision": true,
"tool_use_system_prompt_tokens": 159
},
"openrouter/anthropic/claude-opus-4.6": {
"cache_creation_input_token_cost": 0.00000625,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "openrouter",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 0.000025,
"supports_assistant_prefill": true,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"supports_vision": true,
"tool_use_system_prompt_tokens": 346
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
"cache_creation_input_token_cost": 0.00000375,

View file

@ -11,7 +11,7 @@
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import {
@ -27,14 +27,19 @@ import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:utility');
/** Cached app version — read once from package.json, not every request. */
let cachedVersion: string | null = null;
export function registerUtilityRoutes(app: FastifyInstance): void {
// App version
// App version (cached — no file I/O after first call)
app.get('/api/version', async () => {
if (cachedVersion) return cachedVersion;
try {
// Read version from package.json (works in both Electron and Node)
const pkgPath = path.resolve(__dirname, '../../../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
return pkg.version;
const content = await fsp.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(content) as { version: string };
cachedVersion = pkg.version;
return cachedVersion;
} catch {
return '0.0.0';
}
@ -73,7 +78,7 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
}
});
// Read mentioned file
// Read mentioned file — async I/O, no TOCTOU
app.post<{ Body: { absolutePath: string; projectRoot: string; maxTokens?: number } }>(
'/api/read-mentioned-file',
async (request) => {
@ -87,16 +92,12 @@ export function registerUtilityRoutes(app: FastifyInstance): void {
const safePath = validation.normalizedPath!;
if (!fs.existsSync(safePath)) {
return null;
}
const stats = fs.statSync(safePath);
const stats = await fsp.stat(safePath);
if (!stats.isFile()) {
return null;
}
const content = fs.readFileSync(safePath, 'utf8');
const content = await fsp.readFile(safePath, 'utf8');
const estimatedTokens = countTokens(content);
if (estimatedTokens > maxTokens) {

View file

@ -8,7 +8,7 @@
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import type { FastifyInstance } from 'fastify';
@ -39,11 +39,8 @@ export function registerValidationRoutes(app: FastifyInstance): void {
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
// Single async stat — no TOCTOU, doesn't block the main thread
const stats = await fsp.stat(fullPath);
return { exists: true, isDirectory: stats.isDirectory() };
} catch {
return { exists: false };
@ -56,18 +53,24 @@ export function registerValidationRoutes(app: FastifyInstance): void {
'/api/validate/mentions',
async (request) => {
const { mentions, projectPath } = request.body;
const results = new Map<string, boolean>();
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
// Validate all mentions in parallel with async I/O
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}
try {
await fsp.access(fullPath);
return [`@${mention.value}`, true] as const;
} catch {
return [`@${mention.value}`, false] as const;
}
})
);
return Object.fromEntries(results);
return Object.fromEntries(entries);
}
);

View file

@ -9,6 +9,13 @@
* - Manage application lifecycle
*/
// Increase UV thread pool size BEFORE any async I/O.
// Default is 4 threads which is far too few for startup:
// binary resolution stat() calls, CLI subprocess spawning, fs.watch(),
// and readFile/readdir from IPC handlers all compete for the pool.
// On Windows this saturates all threads, blocking the event loop.
process.env.UV_THREADPOOL_SIZE ??= '16';
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
@ -32,6 +39,7 @@ import { app, BrowserWindow } from 'electron';
import { existsSync } from 'fs';
import { join } from 'path';
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { showTeamNativeNotification } from './ipc/teams';
import { HttpServer } from './services/infrastructure/HttpServer';
@ -55,6 +63,7 @@ import {
UpdaterService,
} from './services';
import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
@ -69,18 +78,51 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']);
// --- Team display name cache (avoid listTeams() on every notification) ---
const TEAM_DISPLAY_NAME_TTL_MS = 30_000;
const teamDisplayNameCache = new Map<string, { value: string; expiresAt: number }>();
let teamListInFlight: Promise<Map<string, string>> | null = null;
async function refreshTeamDisplayNameCache(): Promise<Map<string, string>> {
if (teamListInFlight) {
return teamListInFlight;
}
teamListInFlight = (async () => {
const out = new Map<string, string>();
try {
if (!teamDataService) return out;
const summary = await teamDataService.listTeams();
for (const team of summary) {
if (team?.teamName) {
out.set(team.teamName, team.displayName || team.teamName);
}
}
} catch {
// ignore
} finally {
teamListInFlight = null;
}
return out;
})();
return teamListInFlight;
}
/** Resolve human-friendly team display name, falling back to raw teamName. */
async function resolveTeamDisplayName(teamName: string): Promise<string> {
try {
if (teamDataService) {
const summary = await teamDataService.listTeams();
const team = summary.find((t) => t.teamName === teamName);
if (team?.displayName) return team.displayName;
}
} catch {
// fallback
const cached = teamDisplayNameCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
return teamName;
const map = await refreshTeamDisplayNameCache();
const resolved = map.get(teamName) ?? teamName;
teamDisplayNameCache.set(teamName, {
value: resolved,
expiresAt: Date.now() + TEAM_DISPLAY_NAME_TTL_MS,
});
return resolved;
}
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
@ -213,14 +255,51 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
// Wire file-change events to renderer and HTTP SSE
const SCAN_CACHE_INVALIDATE_DEBOUNCE_MS = 250;
let scanCacheInvalidateTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleScanCacheInvalidation = (): void => {
if (scanCacheInvalidateTimer) {
clearTimeout(scanCacheInvalidateTimer);
}
scanCacheInvalidateTimer = setTimeout(() => {
scanCacheInvalidateTimer = null;
context.projectScanner.clearScanCache();
}, SCAN_CACHE_INVALIDATE_DEBOUNCE_MS);
};
const fileChangeHandler = (event: unknown): void => {
// Avoid triggering a full project rescan on every session append.
// The ProjectScanner already has a short TTL cache; we only invalidate for
// structural changes (add/unlink), and we debounce bursts of events.
try {
if (event && typeof event === 'object') {
const row = event as Partial<FileChangeEvent>;
const isSubagent = row.isSubagent === true;
const changeType = row.type;
if (!isSubagent && (changeType === 'add' || changeType === 'unlink')) {
scheduleScanCacheInvalidation();
}
} else {
// Fallback: if we can't classify the event, invalidate (debounced).
scheduleScanCacheInvalidation();
}
} catch {
// ignore
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
}
httpServer?.broadcast('file-change', event);
};
context.fileWatcher.on('file-change', fileChangeHandler);
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
fileChangeCleanup = () => {
context.fileWatcher.off('file-change', fileChangeHandler);
if (scanCacheInvalidateTimer) {
clearTimeout(scanCacheInvalidateTimer);
scanCacheInvalidateTimer = null;
}
};
// Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above)
const todoChangeHandler = (event: unknown): void => {
@ -406,9 +485,11 @@ function initializeServices(): void {
todosDir: localTodosDir,
});
// Register and start local context
// Register context and start cache cleanup only.
// FileWatcher is deferred to did-finish-load to avoid blocking window creation
// with fs.watch() setup (especially slow on Windows NTFS with recursive watchers).
contextRegistry.registerContext(localContext);
localContext.start();
localContext.startCacheOnly();
logger.info(`Projects directory: ${localContext.projectScanner.getProjectsDir()}`);
@ -435,9 +516,8 @@ function initializeServices(): void {
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
const reviewApplier = new ReviewApplierService();
// Fire-and-forget: warm up CLI and install teamctl.js at startup
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
// warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer();
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
@ -449,9 +529,8 @@ function initializeServices(): void {
};
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
// Start periodic health checks for registered CLI processes (every 2s).
// Dead processes get stoppedAt written to processes.json → FileWatcher picks it up.
teamDataService.startProcessHealthPolling();
// startProcessHealthPolling() is deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup.
// Initialize IPC handlers with registry
initializeIpcHandlers(
@ -562,6 +641,9 @@ function shutdownServices(): void {
teamChangeCleanup = null;
}
// Clean up editor state (watcher, git service)
cleanupEditorState();
// Dispose all contexts (including local)
if (contextRegistry) {
contextRegistry.dispose();
@ -572,6 +654,13 @@ function shutdownServices(): void {
sshConnectionManager.dispose();
}
// Stop all running team provisioning processes
if (teamProvisioningService) {
for (const teamName of teamProvisioningService.getAliveTeams()) {
teamProvisioningService.stopTeam(teamName);
}
}
// Kill all PTY processes
if (ptyTerminalService) {
ptyTerminalService.killAll();
@ -649,7 +738,28 @@ function createWindow(): void {
mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen());
}
}, 0);
// Start file watchers now that the window is visible and responsive.
// Deferred from initializeServices() to avoid blocking window creation
// with fs.watch() setup (especially slow on Windows with recursive watchers).
const activeContext = contextRegistry.getActive();
if (process.platform === 'win32') {
// On Windows, delay FileWatcher startup to let the renderer complete
// its initial IPC calls without UV thread pool contention. Recursive
// fs.watch() on NTFS saturates all 4 default UV threads.
setTimeout(() => activeContext.startFileWatcher(), 1500);
} else {
activeContext.startFileWatcher();
}
setTimeout(() => updaterService.checkForUpdates(), 3000);
// Defer non-critical startup work to avoid thread pool contention.
// The window is now visible and responsive; these run in the background.
setTimeout(() => {
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
teamDataService.startProcessHealthPolling();
}, 5000);
}
});
@ -731,6 +841,8 @@ function createWindow(): void {
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(null);
}
setEditorMainWindow(null);
cleanupEditorState();
});
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
@ -752,6 +864,7 @@ function createWindow(): void {
if (ptyTerminalService) {
ptyTerminalService.setMainWindow(mainWindow);
}
setEditorMainWindow(mainWindow);
logger.info('Main window created');
}

377
src/main/ipc/editor.ts Normal file
View file

@ -0,0 +1,377 @@
/**
* Editor IPC handlers.
*
* Module-level state: `activeProjectRoot` stores the validated project path.
* Renderer cannot override it it's set only via `editor:open` with full validation (SEC-5).
*/
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { isPathWithinRoot } from '@main/utils/pathValidation';
import {
EDITOR_CHANGE,
EDITOR_CLOSE,
EDITOR_CREATE_DIR,
EDITOR_CREATE_FILE,
EDITOR_DELETE_FILE,
EDITOR_GIT_STATUS,
EDITOR_LIST_FILES,
EDITOR_MOVE_FILE,
EDITOR_OPEN,
EDITOR_READ_DIR,
EDITOR_READ_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import {
checkFileConflict,
createSearchAbortController,
EditorFileWatcher,
FileSearchService,
GitStatusService,
ProjectFileService,
} from '../services/editor';
import { createIpcWrapper } from './ipcWrapper';
import type {
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
GitStatusResult,
MoveFileResponse,
QuickOpenFile,
ReadDirResult,
ReadFileResult,
SearchInFilesOptions,
SearchInFilesResult,
WriteFileResponse,
} from '@shared/types/editor';
import type { IpcResult } from '@shared/types/ipc';
import type { BrowserWindow, IpcMain, IpcMainInvokeEvent } from 'electron';
// =============================================================================
// Module-level state (SEC-5)
// =============================================================================
let activeProjectRoot: string | null = null;
let mainWindowRef: BrowserWindow | null = null;
let activeSearchController: AbortController | null = null;
const projectFileService = new ProjectFileService();
const fileSearchService = new FileSearchService();
const gitStatusService = new GitStatusService();
const editorFileWatcher = new EditorFileWatcher();
const wrapHandler = createIpcWrapper('IPC:editor');
const log = createLogger('IPC:editor');
// =============================================================================
// Handlers
// =============================================================================
/**
* Initialize editor with validated project path (SEC-15).
*/
async function handleEditorOpen(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult<void>> {
return wrapHandler('open', async () => {
// Validate projectPath before trusting it
if (!projectPath || typeof projectPath !== 'string') {
throw new Error('Invalid project path');
}
if (!path.isAbsolute(projectPath)) {
throw new Error('Project path must be absolute');
}
const normalized = path.resolve(path.normalize(projectPath));
// Block filesystem root
if (normalized === '/' || /^[A-Z]:\\$/i.test(normalized)) {
throw new Error('Cannot open filesystem root as project');
}
// Block ~/.claude directory itself
const claudeDir = getClaudeBasePath();
if (isPathWithinRoot(normalized, claudeDir)) {
throw new Error('Cannot open Claude data directory as project');
}
// Verify it's an existing directory
const stat = await fs.stat(normalized);
if (!stat.isDirectory()) {
throw new Error('Project path is not a directory');
}
// Stop any previous watcher/git before switching projects
editorFileWatcher.stop();
gitStatusService.destroy();
activeProjectRoot = normalized;
gitStatusService.init(normalized);
log.info('Editor opened:', normalized);
});
}
/**
* Cleanup editor state.
*/
async function handleEditorClose(): Promise<IpcResult<void>> {
return wrapHandler('close', async () => {
editorFileWatcher.stop();
gitStatusService.destroy();
activeProjectRoot = null;
log.info('Editor closed');
});
}
/**
* Read directory listing (depth=1, lazy).
*/
async function handleEditorReadDir(
_event: IpcMainInvokeEvent,
dirPath: string,
maxEntries?: number
): Promise<IpcResult<ReadDirResult>> {
return wrapHandler('readDir', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.readDir(activeProjectRoot, dirPath, maxEntries ?? undefined);
});
}
/**
* Read file content with binary detection.
*/
async function handleEditorReadFile(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<ReadFileResult>> {
return wrapHandler('readFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.readFile(activeProjectRoot, filePath);
});
}
/**
* Write file content with atomic write (SEC-9, SEC-12, SEC-14).
* Optional baselineMtimeMs enables conflict detection before writing.
*/
async function handleEditorWriteFile(
_event: IpcMainInvokeEvent,
filePath: string,
content: string,
baselineMtimeMs?: number
): Promise<IpcResult<WriteFileResponse>> {
return wrapHandler('writeFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// Conflict detection: check if file was modified externally since last read/save
if (baselineMtimeMs !== undefined && baselineMtimeMs > 0) {
const conflict = await checkFileConflict(filePath, baselineMtimeMs);
if (conflict.hasConflict) {
if (conflict.deleted) {
throw new Error('CONFLICT_DELETED: File was deleted externally');
}
throw new Error('CONFLICT: File was modified externally');
}
}
return projectFileService.writeFile(activeProjectRoot, filePath, content);
});
}
/**
* Create a new file in the project.
*/
async function handleEditorCreateFile(
_event: IpcMainInvokeEvent,
parentDir: string,
fileName: string
): Promise<IpcResult<CreateFileResponse>> {
return wrapHandler('createFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.createFile(activeProjectRoot, parentDir, fileName);
});
}
/**
* Create a new directory in the project.
*/
async function handleEditorCreateDir(
_event: IpcMainInvokeEvent,
parentDir: string,
dirName: string
): Promise<IpcResult<CreateDirResponse>> {
return wrapHandler('createDir', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.createDir(activeProjectRoot, parentDir, dirName);
});
}
/**
* Delete a file or directory (move to Trash).
*/
async function handleEditorDeleteFile(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<IpcResult<DeleteFileResponse>> {
return wrapHandler('deleteFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.deleteFile(activeProjectRoot, filePath);
});
}
/**
* Move a file or directory to a new location.
*/
async function handleEditorMoveFile(
_event: IpcMainInvokeEvent,
sourcePath: string,
destDir: string
): Promise<IpcResult<MoveFileResponse>> {
return wrapHandler('moveFile', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return projectFileService.moveFile(activeProjectRoot, sourcePath, destDir);
});
}
/**
* Search in files (literal string search, SEC-8 timeout).
*/
async function handleEditorSearchInFiles(
_event: IpcMainInvokeEvent,
options: SearchInFilesOptions
): Promise<IpcResult<SearchInFilesResult>> {
return wrapHandler('searchInFiles', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
// Cancel any in-flight search
if (activeSearchController) {
activeSearchController.abort();
}
const controller = createSearchAbortController();
activeSearchController = controller;
try {
return await fileSearchService.searchInFiles(activeProjectRoot, options, controller.signal);
} finally {
if (activeSearchController === controller) {
activeSearchController = null;
}
}
});
}
/**
* List all project files recursively (for Quick Open).
*/
async function handleEditorListFiles(): Promise<IpcResult<QuickOpenFile[]>> {
return wrapHandler('listFiles', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return fileSearchService.listFiles(activeProjectRoot);
});
}
/**
* Get git status for current project (cached 5s).
*/
async function handleEditorGitStatus(): Promise<IpcResult<GitStatusResult>> {
return wrapHandler('gitStatus', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
return gitStatusService.getStatus();
});
}
/**
* Enable/disable file watcher for current project.
*/
async function handleEditorWatchDir(
_event: IpcMainInvokeEvent,
enable: boolean
): Promise<IpcResult<void>> {
return wrapHandler('watchDir', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
if (enable) {
editorFileWatcher.start(activeProjectRoot, (event) => {
// Invalidate git cache on file changes
gitStatusService.invalidateCache();
// Forward event to renderer
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send(EDITOR_CHANGE, event);
}
});
} else {
editorFileWatcher.stop();
}
});
}
// =============================================================================
// Registration
// =============================================================================
export function initializeEditorHandlers(): void {
// No external dependencies needed — service created at module level
}
/**
* Set main window reference for forwarding watcher events.
* Called from main/index.ts after window creation.
*/
export function setEditorMainWindow(win: BrowserWindow | null): void {
mainWindowRef = win;
}
export function registerEditorHandlers(ipcMain: IpcMain): void {
ipcMain.handle(EDITOR_OPEN, handleEditorOpen);
ipcMain.handle(EDITOR_CLOSE, handleEditorClose);
ipcMain.handle(EDITOR_READ_DIR, handleEditorReadDir);
ipcMain.handle(EDITOR_READ_FILE, handleEditorReadFile);
ipcMain.handle(EDITOR_WRITE_FILE, handleEditorWriteFile);
ipcMain.handle(EDITOR_CREATE_FILE, handleEditorCreateFile);
ipcMain.handle(EDITOR_CREATE_DIR, handleEditorCreateDir);
ipcMain.handle(EDITOR_DELETE_FILE, handleEditorDeleteFile);
ipcMain.handle(EDITOR_MOVE_FILE, handleEditorMoveFile);
ipcMain.handle(EDITOR_SEARCH_IN_FILES, handleEditorSearchInFiles);
ipcMain.handle(EDITOR_LIST_FILES, handleEditorListFiles);
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
}
export function removeEditorHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(EDITOR_OPEN);
ipcMain.removeHandler(EDITOR_CLOSE);
ipcMain.removeHandler(EDITOR_READ_DIR);
ipcMain.removeHandler(EDITOR_READ_FILE);
ipcMain.removeHandler(EDITOR_WRITE_FILE);
ipcMain.removeHandler(EDITOR_CREATE_FILE);
ipcMain.removeHandler(EDITOR_CREATE_DIR);
ipcMain.removeHandler(EDITOR_DELETE_FILE);
ipcMain.removeHandler(EDITOR_MOVE_FILE);
ipcMain.removeHandler(EDITOR_SEARCH_IN_FILES);
ipcMain.removeHandler(EDITOR_LIST_FILES);
ipcMain.removeHandler(EDITOR_GIT_STATUS);
ipcMain.removeHandler(EDITOR_WATCH_DIR);
}
/**
* Reset editor state (called from mainWindow.on('closed')).
* Prevents state leak when Cmd+Q on macOS.
*/
export function cleanupEditorState(): void {
editorFileWatcher.stop();
gitStatusService.destroy();
activeProjectRoot = null;
}

View file

@ -28,6 +28,7 @@ import {
registerContextHandlers,
removeContextHandlers,
} from './context';
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
import {
initializeHttpServerHandlers,
registerHttpServerHandlers,
@ -143,6 +144,8 @@ export function initializeIpcHandlers(
if (ptyTerminal) {
initializeTerminalHandlers(ptyTerminal);
}
initializeEditorHandlers();
if (changeExtractor) {
initializeReviewHandlers({
extractor: changeExtractor,
@ -166,6 +169,7 @@ export function initializeIpcHandlers(
registerContextHandlers(ipcMain);
registerTeamHandlers(ipcMain);
registerReviewHandlers(ipcMain);
registerEditorHandlers(ipcMain);
registerWindowHandlers(ipcMain);
if (cliInstaller) {
registerCliInstallerHandlers(ipcMain);
@ -198,6 +202,7 @@ export function removeIpcHandlers(): void {
removeContextHandlers(ipcMain);
removeTeamHandlers(ipcMain);
removeReviewHandlers(ipcMain);
removeEditorHandlers(ipcMain);
removeWindowHandlers(ipcMain);
removeCliInstallerHandlers(ipcMain);
removeTerminalHandlers(ipcMain);

View file

@ -0,0 +1,25 @@
/**
* Generic IPC handler wrapper standardizes error handling and logging.
*
* Creates a domain-specific wrapper that catches errors, logs them,
* and returns IpcResult<T> for consistent renderer-side handling.
*/
import { createLogger } from '@shared/utils/logger';
import type { IpcResult } from '@shared/types/ipc';
export function createIpcWrapper(logPrefix: string) {
const log = createLogger(logPrefix);
return async function wrap<T>(operation: string, fn: () => Promise<T>): Promise<IpcResult<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`handler error [${operation}]:`, message);
return { success: false, error: message };
}
};
}

View file

@ -4,7 +4,9 @@
* Паттерн: module-level state + guard + wrapReviewHandler (как teams.ts)
*/
import { createIpcWrapper } from '@main/ipc/ipcWrapper';
import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore';
import { validateFilePath } from '@main/utils/pathValidation';
import {
REVIEW_APPLY_DECISIONS,
REVIEW_CHECK_CONFLICT,
@ -43,6 +45,7 @@ import type {
} from '@shared/types/review';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const wrapReviewHandler = createIpcWrapper('IPC:review');
const logger = createLogger('IPC:review');
// --- Module-level state ---
@ -128,22 +131,6 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(REVIEW_CLEAR_DECISIONS);
}
// --- Локальный wrapReviewHandler ---
async function wrapReviewHandler<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`Review handler error [${operation}]:`, message);
return { success: false, error: message };
}
}
// --- Phase 1 Handlers ---
async function handleGetAgentChanges(
@ -259,10 +246,15 @@ async function handleSaveEditedFile(
if (!filePath || typeof content !== 'string') {
return { success: false, error: 'Invalid parameters' };
}
const pathCheck = validateFilePath(filePath, null);
if (!pathCheck.valid) {
logger.error(`saveEditedFile blocked: ${String(pathCheck.error)} (path: ${String(filePath)})`);
return { success: false, error: `Path validation failed: ${String(pathCheck.error)}` };
}
return wrapReviewHandler('saveEditedFile', async () => {
const result = await getApplier().saveEditedFile(filePath, content);
const result = await getApplier().saveEditedFile(pathCheck.normalizedPath!, content);
// Invalidate cached content so next fetch reads the saved version from disk
getContentResolver().invalidateFile(filePath);
getContentResolver().invalidateFile(pathCheck.normalizedPath!);
return result;
});
}

View file

@ -41,6 +41,7 @@ import {
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
@ -186,6 +187,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_UPDATE_TASK_FIELDS, handleUpdateTaskFields);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_RESTORE, handleRestoreTeam);
ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam);
@ -231,6 +233,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_FIELDS);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_RESTORE);
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
@ -318,6 +321,8 @@ async function handleGetData(
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
const tn = validated.value!;
const startedAt = Date.now();
logger.info(`[teams:getData] start team=${tn}`);
let data: TeamData;
try {
data = await getTeamDataService().getTeamData(tn);
@ -332,6 +337,14 @@ async function handleGetData(
logger.error(`[teams:getData] ${message}`);
return { success: false, error: message };
}
const getDataMs = Date.now() - startedAt;
if (getDataMs >= 1000) {
logger.warn(
`[teams:getData] slow team=${tn} ms=${getDataMs} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}`
);
} else {
logger.info(`[teams:getData] done team=${tn} ms=${getDataMs}`);
}
const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn);
@ -639,6 +652,7 @@ async function handleLaunchTeam(
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
clearContext: payload.clearContext === true ? true : undefined,
},
(progress) => {
try {
@ -1508,9 +1522,79 @@ async function handleRemoveMember(
const vMember = validateMemberName(memberName);
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
return wrapTeamHandler('removeMember', () =>
getTeamDataService().removeMember(vTeam.value!, vMember.value!)
);
return wrapTeamHandler('removeMember', async () => {
const tn = vTeam.value!;
const name = vMember.value!;
await getTeamDataService().removeMember(tn, name);
// Notify the lead about removed member
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const message =
`Teammate "${name}" has been removed from the team. ` +
`They will no longer participate in team activities. Please reassign their tasks if needed.`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about removal of "${name}" in ${tn}`);
}
}
});
}
async function handleUpdateTaskFields(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
fields: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
if (typeof taskId !== 'string' || !taskId.trim()) {
return { success: false, error: 'taskId must be a non-empty string' };
}
if (!fields || typeof fields !== 'object') {
return { success: false, error: 'fields must be an object' };
}
const { subject, description } = fields as { subject?: unknown; description?: unknown };
if (subject !== undefined) {
if (typeof subject !== 'string') return { success: false, error: 'subject must be a string' };
if (subject.trim().length === 0) return { success: false, error: 'subject cannot be empty' };
if (subject.length > 500)
return { success: false, error: 'subject must be 500 characters or less' };
}
if (description !== undefined && typeof description !== 'string') {
return { success: false, error: 'description must be a string' };
}
const validFields: { subject?: string; description?: string } = {};
if (typeof subject === 'string') validFields.subject = subject;
if (typeof description === 'string') validFields.description = description;
if (Object.keys(validFields).length === 0) {
return { success: false, error: 'At least one field must be provided' };
}
return wrapTeamHandler('updateTaskFields', async () => {
const tn = vTeam.value!;
await getTeamDataService().updateTaskFields(tn, taskId, validFields);
// Notify the lead about updated task fields
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const changedParts: string[] = [];
if (validFields.subject) changedParts.push('title');
if (validFields.description !== undefined) changedParts.push('description');
const message =
`Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
`New title: "${validFields.subject ?? '(unchanged)'}".`;
try {
await provisioning.sendMessageToTeam(tn, message);
} catch {
logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`);
}
}
});
}
async function handleUpdateMemberRole(

View file

@ -10,7 +10,7 @@
import { createLogger } from '@shared/utils/logger';
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import {
type ClaudeMdFileInfo,
@ -77,9 +77,16 @@ function handleGetAppVersion(): string {
* Handler for 'shell:showInFolder' IPC call.
* Reveals a file in the system file manager (Finder/Explorer).
*/
function handleShellShowInFolder(_event: IpcMainInvokeEvent, filePath: string): void {
if (typeof filePath === 'string' && filePath.length > 0 && fs.existsSync(filePath)) {
async function handleShellShowInFolder(
_event: IpcMainInvokeEvent,
filePath: string
): Promise<void> {
if (typeof filePath !== 'string' || filePath.length === 0) return;
try {
await fsp.access(filePath);
shell.showItemInFolder(filePath);
} catch {
// File doesn't exist — silently ignore
}
}
@ -137,8 +144,10 @@ async function handleShellOpenPath(
const safePath = validation.normalizedPath!;
// Check if path exists
if (!fs.existsSync(safePath)) {
// Check if path exists (async to avoid blocking main thread)
try {
await fsp.access(safePath);
} catch {
logger.error(`shell:openPath - path does not exist: ${safePath}`);
return { success: false, error: 'Path does not exist' };
}
@ -224,19 +233,13 @@ async function handleReadMentionedFile(
const safePath = validation.normalizedPath!;
// Check if file exists
if (!fs.existsSync(safePath)) {
return null;
}
// Check if it's a file (not directory)
const stats = fs.statSync(safePath);
// Single async stat + read — no TOCTOU, doesn't block main thread
const stats = await fsp.stat(safePath);
if (!stats.isFile()) {
return null;
}
// Read file content
const content = fs.readFileSync(safePath, 'utf8');
const content = await fsp.readFile(safePath, 'utf8');
// Calculate tokens
const estimatedTokens = countTokens(content);

View file

@ -9,7 +9,7 @@
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
const logger = createLogger('IPC:validation');
@ -75,11 +75,8 @@ async function handleValidatePath(
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
// Single async stat — no TOCTOU, doesn't block the main thread
const stats = await fsp.stat(fullPath);
return {
exists: true,
isDirectory: stats.isDirectory(),
@ -99,21 +96,27 @@ async function handleValidateMentions(
mentions: { type: 'path'; value: string }[],
projectPath: string
): Promise<Record<string, boolean>> {
const results = new Map<string, boolean>();
// Validate all mentions in parallel with async I/O
// (was sequential sync existsSync — blocked main thread per mention)
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = path.join(projectPath, mention.value);
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
try {
await fsp.access(fullPath);
return [`@${mention.value}`, true] as const;
} catch {
return [`@${mention.value}`, false] as const;
}
})
);
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
return Object.fromEntries(results);
return Object.fromEntries(entries);
}
/**

View file

@ -78,6 +78,15 @@ export class ProjectScanner {
{ mtimeMs: number; size: number; preview: { text: string; timestamp: string } | null }
>();
// Short-lived scan cache to prevent duplicate scans within the same request cycle.
// Both getProjects() and getRepositoryGroups() call scan() — the cache deduplicates.
private scanCache: { projects: Project[]; timestamp: number } | null = null;
private static readonly SCAN_CACHE_TTL_MS = 2000;
// Platform-aware batch sizes to avoid UV thread pool saturation on Windows
private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 32 : 128;
private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24;
// Delegated services
private readonly fsProvider: FileSystemProvider;
private readonly sessionContentFilter: typeof SessionContentFilter;
@ -108,6 +117,15 @@ export class ProjectScanner {
* @returns Promise resolving to projects sorted by most recent activity
*/
async scan(): Promise<Project[]> {
// Short-lived cache: prevents duplicate scans when getProjects() and
// getRepositoryGroups() fire in Promise.all() on startup/context switch.
if (
this.scanCache &&
Date.now() - this.scanCache.timestamp < ProjectScanner.SCAN_CACHE_TTL_MS
) {
return this.scanCache.projects;
}
const startedAt = Date.now();
try {
if (!(await this.fsProvider.exists(this.projectsDir))) {
@ -128,7 +146,7 @@ export class ProjectScanner {
// Process each project directory (may return multiple projects per dir)
const projectArrays = await this.collectFulfilledInBatches(
projectDirs,
this.fsProvider.type === 'ssh' ? 8 : 24,
this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH,
async (dir) => this.scanProject(dir.name)
);
@ -142,6 +160,7 @@ export class ProjectScanner {
);
}
this.scanCache = { projects: validProjects, timestamp: Date.now() };
return validProjects;
} catch (error) {
logger.error('Error scanning projects directory:', error);
@ -149,6 +168,14 @@ export class ProjectScanner {
}
}
/**
* Clears the scan cache so the next scan() call reads fresh data.
* Call this when a file change is detected by FileWatcher.
*/
clearScanCache(): void {
this.scanCache = null;
}
// ===========================================================================
// Repository Grouping (Worktree Support)
// ===========================================================================
@ -173,8 +200,33 @@ export class ProjectScanner {
return [];
}
// 2. Delegate to WorktreeGrouper
return this.worktreeGrouper.groupByRepository(projects);
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
// Git identity resolution is bypassed to avoid blocking I/O on startup.
// Each project becomes a single-worktree group.
const groups: RepositoryGroup[] = projects.map((project) => ({
id: project.id,
identity: null,
worktrees: [
{
id: project.id,
path: project.path,
name: project.name,
isMainWorktree: true,
source: 'unknown' as const,
sessions: project.sessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
},
],
name: project.name,
mostRecentSession: project.mostRecentSession,
totalSessions: project.sessions.length,
}));
// Sort by most recent activity (same order as the full git-aware version)
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
return groups;
} catch (error) {
logger.error('Error scanning with worktree grouping:', error);
return [];
@ -226,7 +278,7 @@ export class ProjectScanner {
const shouldSplitByCwd = this.fsProvider.type !== 'ssh';
const sessionInfos = await this.collectFulfilledInBatches(
sessionFiles,
this.fsProvider.type === 'ssh' ? 32 : 128,
this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH,
async (file) => {
const filePath = path.join(projectPath, file.name);
const { mtimeMs, birthtimeMs } = await this.resolveFileDetails(file, filePath);
@ -736,11 +788,8 @@ export class ProjectScanner {
});
}
// Check for subagents and load task list data in parallel
const [hasSubagents, todoData] = await Promise.all([
this.subagentLocator.hasSubagents(projectId, sessionId),
this.loadTodoData(sessionId),
]);
// Check for subagents (todoData skipped here — loaded on-demand in detail view)
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
const metadataLevel: SessionMetadataLevel = 'deep';
const firstMessageTimestampMs = this.parseTimestampMs(metadata.firstUserMessage?.timestamp);
const createdAt =
@ -752,7 +801,6 @@ export class ProjectScanner {
id: sessionId,
projectId,
projectPath,
todoData,
createdAt: Math.floor(createdAt),
firstMessage: metadata.firstUserMessage?.text,
messageTimestamp: metadata.firstUserMessage?.timestamp,
@ -920,15 +968,16 @@ export class ProjectScanner {
async loadTodoData(sessionId: string): Promise<unknown> {
try {
const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId);
if (!(await this.fsProvider.exists(todoPath))) {
return undefined;
}
const content = await this.fsProvider.readFile(todoPath);
return JSON.parse(content) as unknown;
} catch (error) {
// Log but continue - task list data is non-critical
} catch (error: unknown) {
// ENOENT/EACCES = file missing or inaccessible — normal when no todos exist
if (error instanceof Error && 'code' in error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'EACCES') {
return undefined;
}
}
logger.debug(`Failed to load task list data for session ${sessionId}:`, error);
return undefined;
}

View file

@ -43,37 +43,21 @@ export class SubagentLocator {
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (await this.fsProvider.exists(newSubagentsPath)) {
try {
const entries = await this.fsProvider.readdir(newSubagentsPath);
const subagentFiles = entries.filter(
(entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const entry of subagentFiles) {
const filePath = path.join(newSubagentsPath, entry.name);
try {
const stats = await this.fsProvider.stat(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = await this.fsProvider.readFile(filePath);
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
}
} catch {
// Ignore errors
}
try {
const entries = await this.fsProvider.readdir(newSubagentsPath);
// A non-empty agent-*.jsonl file is sufficient proof of subagents.
// readdir() populates size from stat, so no extra I/O needed.
return entries.some(
(entry) =>
entry.name.startsWith('agent-') &&
entry.name.endsWith('.jsonl') &&
typeof entry.size === 'number' &&
entry.size > 0
);
} catch {
// Directory doesn't exist or is unreadable — no subagents
return false;
}
return false;
}
/**
@ -88,37 +72,22 @@ export class SubagentLocator {
hasSubagentsSync(projectId: string, sessionId: string): boolean {
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (fs.existsSync(newSubagentsPath)) {
try {
const entries = fs.readdirSync(newSubagentsPath);
const subagentFiles = entries.filter(
(name) => name.startsWith('agent-') && name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const fileName of subagentFiles) {
const filePath = path.join(newSubagentsPath, fileName);
try {
const stats = fs.statSync(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = fs.readFileSync(filePath, 'utf8');
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
try {
const entries = fs.readdirSync(newSubagentsPath);
// A non-empty agent-*.jsonl file is sufficient proof of subagents.
return entries.some((name) => {
if (!name.startsWith('agent-') || !name.endsWith('.jsonl')) return false;
try {
const stats = fs.statSync(path.join(newSubagentsPath, name));
return stats.size > 0;
} catch {
return false;
}
} catch {
// Ignore errors
}
});
} catch {
// Directory doesn't exist or is unreadable — no subagents
return false;
}
return false;
}
/**

View file

@ -135,33 +135,35 @@ export class WorktreeGrouper {
const repositoryGroups: RepositoryGroup[] = [];
for (const [groupId, group] of repoGroups) {
const worktrees: Worktree[] = group.projects.map((project) => {
const branch = group.branches.get(project.id) ?? null;
const isMainWorktree = !gitIdentityResolver.isWorktree(project.path);
// Use filtered sessions instead of raw sessions
const filteredSessions = projectFilteredSessions.get(project.id) ?? [];
// Detect worktree source for badge display
const source = gitIdentityResolver.detectWorktreeSource(project.path);
// Use source-aware display name generation
const displayName = gitIdentityResolver.getWorktreeDisplayName(
project.path,
source,
branch,
isMainWorktree
);
const worktrees: Worktree[] = await Promise.all(
group.projects.map(async (project) => {
const branch = group.branches.get(project.id) ?? null;
const isMainWorktree = !(await gitIdentityResolver.isWorktree(project.path));
// Use filtered sessions instead of raw sessions
const filteredSessions = projectFilteredSessions.get(project.id) ?? [];
// Detect worktree source for badge display
const source = await gitIdentityResolver.detectWorktreeSource(project.path);
// Use source-aware display name generation
const displayName = await gitIdentityResolver.getWorktreeDisplayName(
project.path,
source,
branch,
isMainWorktree
);
return {
id: project.id,
path: project.path,
name: displayName,
gitBranch: branch ?? undefined,
isMainWorktree,
source,
sessions: filteredSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
};
});
return {
id: project.id,
path: project.path,
name: displayName,
gitBranch: branch ?? undefined,
isMainWorktree,
source,
sessions: filteredSessions,
createdAt: project.createdAt,
mostRecentSession: project.mostRecentSession,
};
})
);
// Filter out worktrees with 0 visible sessions
const nonEmptyWorktrees = worktrees.filter((wt) => wt.sessions.length > 0);

View file

@ -0,0 +1,92 @@
/**
* File watcher for the project editor using chokidar v4.
*
* Watches project directory for external file changes and emits
* normalized events. chokidar handles platform differences (FSEvents on macOS,
* inotify on Linux), recursive watching, and ENOSPC fallback.
*
* Security: paths emitted in events are validated against project root
* before being sent to renderer (SEC-2).
*/
import { isPathWithinRoot } from '@main/utils/pathValidation';
import { createLogger } from '@shared/utils/logger';
import { watch } from 'chokidar';
import type { EditorFileChangeEvent } from '@shared/types/editor';
import type { FSWatcher } from 'chokidar';
const log = createLogger('EditorFileWatcher');
// =============================================================================
// Constants
// =============================================================================
/** Directories to ignore (regex for chokidar's `ignored` option) */
const IGNORED_PATTERN =
/(node_modules|\.git|dist|__pycache__|\.cache|\.next|\.venv|\.tox|vendor|\.DS_Store)/;
const MAX_DEPTH = 20;
// =============================================================================
// Service
// =============================================================================
export class EditorFileWatcher {
private watcher: FSWatcher | null = null;
private projectRoot: string | null = null;
/**
* Start watching a project directory.
* Idempotent: stops any existing watcher first.
*/
start(projectRoot: string, onChange: (event: EditorFileChangeEvent) => void): void {
this.stop();
this.projectRoot = projectRoot;
log.info('Starting file watcher for:', projectRoot);
this.watcher = watch(projectRoot, {
ignored: IGNORED_PATTERN,
ignoreInitial: true,
followSymlinks: false,
depth: MAX_DEPTH,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
// SEC-2: validate path is within project root before sending to renderer
if (!isPathWithinRoot(filePath, projectRoot)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
onChange({ type, path: filePath });
};
this.watcher.on('change', (p) => emitSafe('change', p));
this.watcher.on('add', (p) => emitSafe('create', p));
this.watcher.on('unlink', (p) => emitSafe('delete', p));
this.watcher.on('error', (error) => {
log.error('Watcher error:', error);
});
}
/**
* Stop watching. Safe to call multiple times.
*/
stop(): void {
if (this.watcher) {
log.info('Stopping file watcher');
void this.watcher.close();
this.watcher = null;
}
this.projectRoot = null;
}
/**
* Whether the watcher is currently active.
*/
isWatching(): boolean {
return this.watcher !== null;
}
}

View file

@ -0,0 +1,288 @@
/**
* File search service literal string search across project files.
*
* Security: path containment enforced via isPathWithinRoot. .git/ blocked.
* Performance: max 1000 files, max 1MB/file, 5s timeout via AbortController.
*/
import { isGitInternalPath, isPathWithinRoot } from '@main/utils/pathValidation';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import { isBinaryFile } from 'isbinaryfile';
import * as path from 'path';
import type {
SearchFileResult,
SearchInFilesOptions,
SearchInFilesResult,
SearchMatch,
} from '@shared/types/editor';
// =============================================================================
// Constants
// =============================================================================
const MAX_FILES = 1000;
const MAX_FILE_SIZE = 1024 * 1024; // 1 MB
const DEFAULT_MAX_RESULT_FILES = 100;
const DEFAULT_MAX_MATCHES = 500;
const SEARCH_TIMEOUT_MS = 5000;
const IGNORED_DIRS = new Set([
'.git',
'node_modules',
'.next',
'dist',
'__pycache__',
'.cache',
'.venv',
'.tox',
'vendor',
'build',
'coverage',
'.turbo',
]);
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
const log = createLogger('FileSearchService');
// =============================================================================
// Service
// =============================================================================
export class FileSearchService {
/**
* List all files in the project recursively (for Quick Open).
* Lightweight no content reading, no binary checks, no stat.
* Returns relative paths for display and absolute paths for opening.
*/
async listFiles(
projectRoot: string,
signal?: AbortSignal
): Promise<{ path: string; name: string; relativePath: string }[]> {
const files: { path: string; name: string; relativePath: string }[] = [];
await this.collectFilePaths(projectRoot, projectRoot, files, signal);
return files;
}
/**
* Search for a literal string across project files.
*
* @param projectRoot - Validated project root path
* @param options - Search options (query, caseSensitive, limits)
* @param signal - Optional AbortSignal for cancellation
*/
async searchInFiles(
projectRoot: string,
options: SearchInFilesOptions,
signal?: AbortSignal
): Promise<SearchInFilesResult> {
const { query, caseSensitive = false } = options;
const maxFiles = Math.min(
options.maxFiles ?? DEFAULT_MAX_RESULT_FILES,
DEFAULT_MAX_RESULT_FILES
);
const maxMatches = Math.min(options.maxMatches ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES);
if (!query || query.length === 0) {
return { results: [], totalMatches: 0, truncated: false };
}
const searchQuery = caseSensitive ? query : query.toLowerCase();
// Collect all searchable files
const files: string[] = [];
await this.collectFiles(projectRoot, projectRoot, files, signal);
const results: SearchFileResult[] = [];
let totalMatches = 0;
let truncated = false;
for (const filePath of files) {
if (signal?.aborted) break;
if (results.length >= maxFiles || totalMatches >= maxMatches) {
truncated = true;
break;
}
try {
const matches = await this.searchFile(filePath, searchQuery, caseSensitive, signal);
if (matches.length > 0) {
const remaining = maxMatches - totalMatches;
const trimmedMatches = matches.length > remaining ? matches.slice(0, remaining) : matches;
results.push({ filePath, matches: trimmedMatches });
totalMatches += trimmedMatches.length;
if (totalMatches >= maxMatches) {
truncated = true;
}
}
} catch {
// Skip files that can't be read
}
}
return { results, totalMatches, truncated };
}
/**
* Lightweight recursive file path collection (no stat, no binary check).
* Used by listFiles() for Quick Open needs to be fast.
*/
private async collectFilePaths(
projectRoot: string,
dirPath: string,
files: { path: string; name: string; relativePath: string }[],
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted || files.length >= MAX_FILES) return;
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return;
}
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break;
const fullPath = path.join(dirPath, entry.name);
if (!isPathWithinRoot(fullPath, projectRoot)) continue;
if (isGitInternalPath(fullPath)) continue;
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFilePaths(projectRoot, fullPath, files, signal);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
const relativePath = fullPath.startsWith(projectRoot)
? fullPath.slice(projectRoot.length + 1)
: entry.name;
files.push({ path: fullPath, name: entry.name, relativePath });
}
}
}
/**
* Recursively collect all searchable files.
*/
private async collectFiles(
projectRoot: string,
dirPath: string,
files: string[],
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted || files.length >= MAX_FILES) return;
let entries;
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
return; // Permission denied or not a directory
}
// Sort: files first for early results
const sorted = [...entries].sort((a, b) => {
if (a.isFile() && !b.isFile()) return -1;
if (!a.isFile() && b.isFile()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of sorted) {
if (signal?.aborted || files.length >= MAX_FILES) break;
const fullPath = path.join(dirPath, entry.name);
// Security: containment check
if (!isPathWithinRoot(fullPath, projectRoot)) continue;
// Block .git internal paths
if (isGitInternalPath(fullPath)) continue;
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
await this.collectFiles(projectRoot, fullPath, files, signal);
} else if (entry.isFile()) {
if (IGNORED_FILES.has(entry.name)) continue;
// Skip files > 1MB
try {
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE) continue;
} catch {
continue;
}
// Skip binary files (quick check via first 512 bytes)
try {
if (await isBinaryFile(fullPath)) continue;
} catch {
continue;
}
files.push(fullPath);
}
}
}
/**
* Search a single file for literal string matches.
*/
private async searchFile(
filePath: string,
query: string,
caseSensitive: boolean,
signal?: AbortSignal
): Promise<SearchMatch[]> {
if (signal?.aborted) return [];
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
const matches: SearchMatch[] = [];
for (let i = 0; i < lines.length; i++) {
if (signal?.aborted) break;
const line = lines[i];
const searchLine = caseSensitive ? line : line.toLowerCase();
let startIndex = 0;
while (true) {
const idx = searchLine.indexOf(query, startIndex);
if (idx === -1) break;
matches.push({
line: i + 1,
column: idx,
lineContent: line.trim(),
});
startIndex = idx + query.length;
}
}
return matches;
}
}
/**
* Create an AbortController with automatic timeout.
*/
export function createSearchAbortController(): AbortController {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
log.warn('Search timed out after', SEARCH_TIMEOUT_MS, 'ms');
}, SEARCH_TIMEOUT_MS);
// Clean up timeout when aborted by other means
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true });
return controller;
}

View file

@ -0,0 +1,138 @@
/**
* Git status service for the project editor.
*
* Uses `simple-git` with --no-optional-locks (GIT_OPTIONAL_LOCKS=0) to prevent
* .git/index.lock conflicts during background queries.
* Results are cached for 5 seconds; invalidated on file watcher events.
*/
import { createLogger } from '@shared/utils/logger';
import { simpleGit } from 'simple-git';
import type { GitFileStatus, GitStatusResult } from '@shared/types/editor';
import type { SimpleGit, StatusResult } from 'simple-git';
const log = createLogger('GitStatusService');
// =============================================================================
// Constants
// =============================================================================
const GIT_TIMEOUT_MS = 10_000;
const CACHE_TTL_MS = 5_000;
// =============================================================================
// Service
// =============================================================================
export class GitStatusService {
private git: SimpleGit | null = null;
private projectRoot: string | null = null;
// Cache
private cachedResult: GitStatusResult | null = null;
private cacheTimestamp = 0;
/**
* Initialize service for a project root.
* Creates a simple-git instance with --no-optional-locks and timeout.
*/
init(projectRoot: string): void {
this.projectRoot = projectRoot;
this.git = simpleGit({
baseDir: projectRoot,
timeout: { block: GIT_TIMEOUT_MS },
}).env('GIT_OPTIONAL_LOCKS', '0');
this.invalidateCache();
}
/**
* Reset service state.
*/
destroy(): void {
this.git = null;
this.projectRoot = null;
this.cachedResult = null;
this.cacheTimestamp = 0;
}
/**
* Invalidate cached status (e.g. on file watcher event).
*/
invalidateCache(): void {
this.cachedResult = null;
this.cacheTimestamp = 0;
}
/**
* Get git status for the current project.
* Returns cached result if within TTL.
*/
async getStatus(): Promise<GitStatusResult> {
if (!this.git || !this.projectRoot) {
return { files: [], isGitRepo: false, branch: null };
}
// Return cached if fresh
if (this.cachedResult && Date.now() - this.cacheTimestamp < CACHE_TTL_MS) {
return this.cachedResult;
}
try {
// Check if it's a git repo first
const isRepo = await this.isGitRepo();
if (!isRepo) {
const result: GitStatusResult = { files: [], isGitRepo: false, branch: null };
this.setCacheResult(result);
return result;
}
const statusResult = await this.git.status();
const files = mapStatusResult(statusResult);
const branch = statusResult.current ?? null;
const result: GitStatusResult = { files, isGitRepo: true, branch };
this.setCacheResult(result);
return result;
} catch (error) {
log.error('Failed to get git status:', error);
// Graceful degradation: return empty non-repo result
return { files: [], isGitRepo: false, branch: null };
}
}
private async isGitRepo(): Promise<boolean> {
if (!this.git) return false;
try {
await this.git.revparse(['--is-inside-work-tree']);
return true;
} catch {
return false;
}
}
private setCacheResult(result: GitStatusResult): void {
this.cachedResult = result;
this.cacheTimestamp = Date.now();
}
}
// =============================================================================
// Mapping
// =============================================================================
/**
* Map simple-git StatusResult to our GitFileStatus[] format.
*/
export function mapStatusResult(result: StatusResult): GitFileStatus[] {
const files: GitFileStatus[] = [];
for (const p of result.modified) files.push({ path: p, status: 'modified' });
for (const p of result.not_added) files.push({ path: p, status: 'untracked' });
for (const p of result.staged) files.push({ path: p, status: 'staged' });
for (const p of result.deleted) files.push({ path: p, status: 'deleted' });
for (const p of result.conflicted) files.push({ path: p, status: 'conflict' });
for (const r of result.renamed) {
files.push({ path: r.to, status: 'renamed', renamedFrom: r.from });
}
return files;
}

View file

@ -0,0 +1,565 @@
/**
* Stateless file service for the project editor.
*
* Every method receives `projectRoot` as the first argument.
* Security: path containment, symlink escape detection, device path blocking,
* binary detection, and size limits are enforced on every call.
*/
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import {
isDevicePath,
isGitInternalPath,
isPathWithinAllowedDirectories,
isPathWithinRoot,
matchesSensitivePattern,
validateFileName,
validateFilePath,
} from '@main/utils/pathValidation';
import { createLogger } from '@shared/utils/logger';
import { shell } from 'electron';
import * as fs from 'fs/promises';
import { isBinaryFile } from 'isbinaryfile';
import * as path from 'path';
import type {
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
FileTreeEntry,
MoveFileResponse,
ReadDirResult,
ReadFileResult,
WriteFileResponse,
} from '@shared/types/editor';
// =============================================================================
// Constants
// =============================================================================
const MAX_FILE_SIZE_FULL = 2 * 1024 * 1024; // 2 MB
const MAX_FILE_SIZE_PREVIEW = 5 * 1024 * 1024; // 5 MB
const MAX_WRITE_SIZE = 2 * 1024 * 1024; // 2 MB
const MAX_DIR_ENTRIES = 500;
const PREVIEW_LINE_COUNT = 100;
const IGNORED_DIRS = new Set([
'.git',
'node_modules',
'.next',
'dist',
'__pycache__',
'.cache',
'.venv',
'.tox',
'vendor',
]);
const IGNORED_FILES = new Set(['.DS_Store', 'Thumbs.db']);
const log = createLogger('ProjectFileService');
// =============================================================================
// Service
// =============================================================================
export class ProjectFileService {
/**
* Read a directory listing (depth=1, lazy loading).
*
* Security:
* - Containment via isPathWithinAllowedDirectories (NOT validateFilePath sensitive files
* are shown with isSensitive flag, not filtered)
* - Symlinks: realpath + re-check containment, silently skip escapes (SEC-2)
*/
async readDir(
projectRoot: string,
dirPath: string,
maxEntries: number = MAX_DIR_ENTRIES
): Promise<ReadDirResult> {
const normalizedDir = path.resolve(dirPath);
// Containment check (allow sensitive files to be listed with flag)
if (!isPathWithinAllowedDirectories(normalizedDir, projectRoot)) {
throw new Error('Directory is outside project root');
}
const stat = await fs.lstat(normalizedDir);
if (!stat.isDirectory()) {
throw new Error('Not a directory');
}
const dirents = await fs.readdir(normalizedDir, { withFileTypes: true });
const entries: FileTreeEntry[] = [];
let truncated = false;
for (const dirent of dirents) {
// Ignore well-known noise
if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue;
if (dirent.isFile() && IGNORED_FILES.has(dirent.name)) continue;
const entryPath = path.join(normalizedDir, dirent.name);
// Symlink handling: resolve and re-check containment
if (dirent.isSymbolicLink()) {
try {
const realPath = await fs.realpath(entryPath);
if (!isPathWithinAllowedDirectories(realPath, projectRoot)) {
continue; // Silently skip symlinks that escape project root (SEC-2)
}
const realStat = await fs.stat(realPath);
const entry = this.buildEntry(
dirent.name,
entryPath,
realStat.isDirectory() ? 'directory' : 'file',
realStat.isFile() ? realStat.size : undefined
);
entries.push(entry);
} catch {
// Broken symlink — skip silently
continue;
}
} else if (dirent.isDirectory()) {
entries.push(this.buildEntry(dirent.name, entryPath, 'directory'));
} else if (dirent.isFile()) {
try {
const fileStat = await fs.stat(entryPath);
entries.push(this.buildEntry(dirent.name, entryPath, 'file', fileStat.size));
} catch {
// Can't stat — include without size
entries.push(this.buildEntry(dirent.name, entryPath, 'file'));
}
}
// Skip other types (block devices, sockets, etc.)
if (entries.length >= maxEntries) {
truncated = true;
break;
}
}
// Sort: directories first, then alphabetical
entries.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries, truncated };
}
/**
* Read file content with security checks and binary detection.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Device path blocking (SEC-4)
* - lstat + isFile check (SEC-4)
* - Size limits (SEC-4)
* - Post-read TOCTOU realpath verify (SEC-3)
*/
async readFile(projectRoot: string, filePath: string): Promise<ReadFileResult> {
// 1. Path validation (traversal, sensitive, symlink)
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot read device files');
}
// 3. File type check
const stats = await fs.lstat(normalizedPath);
if (!stats.isFile()) {
throw new Error('Not a regular file');
}
// 4. Size check — reject files beyond preview limit
if (stats.size > MAX_FILE_SIZE_PREVIEW) {
throw new Error(
`File too large (${(stats.size / 1024 / 1024).toFixed(1)}MB). Open in external editor.`
);
}
// 5. Binary check
const binary = await isBinaryFile(normalizedPath);
if (binary) {
return {
content: '',
size: stats.size,
mtimeMs: stats.mtimeMs,
truncated: false,
encoding: 'binary',
isBinary: true,
};
}
// 6. Read content
const raw = await fs.readFile(normalizedPath, 'utf8');
// 7. Post-read TOCTOU verify
const realPath = await fs.realpath(normalizedPath);
const postValidation = validateFilePath(realPath, projectRoot);
if (!postValidation.valid) {
throw new Error('Path changed during read (TOCTOU)');
}
// 8. Tiered response
const isPreview = stats.size > MAX_FILE_SIZE_FULL;
const content = isPreview ? raw.split('\n').slice(0, PREVIEW_LINE_COUNT).join('\n') : raw;
return {
content,
size: stats.size,
mtimeMs: stats.mtimeMs,
truncated: isPreview,
encoding: 'utf-8',
isBinary: false,
};
}
/**
* Write file content with atomic write and full security checks.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - Project-only containment block writes outside projectRoot (SEC-14)
* - Block .git/ internal paths (SEC-12)
* - Device path blocking (SEC-4)
* - Content size limit (2MB)
* - Atomic write via tmp + rename (SEC-9)
*/
async writeFile(
projectRoot: string,
filePath: string,
content: string
): Promise<WriteFileResponse> {
// 1. Path validation
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Project-only containment (SEC-14: block ~/.claude writes)
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 3. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(normalizedPath)) {
throw new Error('Cannot write to .git/ directory');
}
// 4. Device path block
if (isDevicePath(normalizedPath)) {
throw new Error('Cannot write to device files');
}
// 5. Content size check
const byteLength = Buffer.byteLength(content, 'utf8');
if (byteLength > MAX_WRITE_SIZE) {
throw new Error(
`Content too large (${(byteLength / 1024 / 1024).toFixed(1)}MB). Maximum is 2MB.`
);
}
// 6. Atomic write
await atomicWriteAsync(normalizedPath, content);
// 7. Get post-write stats
const stats = await fs.stat(normalizedPath);
log.info('File saved:', normalizedPath, `(${stats.size} bytes)`);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
}
/**
* Create a new empty file.
*
* Security:
* - validateFileName for traversal, control chars (SEC-1)
* - validateFilePath for parent containment (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ writes (SEC-12)
* - Check parent is directory, file does NOT exist
*/
async createFile(
projectRoot: string,
parentDir: string,
fileName: string
): Promise<CreateFileResponse> {
// 1. Validate file name
const nameValidation = validateFileName(fileName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate parent directory path
const parentValidation = validateFilePath(parentDir, projectRoot);
if (!parentValidation.valid) {
throw new Error(parentValidation.error);
}
const normalizedParent = parentValidation.normalizedPath!;
// 3. Build full path
const fullPath = path.join(normalizedParent, fileName.trim());
// 4. Project-only containment (SEC-14)
if (!isPathWithinRoot(fullPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 5. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(fullPath)) {
throw new Error('Cannot create files in .git/ directory');
}
// 6. Verify parent is a directory
const parentStat = await fs.lstat(normalizedParent);
if (!parentStat.isDirectory()) {
throw new Error('Parent path is not a directory');
}
// 7. Verify file does NOT exist
try {
await fs.access(fullPath);
throw new Error('File already exists');
} catch (err) {
// Expected: ENOENT means file doesn't exist (good)
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err; // Re-throw 'File already exists' or other errors
}
}
// 8. Create empty file
await fs.writeFile(fullPath, '', 'utf8');
// 9. Get stats
const stats = await fs.stat(fullPath);
log.info('File created:', fullPath);
return { filePath: fullPath, mtimeMs: stats.mtimeMs };
}
/**
* Create a new directory.
*
* Same security checks as createFile, but uses fs.mkdir.
*/
async createDir(
projectRoot: string,
parentDir: string,
dirName: string
): Promise<CreateDirResponse> {
// 1. Validate directory name
const nameValidation = validateFileName(dirName);
if (!nameValidation.valid) {
throw new Error(nameValidation.error);
}
// 2. Validate parent directory path
const parentValidation = validateFilePath(parentDir, projectRoot);
if (!parentValidation.valid) {
throw new Error(parentValidation.error);
}
const normalizedParent = parentValidation.normalizedPath!;
// 3. Build full path
const fullPath = path.join(normalizedParent, dirName.trim());
// 4. Project-only containment (SEC-14)
if (!isPathWithinRoot(fullPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 5. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(fullPath)) {
throw new Error('Cannot create directories in .git/ directory');
}
// 6. Verify parent is a directory
const parentStat = await fs.lstat(normalizedParent);
if (!parentStat.isDirectory()) {
throw new Error('Parent path is not a directory');
}
// 7. Verify directory does NOT exist
try {
await fs.access(fullPath);
throw new Error('Directory already exists');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 8. Create directory
await fs.mkdir(fullPath);
log.info('Directory created:', fullPath);
return { dirPath: fullPath };
}
/**
* Delete a file or directory by moving it to the system Trash.
*
* Security:
* - validateFilePath for containment (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ deletes (SEC-12)
* - Uses shell.trashItem for safe, reversible deletion
*/
async deleteFile(projectRoot: string, filePath: string): Promise<DeleteFileResponse> {
// 1. Validate file path
const validation = validateFilePath(filePath, projectRoot);
if (!validation.valid) {
throw new Error(validation.error);
}
const normalizedPath = validation.normalizedPath!;
// 2. Project-only containment (SEC-14)
if (!isPathWithinRoot(normalizedPath, projectRoot)) {
throw new Error('Path is outside project root');
}
// 3. Block .git/ internal paths (SEC-12)
if (isGitInternalPath(normalizedPath)) {
throw new Error('Cannot delete files in .git/ directory');
}
// 4. Verify path exists
await fs.lstat(normalizedPath);
// 5. Move to Trash (safe, reversible)
await shell.trashItem(normalizedPath);
log.info('File moved to Trash:', normalizedPath);
return { deletedPath: normalizedPath };
}
/**
* Move a file or directory to a new location within the project.
*
* Security:
* - validateFilePath for traversal + sensitive check (SEC-1)
* - isPathWithinRoot for project-only containment (SEC-14)
* - isGitInternalPath to block .git/ moves (SEC-12)
* - Parent child move prevention
* - Name collision detection
* - EXDEV cross-device fallback (fs.cp + fs.rm)
*/
async moveFile(
projectRoot: string,
sourcePath: string,
destDir: string
): Promise<MoveFileResponse> {
// 1. Validate source path
const srcValidation = validateFilePath(sourcePath, projectRoot);
if (!srcValidation.valid) {
throw new Error(srcValidation.error);
}
const normalizedSrc = srcValidation.normalizedPath!;
// 2. Validate dest directory path
const destValidation = validateFilePath(destDir, projectRoot);
if (!destValidation.valid) {
throw new Error(destValidation.error);
}
const normalizedDest = destValidation.normalizedPath!;
// 3. Project containment (SEC-14)
if (!isPathWithinRoot(normalizedSrc, projectRoot)) {
throw new Error('Source path is outside project root');
}
if (!isPathWithinRoot(normalizedDest, projectRoot)) {
throw new Error('Destination path is outside project root');
}
// 4. Block .git/ paths (SEC-12)
if (isGitInternalPath(normalizedSrc)) {
throw new Error('Cannot move files from .git/ directory');
}
if (isGitInternalPath(normalizedDest)) {
throw new Error('Cannot move files into .git/ directory');
}
// 5. Verify source exists and determine type
const srcStat = await fs.lstat(normalizedSrc);
const isDirectory = srcStat.isDirectory();
// 6. Verify destination is a directory
const destStat = await fs.lstat(normalizedDest);
if (!destStat.isDirectory()) {
throw new Error('Destination is not a directory');
}
// 7. Build new path
const newPath = path.join(normalizedDest, path.basename(normalizedSrc));
// 8. Prevent parent → child move (moving dir into itself)
if (normalizedDest.startsWith(normalizedSrc + '/') || normalizedDest === normalizedSrc) {
throw new Error('Cannot move a directory into itself');
}
// 9. Check destination doesn't already exist
try {
await fs.access(newPath);
throw new Error('File already exists at destination');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err;
}
}
// 10. Block sensitive destination
if (matchesSensitivePattern(newPath)) {
throw new Error('Cannot move to sensitive file location');
}
// 11. Perform rename with EXDEV fallback
try {
await fs.rename(normalizedSrc, newPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
const stat = await fs.lstat(normalizedSrc);
if (stat.isDirectory()) {
await fs.cp(normalizedSrc, newPath, { recursive: true });
} else {
await fs.copyFile(normalizedSrc, newPath);
}
await fs.rm(normalizedSrc, { recursive: true, force: true });
} else {
throw err;
}
}
log.info('File moved:', normalizedSrc, '→', newPath);
return { newPath, isDirectory };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private buildEntry(
name: string,
entryPath: string,
type: 'file' | 'directory',
size?: number
): FileTreeEntry {
const entry: FileTreeEntry = { name, path: entryPath, type };
if (size !== undefined) entry.size = size;
if (matchesSensitivePattern(entryPath)) entry.isSensitive = true;
return entry;
}
}
export { MAX_DIR_ENTRIES, MAX_FILE_SIZE_FULL, MAX_FILE_SIZE_PREVIEW, MAX_WRITE_SIZE };

View file

@ -0,0 +1,52 @@
/**
* Conflict detection utility for the project editor.
*
* Checks if a file has been modified externally since the last known mtime.
* Used before saving to prevent silently overwriting external changes.
*/
import * as fs from 'fs/promises';
// =============================================================================
// Types
// =============================================================================
export interface ConflictCheckResult {
/** True if the file was modified externally */
hasConflict: boolean;
/** Current mtime on disk */
currentMtimeMs: number;
/** True if the file no longer exists on disk */
deleted: boolean;
}
// =============================================================================
// Functions
// =============================================================================
/**
* Check if a file has been modified since the given baseline mtime.
*
* @param filePath - Absolute path to the file
* @param baselineMtimeMs - Last known mtime (from readFile result)
* @returns Conflict check result
*/
export async function checkFileConflict(
filePath: string,
baselineMtimeMs: number
): Promise<ConflictCheckResult> {
try {
const stats = await fs.stat(filePath);
const currentMtimeMs = stats.mtimeMs;
// Allow 1ms tolerance for filesystem rounding
const hasConflict = Math.abs(currentMtimeMs - baselineMtimeMs) > 1;
return { hasConflict, currentMtimeMs, deleted: false };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { hasConflict: true, currentMtimeMs: 0, deleted: true };
}
throw error;
}
}

View file

@ -0,0 +1,5 @@
export { checkFileConflict } from './conflictDetection';
export { EditorFileWatcher } from './EditorFileWatcher';
export { createSearchAbortController, FileSearchService } from './FileSearchService';
export { GitStatusService, mapStatusResult } from './GitStatusService';
export { ProjectFileService } from './ProjectFileService';

View file

@ -17,16 +17,16 @@
* - Human-readable error messages per phase
*/
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { getHomeDir } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import http from 'http';
import https from 'https';
import { tmpdir } from 'os';
import { join } from 'path';
import { promisify } from 'util';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
@ -36,10 +36,6 @@ import type { IncomingMessage } from 'http';
const logger = createLogger('CliInstallerService');
// Note: execFile (not exec) is used intentionally — no shell injection risk.
// Arguments are passed as arrays, never interpolated into shell strings.
const execFileAsync = promisify(execFile);
// =============================================================================
// Constants
// =============================================================================
@ -58,46 +54,96 @@ const INSTALL_TIMEOUT_MS = 120_000;
/** Max redirects to follow when fetching from GCS */
const MAX_REDIRECTS = 5;
/** Socket timeout for HTTP requests — covers DNS + TCP + TLS + first byte (ms) */
const HTTP_CONNECT_TIMEOUT_MS = 15_000;
/** Overall timeout for getStatus() to prevent UI hanging indefinitely (ms) */
const GET_STATUS_TIMEOUT_MS = 25_000;
/** Max retries for EBUSY (antivirus scanning the new binary) */
const EBUSY_MAX_RETRIES = 3;
/** Delay between EBUSY retries (multiplied by attempt number) */
const EBUSY_RETRY_DELAY_MS = 2000;
/**
* Build env for child processes with correct HOME.
* On Windows with non-ASCII usernames, process.env may have a broken HOME/USERPROFILE.
* getHomeDir() uses Electron's app.getPath('home') which handles Unicode correctly.
*/
function buildChildEnv(): NodeJS.ProcessEnv {
const home = getHomeDir();
return {
...process.env,
HOME: home,
USERPROFILE: home,
};
}
// =============================================================================
// Helpers
// =============================================================================
/**
* Follow redirects manually for https.get (Node https does NOT auto-follow).
* Includes a socket-level timeout covering DNS + TCP connect + TLS + first byte.
*/
function httpsGetFollowRedirects(
url: string,
redirectsLeft = MAX_REDIRECTS
redirectsLeft = MAX_REDIRECTS,
timeoutMs = HTTP_CONNECT_TIMEOUT_MS
): Promise<IncomingMessage> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
let settled = false;
transport
.get(url, (res) => {
const status = res.statusCode ?? 0;
const settleResolve = (value: IncomingMessage): void => {
if (settled) return;
settled = true;
resolve(value);
};
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
const settleReject = (err: Error): void => {
if (settled) return;
settled = true;
reject(err);
};
const req = transport.get(url, (res) => {
const status = res.statusCode ?? 0;
if (status >= 300 && status < 400 && res.headers.location) {
if (redirectsLeft <= 0) {
res.destroy();
httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1).then(resolve, reject);
settleReject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).toString();
res.destroy();
httpsGetFollowRedirects(redirectUrl, redirectsLeft - 1, timeoutMs).then(
settleResolve,
settleReject
);
return;
}
if (status !== 200) {
res.destroy();
reject(new Error(`HTTP ${status} fetching ${url}`));
return;
}
if (status !== 200) {
res.destroy();
settleReject(new Error(`HTTP ${status} fetching ${url}`));
return;
}
resolve(res);
})
.on('error', reject);
settleResolve(res);
});
// Socket-level timeout: fires if the socket is idle for timeoutMs at any point
// during DNS resolution, TCP connect, TLS handshake, or waiting for response headers.
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
});
req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
});
}
@ -195,18 +241,44 @@ export class CliInstallerService {
authMethod: null,
};
// Run the actual status gathering with an overall timeout.
// On timeout, return whatever partial result was collected so far.
const ref = { current: result };
await Promise.race([
this.gatherStatus(ref),
new Promise<void>((resolve) =>
setTimeout(() => {
logger.warn(
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
);
resolve();
}, GET_STATUS_TIMEOUT_MS)
),
]);
return result;
}
/**
* Gathers CLI status information, mutating the provided result object.
* Split from getStatus() to enable overall timeout via Promise.race
* on timeout, getStatus() returns whatever fields were populated so far.
*/
private async gatherStatus(ref: { current: CliInstallationStatus }): Promise<void> {
const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) {
result.installed = true;
result.binaryPath = binaryPath;
r.installed = true;
r.binaryPath = binaryPath;
try {
const { stdout } = await execFileAsync(binaryPath, ['--version'], {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
});
result.installedVersion = normalizeVersion(stdout);
r.installedVersion = normalizeVersion(stdout);
logger.info(
`Installed CLI version: "${stdout.trim()}" → normalized: "${result.installedVersion}"`
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
);
} catch (err) {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
@ -214,39 +286,34 @@ export class CliInstallerService {
// Check auth status
try {
const { stdout: authStdout } = await execFileAsync(binaryPath, ['auth', 'status'], {
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(),
});
const auth = JSON.parse(authStdout.trim()) as { loggedIn?: boolean; authMethod?: string };
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}`
);
r.authLoggedIn = auth.loggedIn === true;
r.authMethod = auth.authMethod ?? null;
logger.info(`Auth status: loggedIn=${r.authLoggedIn}, method=${r.authMethod ?? 'null'}`);
} catch (err) {
logger.warn('Failed to check auth status:', getErrorMessage(err));
result.authLoggedIn = false;
r.authLoggedIn = false;
}
}
try {
const latestRaw = await fetchText(`${GCS_BASE}/latest`);
result.latestVersion = normalizeVersion(latestRaw);
logger.info(
`Latest CLI version: "${latestRaw.trim()}" → normalized: "${result.latestVersion}"`
);
r.latestVersion = normalizeVersion(latestRaw);
logger.info(`Latest CLI version: "${latestRaw.trim()}" → normalized: "${r.latestVersion}"`);
if (result.installedVersion && result.latestVersion) {
result.updateAvailable = isVersionOlder(result.installedVersion, result.latestVersion);
if (r.installedVersion && r.latestVersion) {
r.updateAvailable = isVersionOlder(r.installedVersion, r.latestVersion);
logger.info(
`Update available: ${result.updateAvailable} (${result.installedVersion}${result.latestVersion})`
`Update available: ${r.updateAvailable} (${r.installedVersion}${r.latestVersion})`
);
}
} catch (err) {
logger.warn('Failed to fetch latest CLI version:', getErrorMessage(err));
}
return result;
}
// ---------------------------------------------------------------------------
@ -331,7 +398,17 @@ export class CliInstallerService {
await fsp.chmod(tmpFilePath, 0o755);
}
this.sendProgress({ type: 'installing', detail: 'Starting shell integration...' });
// On Windows, antivirus (Defender) scans new executables on first access.
// A brief pause lets the scan complete before we spawn, preventing EBUSY.
if (process.platform === 'win32') {
await new Promise((r) => setTimeout(r, 1000));
}
this.sendProgress({
type: 'installing',
detail: 'Starting shell integration...',
rawChunk: 'Starting shell integration...\r\n',
});
logger.info('Running claude install...');
try {
@ -407,7 +484,11 @@ export class CliInstallerService {
});
res.on('end', () => {
fileStream.end(() => resolve(hash.digest('hex')));
const digest = hash.digest('hex');
fileStream.end();
// Wait for 'close' (not just 'finish') — ensures file descriptor is fully released.
// On Windows, spawning the file before 'close' can cause EBUSY.
fileStream.on('close', () => resolve(digest));
});
res.on('error', (err) => {
@ -425,16 +506,17 @@ export class CliInstallerService {
/**
* Run `claude install` via spawn with streaming output.
* Collects all output for error context. Non-zero exit tolerated if binary resolves.
* Retries on EBUSY (antivirus scanning the new binary).
*/
private async runInstallWithStreaming(binaryPath: string): Promise<void> {
private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(binaryPath, ['install'], {
env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' },
const child = spawnCli(binaryPath, ['install'], {
env: { ...buildChildEnv(), CLAUDE_SKIP_ANALYTICS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeout = setTimeout(() => {
child.kill();
killProcessTree(child);
reject(
new Error(
`Timed out after ${INSTALL_TIMEOUT_MS / 1000}s. ` +
@ -446,14 +528,19 @@ export class CliInstallerService {
const outputLines: string[] = [];
const handleOutput = (chunk: Buffer): void => {
const text = chunk.toString('utf-8').trim();
if (!text) return;
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
outputLines.push(trimmed);
logger.info(`[claude install] ${trimmed}`);
this.sendProgress({ type: 'installing', detail: trimmed });
const raw = chunk.toString('utf-8');
if (!raw.trim()) return;
// Send raw chunk for xterm.js rendering in UI
this.sendProgress({ type: 'installing', rawChunk: raw });
// Extract clean text for logger and error context
for (const line of raw.split('\n')) {
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- ANSI escape sequences stripped for clean logs
const clean = line.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').trim();
if (clean) {
outputLines.push(clean);
logger.info(`[claude install] ${clean}`);
}
}
};
@ -482,6 +569,24 @@ export class CliInstallerService {
child.on('error', (err) => {
clearTimeout(timeout);
// EBUSY: antivirus (Windows Defender / macOS Gatekeeper) may be scanning the binary — retry
const isEbusy = (err as NodeJS.ErrnoException).code === 'EBUSY';
if (isEbusy && attempt < EBUSY_MAX_RETRIES) {
const delayMs = attempt * EBUSY_RETRY_DELAY_MS;
logger.warn(
`spawn EBUSY (attempt ${attempt}/${EBUSY_MAX_RETRIES}), retrying in ${delayMs}ms...`
);
this.sendProgress({
type: 'installing',
rawChunk: `\r\n⏳ File busy (OS scan), retrying in ${delayMs / 1000}s...\r\n`,
});
setTimeout(() => {
this.runInstallWithStreaming(binaryPath, attempt + 1).then(resolve, reject);
}, delayMs);
return;
}
reject(err);
});
});

View file

@ -9,11 +9,11 @@
* - Handle JSON parse errors gracefully
*/
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { getHomeDir, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as os from 'os';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';
@ -23,7 +23,7 @@ import type { SshConnectionProfile } from '@shared/types/api';
const logger = createLogger('Service:ConfigManager');
const CONFIG_DIR = path.join(os.homedir(), '.claude');
const CONFIG_DIR = path.join(getHomeDir(), '.claude');
const CONFIG_FILENAME = 'claude-devtools-config.json';
const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME);
@ -353,21 +353,21 @@ export class ConfigManager {
/**
* Loads configuration from disk.
* Returns default config if file doesn't exist or is invalid.
* Uses a single readFileSync (no TOCTOU from existsSync + readFileSync).
*/
private loadConfig(): AppConfig {
try {
if (!fs.existsSync(this.configPath)) {
logger.info('No config file found, using defaults');
return this.deepClone(DEFAULT_CONFIG);
}
const content = fs.readFileSync(this.configPath, 'utf8');
const parsed = JSON.parse(content) as Partial<AppConfig>;
// Merge with defaults to ensure all fields exist
return this.mergeWithDefaults(parsed);
} catch (error) {
logger.error('Error loading config, using defaults:', error);
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info('No config file found, using defaults');
} else {
logger.error('Error loading config, using defaults:', error);
}
return this.deepClone(DEFAULT_CONFIG);
}
}
@ -385,16 +385,18 @@ export class ConfigManager {
}
/**
* Persists configuration to the canonical path.
* Persists configuration to the canonical path asynchronously.
* Uses async I/O to avoid blocking the main process event loop.
* mkdir({ recursive: true }) is idempotent no need for an existsSync guard.
*/
private persistConfig(config: AppConfig): void {
const configDir = path.dirname(this.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
const content = JSON.stringify(config, null, 2);
fs.writeFileSync(this.configPath, content, 'utf8');
fsp
.mkdir(path.dirname(this.configPath), { recursive: true })
.then(() => fsp.writeFile(this.configPath, content, 'utf8'))
.catch((error) => {
logger.error('Error persisting config:', error);
});
}
/**

View file

@ -21,6 +21,7 @@ import {
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { projectPathResolver } from '../discovery/ProjectPathResolver';
@ -149,7 +150,9 @@ export class FileWatcher extends EventEmitter {
if (this.fsProvider.type === 'ssh') {
this.startPollingMode();
} else {
this.ensureWatchers();
// Fire-and-forget: ensureWatchers is now async to avoid blocking the event loop
// with synchronous fs.existsSync() calls during startup.
void this.ensureWatchers();
}
this.startCatchUpTimer();
}
@ -278,18 +281,21 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the projects directory watcher.
*/
private startProjectsWatcher(): void {
private async startProjectsWatcher(): Promise<void> {
if (this.projectsWatcher) {
return;
}
try {
if (!fs.existsSync(this.projectsPath)) {
if (!(await this.pathExists(this.projectsPath))) {
logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`);
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.projectsWatcher = fs.watch(
this.projectsPath,
{ recursive: true },
@ -312,18 +318,21 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the todos directory watcher.
*/
private startTodosWatcher(): void {
private async startTodosWatcher(): Promise<void> {
if (this.todosWatcher) {
return;
}
try {
if (!fs.existsSync(this.todosPath)) {
if (!(await this.pathExists(this.todosPath))) {
// Todos directory may not exist yet - that's OK
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => {
if (filename) {
this.handleTodosChange(eventType, filename);
@ -342,17 +351,20 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the teams directory watcher.
*/
private startTeamsWatcher(): void {
private async startTeamsWatcher(): Promise<void> {
if (this.teamsWatcher) {
return;
}
try {
if (!fs.existsSync(this.teamsPath)) {
if (!(await this.pathExists(this.teamsPath))) {
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.teamsWatcher = fs.watch(this.teamsPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTeamsChange(eventType, filename);
@ -370,17 +382,20 @@ export class FileWatcher extends EventEmitter {
/**
* Starts the tasks directory watcher.
*/
private startTasksWatcher(): void {
private async startTasksWatcher(): Promise<void> {
if (this.tasksWatcher) {
return;
}
try {
if (!fs.existsSync(this.tasksPath)) {
if (!(await this.pathExists(this.tasksPath))) {
this.scheduleWatcherRetry();
return;
}
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => {
if (filename) {
this.handleTasksChange(eventType, filename);
@ -395,15 +410,42 @@ export class FileWatcher extends EventEmitter {
}
}
private ensureWatchers(): void {
/**
* Async check for path existence. Replaces sync fs.existsSync()
* to avoid blocking the event loop during watcher initialization.
*/
private async pathExists(p: string): Promise<boolean> {
try {
await fsp.access(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
private async ensureWatchers(): Promise<void> {
if (!this.isWatching || this.fsProvider.type === 'ssh') {
return;
}
this.startProjectsWatcher();
this.startTodosWatcher();
this.startTeamsWatcher();
this.startTasksWatcher();
if (process.platform === 'win32') {
// On Windows, start watchers sequentially to avoid saturating the UV
// thread pool (4 threads by default). Recursive fs.watch() on NTFS is
// significantly slower than on macOS/Linux and can block all threads
// simultaneously when started in parallel, freezing the app.
await this.startProjectsWatcher();
await this.startTodosWatcher();
await this.startTeamsWatcher();
await this.startTasksWatcher();
} else {
// On macOS/Linux, start all watchers in parallel to minimize total startup latency
await Promise.all([
this.startProjectsWatcher(),
this.startTodosWatcher(),
this.startTeamsWatcher(),
this.startTasksWatcher(),
]);
}
if (!this.projectsWatcher || !this.todosWatcher || !this.teamsWatcher || !this.tasksWatcher) {
this.scheduleWatcherRetry();
@ -417,7 +459,7 @@ export class FileWatcher extends EventEmitter {
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.ensureWatchers();
void this.ensureWatchers();
}, WATCHER_RETRY_MS);
}

View file

@ -13,7 +13,8 @@ import { type HttpServices, registerHttpRoutes } from '@main/http';
import { broadcastEvent } from '@main/http/events';
import { createLogger } from '@shared/utils/logger';
import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync } from 'fs';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
const logger = createLogger('Service:HttpServer');
@ -97,8 +98,8 @@ export class HttpServer {
if (rendererPath) {
logger.info(`Serving static files from: ${rendererPath}`);
// Cache index.html for SPA fallback
const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8');
// Cache index.html for SPA fallback (async to avoid blocking main thread)
const indexHtml = await readFile(join(rendererPath, 'index.html'), 'utf-8');
await this.app.register(fastifyStatic, {
root: rendererPath,

View file

@ -43,20 +43,27 @@ export class LocalFileSystemProvider implements FileSystemProvider {
async readdir(dirPath: string): Promise<FsDirent[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
// Stat all entries concurrently to populate mtimeMs, used by SessionSearcher's
// mtime-based cache invalidation. Failures are silently ignored (mtimeMs stays undefined).
// Stat all entries concurrently to populate mtimeMs/birthtimeMs/size.
// Populating all three avoids a second stat() call in resolveFileDetails().
// Failures are silently ignored (fields stay undefined).
return Promise.all(
entries.map(async (entry) => {
let mtimeMs: number | undefined;
let birthtimeMs: number | undefined;
let size: number | undefined;
try {
const stat = await fs.promises.stat(`${dirPath}/${entry.name}`);
mtimeMs = stat.mtimeMs;
birthtimeMs = stat.birthtimeMs;
size = stat.size;
} catch {
// ignore
}
return {
name: entry.name,
mtimeMs,
birthtimeMs,
size,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
};

View file

@ -12,11 +12,11 @@
* - Emit IPC events to renderer: notification:new, notification:updated
*/
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { type BrowserWindow, Notification } from 'electron';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { type DetectedError } from '../error/ErrorMessageBuilder';
@ -77,7 +77,7 @@ const MAX_NOTIFICATIONS = 100;
const THROTTLE_MS = 5000;
/** Path to notifications storage file */
const NOTIFICATIONS_PATH = path.join(os.homedir(), '.claude', 'claude-devtools-notifications.json');
const NOTIFICATIONS_PATH = path.join(getHomeDir(), '.claude', 'claude-devtools-notifications.json');
// =============================================================================
// NotificationManager Class
@ -90,6 +90,10 @@ export class NotificationManager extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private throttleMap = new Map<string, number>();
private isInitialized: boolean = false;
/** Promise that resolves when async initialization is complete.
* Used by addError() to wait for notifications to be loaded from disk
* before writing, preventing a race where save overwrites unloaded data. */
private initPromise: Promise<void> | null = null;
constructor(configManager?: ConfigManager) {
super();
@ -106,7 +110,9 @@ export class NotificationManager extends EventEmitter {
static getInstance(): NotificationManager {
if (!NotificationManager.instance) {
NotificationManager.instance = new NotificationManager();
NotificationManager.instance.initialize();
// Async init: loads notifications without blocking startup.
// addError() awaits initPromise to prevent save-before-load races.
NotificationManager.instance.initPromise = NotificationManager.instance.initialize();
}
return NotificationManager.instance;
}
@ -133,12 +139,12 @@ export class NotificationManager extends EventEmitter {
* Initializes the notification manager.
* Loads existing notifications and prunes if needed.
*/
initialize(): void {
async initialize(): Promise<void> {
if (this.isInitialized) {
return;
}
this.loadNotifications();
await this.loadNotifications();
this.pruneNotifications();
this.isInitialized = true;
@ -157,42 +163,45 @@ export class NotificationManager extends EventEmitter {
// ===========================================================================
/**
* Loads notifications from disk.
* Loads notifications from disk (async to avoid blocking startup).
* Uses a single readFile instead of access() + readFile() to eliminate
* a redundant syscall and TOCTOU race condition.
*/
private loadNotifications(): void {
private async loadNotifications(): Promise<void> {
try {
if (fs.existsSync(NOTIFICATIONS_PATH)) {
const data = fs.readFileSync(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
const data = await fsp.readFile(NOTIFICATIONS_PATH, 'utf8');
const parsed = JSON.parse(data) as unknown;
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
}
if (Array.isArray(parsed)) {
this.notifications = parsed as StoredNotification[];
} else {
logger.warn('Invalid notifications file format, starting fresh');
this.notifications = [];
}
} catch (error) {
logger.error('Error loading notifications:', error);
// ENOENT is expected on first run — no file to load
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Error loading notifications:', error);
}
this.notifications = [];
}
}
/**
* Saves notifications to disk.
* Saves notifications to disk asynchronously.
* Uses async I/O to avoid blocking the main process event loop,
* which is critical on Windows where sync writes can freeze the UI.
*/
private saveNotifications(): void {
try {
// Ensure directory exists
const dir = path.dirname(NOTIFICATIONS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const data = JSON.stringify(this.notifications, null, 2);
const dir = path.dirname(NOTIFICATIONS_PATH);
fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(this.notifications, null, 2), 'utf8');
} catch (error) {
logger.error('Error saving notifications:', error);
}
fsp
.mkdir(dir, { recursive: true })
.then(() => fsp.writeFile(NOTIFICATIONS_PATH, data, 'utf8'))
.catch((error) => {
logger.error('Error saving notifications:', error);
});
}
/**
@ -451,6 +460,12 @@ export class NotificationManager extends EventEmitter {
* @returns The stored notification, or null if filtered/throttled
*/
async addError(error: DetectedError): Promise<StoredNotification | null> {
// Wait for async initialization to complete before modifying notifications.
// Prevents a race where saveNotifications() overwrites not-yet-loaded data.
if (this.initPromise) {
await this.initPromise;
}
// Deduplicate by toolUseId: the same tool call can appear in both the
// subagent JSONL file and the parent session JSONL (as a progress event).
// Keep the subagent-annotated version (with subagentId) when possible.

View file

@ -6,8 +6,8 @@
*/
import crypto from 'node:crypto';
import os from 'node:os';
import { getHomeDir } from '@main/utils/pathDecoder';
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
@ -58,12 +58,18 @@ export class PtyTerminalService {
? (process.env.COMSPEC ?? 'powershell.exe')
: (process.env.SHELL ?? '/bin/bash'));
const home = getHomeDir();
const pty = nodePty.spawn(shell, options?.args ?? [], {
name: 'xterm-256color',
cols: options?.cols ?? 80,
rows: options?.rows ?? 24,
cwd: options?.cwd ?? os.homedir(),
env: { ...process.env, ...options?.env } as Record<string, string>,
cwd: options?.cwd ?? home,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
...options?.env,
} as Record<string, string>,
});
pty.onData((data) => this.send(TERMINAL_DATA, id, data));

View file

@ -137,6 +137,21 @@ export class ServiceContext {
this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
}
/**
* Starts only cache cleanup, deferring FileWatcher to later.
* Use this at app startup so the window appears without waiting for fs.watch().
* Call startFileWatcher() separately after the window is visible.
*/
startCacheOnly(): void {
if (this.disposed) {
logger.error(`Cannot start disposed context: ${this.id}`);
return;
}
logger.info(`Starting ServiceContext (cache only): ${this.id}`);
this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES);
}
/**
* Stops the file watcher (for pausing on context switch).
* Does not dispose resources - can be resumed with startFileWatcher().

View file

@ -8,9 +8,9 @@
* - Gracefully handle missing/unreadable files
*/
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import SSHConfig from 'ssh-config';
@ -22,7 +22,7 @@ export class SshConfigParser {
private configPath: string;
constructor(configPath?: string) {
this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config');
this.configPath = configPath ?? path.join(getHomeDir(), '.ssh', 'config');
}
/**
@ -151,7 +151,7 @@ export class SshConfigParser {
}
const pattern = match[1].trim();
const expandedPattern = pattern.replace(/^~/, os.homedir());
const expandedPattern = pattern.replace(/^~/, getHomeDir());
try {
// Handle glob-like patterns by checking if the path contains wildcards

View file

@ -9,6 +9,7 @@
* - Handle reconnection on errors
*/
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { execFile } from 'child_process';
import { EventEmitter } from 'events';
@ -267,7 +268,7 @@ export class SshConnectionManager extends EventEmitter {
break;
case 'privateKey': {
const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa');
const keyPath = config.privateKeyPath ?? path.join(getHomeDir(), '.ssh', 'id_rsa');
try {
const keyData = await fs.promises.readFile(keyPath, 'utf8');
connectConfig.privateKey = keyData;
@ -308,7 +309,7 @@ export class SshConnectionManager extends EventEmitter {
* Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell.
*/
private async discoverAgentSocket(): Promise<string | null> {
// 1. Check SSH_AUTH_SOCK env var
// 1. Check SSH_AUTH_SOCK env var (all platforms)
if (process.env.SSH_AUTH_SOCK) {
try {
await fs.promises.access(process.env.SSH_AUTH_SOCK);
@ -318,7 +319,19 @@ export class SshConnectionManager extends EventEmitter {
}
}
// 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
// 2. Windows: use OpenSSH named pipe (no Unix sockets on Windows)
if (process.platform === 'win32') {
const pipe = '\\\\.\\pipe\\openssh-ssh-agent';
try {
await fs.promises.access(pipe);
return pipe;
} catch {
// OpenSSH agent not running
}
return null;
}
// 3. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
if (process.platform === 'darwin') {
try {
const sock = await new Promise<string | null>((resolve) => {
@ -343,19 +356,19 @@ export class SshConnectionManager extends EventEmitter {
}
}
// 3. Try known socket paths
// 4. Try known socket paths (macOS/Linux only)
const knownPaths = [
// 1Password SSH agent
path.join(
os.homedir(),
getHomeDir(),
'Library',
'Group Containers',
'2BUA8C4S2C.com.1password',
'agent.sock'
),
path.join(os.homedir(), '.1password', 'agent.sock'),
path.join(getHomeDir(), '.1password', 'agent.sock'),
// Common user agent socket
path.join(os.homedir(), '.ssh', 'agent.sock'),
path.join(getHomeDir(), '.ssh', 'agent.sock'),
];
// Linux: add system paths
@ -395,8 +408,8 @@ export class SshConnectionManager extends EventEmitter {
// The config parser already told us there's an identity file.
// Try common identity file locations from config
const configKeyPaths = [
path.join(os.homedir(), '.ssh', 'id_ed25519'),
path.join(os.homedir(), '.ssh', 'id_rsa'),
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
];
for (const keyPath of configKeyPaths) {
try {
@ -417,9 +430,9 @@ export class SshConnectionManager extends EventEmitter {
// Try default key files
const defaultKeys = [
path.join(os.homedir(), '.ssh', 'id_ed25519'),
path.join(os.homedir(), '.ssh', 'id_rsa'),
path.join(os.homedir(), '.ssh', 'id_ecdsa'),
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
path.join(getHomeDir(), '.ssh', 'id_ecdsa'),
];
for (const keyPath of defaultKeys) {

View file

@ -8,10 +8,9 @@
* - Support tilde (~) expansion to home directory
*/
import { encodePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { encodePath, getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
import { app } from 'electron';
import * as path from 'path';
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
@ -48,7 +47,7 @@ export interface ClaudeMdReadResult {
*/
function expandTilde(filePath: string): string {
if (filePath.startsWith('~')) {
const homeDir = app.getPath('home');
const homeDir = getHomeDir();
return path.join(homeDir, filePath.slice(1));
}
return filePath;

View file

@ -10,6 +10,9 @@
* Git worktree detection:
* - Main repo: .git is a directory
* - Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/<name>"
*
* All filesystem operations use fs.promises to avoid blocking the main process event loop.
* Results are cached with a short TTL to avoid redundant reads during batch operations.
*/
import {
@ -27,12 +30,31 @@ import {
import { type RepositoryIdentity, type WorktreeSource } from '@main/types';
import { createLogger } from '@shared/utils/logger';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as fsp from 'fs/promises';
import * as path from 'path';
const logger = createLogger('Service:GitIdentityResolver');
interface CacheEntry<T> {
value: T;
expiry: number;
}
/** Check if a path exists on the filesystem (async). */
async function fileExists(filePath: string): Promise<boolean> {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
class GitIdentityResolver {
private identityCache = new Map<string, CacheEntry<RepositoryIdentity | null>>();
private branchCache = new Map<string, CacheEntry<string | null>>();
private static readonly CACHE_TTL_MS = 60_000;
/**
* Resolve repository identity from a project path.
*
@ -48,66 +70,80 @@ class GitIdentityResolver {
* @returns RepositoryIdentity or null if not a git repo
*/
async resolveIdentity(projectPath: string): Promise<RepositoryIdentity | null> {
const cached = this.identityCache.get(projectPath);
if (cached && cached.expiry > Date.now()) {
return cached.value;
}
const result = await this.resolveIdentityUncached(projectPath);
this.identityCache.set(projectPath, {
value: result,
expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS,
});
return result;
}
private async resolveIdentityUncached(projectPath: string): Promise<RepositoryIdentity | null> {
try {
const gitPath = path.join(projectPath, '.git');
// First, try filesystem-based resolution
if (fs.existsSync(gitPath)) {
const stats = fs.statSync(gitPath);
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
// .git doesn't exist — fallback to path heuristics
return this.resolveIdentityFromPath(projectPath);
}
let mainGitDir: string;
let mainGitDir: string;
if (stats.isFile()) {
// This is a worktree - parse the .git file to find main repo
const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent);
if (stats.isFile()) {
// This is a worktree - parse the .git file to find main repo
const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent);
if (!gitDirMatch) {
logger.warn(`Invalid .git file format at ${gitPath}`);
return this.resolveIdentityFromPath(projectPath);
}
let worktreeGitDir = gitDirMatch[1].trim();
// Handle relative paths in gitdir (resolve relative to the .git file location)
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(projectPath, worktreeGitDir);
}
mainGitDir = this.extractMainGitDir(worktreeGitDir);
} else if (stats.isDirectory()) {
mainGitDir = gitPath;
} else {
if (!gitDirMatch) {
logger.warn(`Invalid .git file format at ${gitPath}`);
return this.resolveIdentityFromPath(projectPath);
}
// Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders)
// This ensures all worktrees of the same repo get the same ID
try {
mainGitDir = fs.realpathSync(mainGitDir);
} catch {
// If realpath fails (e.g., path doesn't exist), use as-is
let worktreeGitDir = gitDirMatch[1].trim();
// Handle relative paths in gitdir (resolve relative to the .git file location)
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(projectPath, worktreeGitDir);
}
// Extract remote URL from config
const remoteUrl = this.getRemoteUrl(mainGitDir);
// Generate consistent repository ID based on the CANONICAL main git directory
const repoId = this.generateRepoId(remoteUrl, mainGitDir);
// Extract repository name from path or remote URL
const repoName = this.extractRepoName(remoteUrl, mainGitDir);
return {
id: repoId,
remoteUrl: remoteUrl ?? undefined,
mainGitDir,
name: repoName,
};
mainGitDir = this.extractMainGitDir(worktreeGitDir);
} else if (stats.isDirectory()) {
mainGitDir = gitPath;
} else {
return this.resolveIdentityFromPath(projectPath);
}
// Fallback: path doesn't exist, use heuristic resolution
return this.resolveIdentityFromPath(projectPath);
// Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders)
// This ensures all worktrees of the same repo get the same ID
try {
mainGitDir = await fsp.realpath(mainGitDir);
} catch {
// If realpath fails (e.g., path doesn't exist), use as-is
}
// Extract remote URL from config
const remoteUrl = await this.getRemoteUrl(mainGitDir);
// Generate consistent repository ID based on the CANONICAL main git directory
const repoId = this.generateRepoId(remoteUrl, mainGitDir);
// Extract repository name from path or remote URL
const repoName = this.extractRepoName(remoteUrl, mainGitDir);
return {
id: repoId,
remoteUrl: remoteUrl ?? undefined,
mainGitDir,
name: repoName,
};
} catch (error) {
logger.error(`Error resolving git identity for ${projectPath}:`, error);
// Try fallback even on error
@ -223,7 +259,7 @@ class GitIdentityResolver {
* Worktrees have a .git file, main repos have a .git directory.
* Uses path heuristics if filesystem is not available (for deleted worktrees).
*/
isWorktree(projectPath: string): boolean {
async isWorktree(projectPath: string): Promise<boolean> {
// First, try path-based heuristics (works for deleted worktrees)
const parts = projectPath.split(path.sep).filter(Boolean);
@ -257,10 +293,8 @@ class GitIdentityResolver {
// Fallback: check filesystem if available
try {
const gitPath = path.join(projectPath, '.git');
if (fs.existsSync(gitPath)) {
const stats = fs.statSync(gitPath);
return stats.isFile();
}
const stats = await fsp.stat(gitPath);
return stats.isFile();
} catch {
// Ignore errors - filesystem might not be available
}
@ -301,15 +335,17 @@ class GitIdentityResolver {
* @param gitDir - Path to the .git directory
* @returns Remote URL or null if not found
*/
private getRemoteUrl(gitDir: string): string | null {
private async getRemoteUrl(gitDir: string): Promise<string | null> {
try {
const configPath = path.join(gitDir, 'config');
if (!fs.existsSync(configPath)) {
let configContent: string;
try {
configContent = await fsp.readFile(configPath, 'utf-8');
} catch {
return null;
}
const configContent = fs.readFileSync(configPath, 'utf-8');
// Parse git config to find [remote "origin"] section
const lines = configContent.split(/\r?\n/);
let inOriginRemote = false;
@ -413,19 +449,35 @@ class GitIdentityResolver {
* @returns Branch name or null
*/
async getBranch(projectPath: string): Promise<string | null> {
const cached = this.branchCache.get(projectPath);
if (cached && cached.expiry > Date.now()) {
return cached.value;
}
const result = await this.getBranchUncached(projectPath);
this.branchCache.set(projectPath, {
value: result,
expiry: Date.now() + GitIdentityResolver.CACHE_TTL_MS,
});
return result;
}
private async getBranchUncached(projectPath: string): Promise<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) {
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
return null;
}
const stats = fs.statSync(gitPath);
let headPath: string;
if (stats.isFile()) {
// Worktree - read .git file to find the HEAD location
const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim();
const gitFileContent = (await fsp.readFile(gitPath, 'utf-8')).trim();
const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent);
if (!gitDirMatch) {
@ -438,12 +490,13 @@ class GitIdentityResolver {
headPath = path.join(gitPath, 'HEAD');
}
if (!fs.existsSync(headPath)) {
let headContent: string;
try {
headContent = (await fsp.readFile(headPath, 'utf-8')).trim();
} catch {
return null;
}
const headContent = fs.readFileSync(headPath, 'utf-8').trim();
// Check if HEAD is a symbolic ref (branch)
const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent);
if (refMatch) {
@ -476,7 +529,7 @@ class GitIdentityResolver {
* @param projectPath - The filesystem path to check
* @returns WorktreeSource identifier
*/
detectWorktreeSource(projectPath: string): WorktreeSource {
async detectWorktreeSource(projectPath: string): Promise<WorktreeSource> {
const parts = projectPath.split(path.sep).filter(Boolean);
// Pattern: vibe-kanban
@ -518,13 +571,8 @@ class GitIdentityResolver {
// Check if it's a standard git repo (only if filesystem exists)
// For deleted repos, we'll return 'git' as fallback since we can't verify
try {
const gitPath = path.join(projectPath, '.git');
if (fs.existsSync(gitPath)) {
return 'git';
}
} catch {
// Ignore errors - filesystem might not be available
if (await fileExists(path.join(projectPath, '.git'))) {
return 'git';
}
// Default to 'git' for paths that don't match known patterns
@ -542,12 +590,12 @@ class GitIdentityResolver {
* @param isMainWorktree - Whether this is the main worktree
* @returns Display name for the worktree
*/
getWorktreeDisplayName(
async getWorktreeDisplayName(
projectPath: string,
source: WorktreeSource,
branch: string | null,
isMainWorktree: boolean
): string {
): Promise<string> {
const parts = projectPath.split(path.sep).filter(Boolean);
switch (source) {
@ -626,7 +674,7 @@ class GitIdentityResolver {
return branch ?? 'main';
}
// For non-main git worktrees, try to get the worktree name from .git file
return this.getGitWorktreeName(projectPath) ?? branch ?? parts[parts.length - 1];
return (await this.getGitWorktreeName(projectPath)) ?? branch ?? parts[parts.length - 1];
case 'unknown':
default:
@ -645,15 +693,20 @@ class GitIdentityResolver {
* @param projectPath - The filesystem path
* @returns Worktree name or null
*/
private getGitWorktreeName(projectPath: string): string | null {
private async getGitWorktreeName(projectPath: string): Promise<string | null> {
try {
const gitPath = path.join(projectPath, '.git');
if (!fs.existsSync(gitPath)) return null;
const stats = fs.statSync(gitPath);
let stats: Awaited<ReturnType<typeof fsp.stat>>;
try {
stats = await fsp.stat(gitPath);
} catch {
return null;
}
if (!stats.isFile()) return null;
const content = fs.readFileSync(gitPath, 'utf-8');
const content = await fsp.readFile(gitPath, 'utf-8');
const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content);
if (!match) return null;

View file

@ -1,5 +1,5 @@
import { getHomeDir } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
async function isExecutable(filePath: string): Promise<boolean> {
@ -61,7 +61,7 @@ function expandWindowsBinaryNames(binaryName: string): string[] {
}
async function collectNvmCandidates(): Promise<string[]> {
const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node');
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
let versions: string[];
try {
versions = await fs.promises.readdir(nvmNodeRoot);
@ -84,24 +84,30 @@ async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const pathParts = rawPath.split(path.delimiter);
const binaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName];
for (const part of pathParts) {
if (!part) {
continue;
}
const cleanedPart = stripSurroundingQuotes(part);
if (!cleanedPart) {
continue;
}
// Check all PATH directories in parallel. Each directory checks all extension
// variants concurrently. This turns N_dirs × N_exts sequential stat() calls
// into a single parallel batch, dramatically reducing startup time on Windows.
const dirResults = await Promise.all(
pathParts.map(async (part) => {
if (!part) return null;
const cleanedPart = stripSurroundingQuotes(part);
if (!cleanedPart) return null;
for (const name of binaryNames) {
const candidate = path.join(cleanedPart, name);
if (await isExecutable(candidate)) {
return candidate;
}
}
}
return null;
const candidates = binaryNames.map((name) => path.join(cleanedPart, name));
const results = await Promise.all(
candidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return the first matching extension variant within this directory
return results.find((r) => r.ok)?.path ?? null;
})
);
// Return first non-null result, preserving PATH priority order
return dirResults.find((r) => r !== null) ?? null;
}
async function resolveFromExplicitPath(inputPath: string): Promise<string | null> {
@ -172,13 +178,11 @@ export class ClaudeBinaryResolver {
const candidateDirs: string[] = [
// Native binary installation path (claude install)
path.join(os.homedir(), '.local', 'bin'),
path.join(os.homedir(), '.npm-global', 'bin'),
path.join(os.homedir(), '.npm', 'bin'),
path.join(getHomeDir(), '.local', 'bin'),
path.join(getHomeDir(), '.npm-global', 'bin'),
path.join(getHomeDir(), '.npm', 'bin'),
process.platform === 'win32'
? process.env.APPDATA
? path.join(process.env.APPDATA, 'npm')
: ''
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
: '/usr/local/bin',
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
].filter((candidate) => candidate.length > 0);
@ -188,11 +192,20 @@ export class ClaudeBinaryResolver {
);
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
for (const candidate of [...candidates, ...nvmCandidates]) {
if (await isExecutable(candidate)) {
cachedPath = candidate;
return cachedPath;
}
const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed
const results = await Promise.all(
allCandidates.map(async (candidate) => ({
path: candidate,
ok: await isExecutable(candidate),
}))
);
// Return first match, preserving candidate priority order
const found = results.find((r) => r.ok);
if (found) {
cachedPath = found.path;
return cachedPath;
}
// Don't cache null — CLI may be installed later without app restart

View file

@ -1,3 +1,4 @@
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { diffLines } from 'diff';
import { createReadStream } from 'fs';
@ -257,8 +258,13 @@ export class FileContentResolver {
if (!backupFileName) continue;
// Construct the file-history path
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
const historyPath = path.join(homeDir, '.claude', 'file-history', sessionId, backupFileName);
const historyPath = path.join(
getHomeDir(),
'.claude',
'file-history',
sessionId,
backupFileName
);
try {
await access(historyPath);

View file

@ -3,22 +3,25 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBloc
import * as fs from 'fs';
import * as path from 'path';
// eslint-disable-next-line no-restricted-imports -- package.json is at project root, no alias available
import { version as APP_VERSION } from '../../../../package.json';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 11;
function buildTeamCtlScript(): string {
function buildTeamCtlScript(version: string): string {
const script = String.raw`#!/usr/bin/env node
'use strict';
// Team tools (v${TOOL_VERSION})
// Team tools (v${version})
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TOOL_VERSION = ${TOOL_VERSION};
const TOOL_VERSION = '${version}';
function nowIso() {
return new Date().toISOString();
@ -413,6 +416,7 @@ function sendInboxMessage(paths, teamName, flags) {
: String(Date.now()) + '-' + String(Math.random());
const payload = {
from,
to,
text,
timestamp: nowIso(),
read: false,
@ -978,7 +982,7 @@ export class TeamAgentToolsInstaller {
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
await fs.promises.mkdir(toolsDir, { recursive: true });
const desired = buildTeamCtlScript();
const desired = buildTeamCtlScript(APP_VERSION);
let current: string | null = null;
try {
current = await fs.promises.readFile(toolPath, 'utf8');
@ -988,7 +992,7 @@ export class TeamAgentToolsInstaller {
}
}
if (current?.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
if (current?.includes(`TOOL_VERSION = '${APP_VERSION}'`)) {
return toolPath;
}

View file

@ -9,6 +9,57 @@ import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@sh
const logger = createLogger('Service:TeamConfigReader');
const TEAM_LIST_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
const LARGE_CONFIG_BYTES = 512 * 1024;
const CONFIG_HEAD_BYTES = 64 * 1024;
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
async function mapLimit<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let index = 0;
const workerCount = Math.max(1, Math.min(limit, items.length));
const workers = new Array(workerCount).fill(0).map(async () => {
while (true) {
const i = index++;
if (i >= items.length) return;
results[i] = await fn(items[i]);
}
});
await Promise.all(workers);
return results;
}
async function readFileHead(filePath: string, maxBytes: number): Promise<string> {
const handle = await fs.promises.open(filePath, 'r');
try {
const stat = await handle.stat();
const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes));
if (bytesToRead === 0) return '';
const buffer = Buffer.alloc(bytesToRead);
await handle.read(buffer, 0, bytesToRead, 0);
return buffer.toString('utf8');
} finally {
await handle.close();
}
}
function extractQuotedString(head: string, key: string): string | null {
const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`);
const match = re.exec(head);
if (!match?.[1]) return null;
try {
const value = JSON.parse(match[1]) as unknown;
return typeof value === 'string' ? value : null;
} catch {
return null;
}
}
export class TeamConfigReader {
constructor(
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
@ -24,111 +75,123 @@ export class TeamConfigReader {
return [];
}
const summaries: TeamSummary[] = [];
const teamDirs = entries.filter((e) => e.isDirectory());
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const perTeam: (TeamSummary | null)[] = await mapLimit(
teamDirs,
TEAM_LIST_CONCURRENCY,
async (entry): Promise<TeamSummary | null> => {
const teamName = entry.name;
const configPath = path.join(teamsDir, teamName, 'config.json');
const configPath = path.join(teamsDir, entry.name, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as TeamConfig;
if (typeof config.name !== 'string' || config.name.trim() === '') {
logger.debug(`Skipping team dir with invalid config name: ${entry.name}`);
continue;
}
const memberMap = new Map<string, TeamSummaryMember>();
const addMember = (m: TeamMember): void => {
const name = m.name?.trim();
if (!name) return;
const existing = memberMap.get(name);
memberMap.set(name, {
name,
role: m.role?.trim() || existing?.role,
color: m.color?.trim() || existing?.color,
});
};
if (Array.isArray(config.members)) {
for (const member of config.members) {
if (member && typeof member.name === 'string') {
addMember(member);
}
}
}
const removedNames = new Set<string>();
try {
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
for (const member of metaMembers) {
if (member.removedAt) {
removedNames.add(member.name.trim());
} else {
addMember(member);
let config: TeamConfig | null = null;
let displayName: string | null = null;
let description = '';
let color: string | undefined;
let projectPath: string | undefined;
let leadSessionId: string | undefined;
let deletedAt: string | undefined;
let projectPathHistory: TeamConfig['projectPathHistory'] | undefined;
let sessionHistory: TeamConfig['sessionHistory'] | undefined;
let stat: fs.Stats | null = null;
try {
stat = await fs.promises.stat(configPath);
} catch {
stat = null;
}
if (stat && stat.isFile() && stat.size > LARGE_CONFIG_BYTES) {
const head = await readFileHead(configPath, CONFIG_HEAD_BYTES);
displayName = extractQuotedString(head, 'name');
const desc = extractQuotedString(head, 'description');
description = typeof desc === 'string' ? desc : '';
const c = extractQuotedString(head, 'color');
color = typeof c === 'string' && c.trim().length > 0 ? c : undefined;
const pp = extractQuotedString(head, 'projectPath');
projectPath = typeof pp === 'string' && pp.trim().length > 0 ? pp : undefined;
const lead = extractQuotedString(head, 'leadSessionId');
leadSessionId = typeof lead === 'string' && lead.trim().length > 0 ? lead : undefined;
const del = extractQuotedString(head, 'deletedAt');
deletedAt = typeof del === 'string' ? del : undefined;
} else {
const raw = await fs.promises.readFile(configPath, 'utf8');
config = JSON.parse(raw) as TeamConfig;
displayName = typeof config.name === 'string' ? config.name : null;
description = typeof config.description === 'string' ? config.description : '';
color =
typeof config.color === 'string' && config.color.trim().length > 0
? config.color
: undefined;
projectPath =
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId
: undefined;
projectPathHistory = Array.isArray(config.projectPathHistory)
? config.projectPathHistory.slice(-MAX_PROJECT_PATH_HISTORY_IN_SUMMARY)
: undefined;
sessionHistory = Array.isArray(config.sessionHistory)
? config.sessionHistory.slice(-MAX_SESSION_HISTORY_IN_SUMMARY)
: undefined;
deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined;
}
if (typeof displayName !== 'string' || displayName.trim() === '') {
logger.debug(`Skipping team dir with invalid config name: ${teamName}`);
return null;
}
const memberMap = new Map<string, TeamSummaryMember>();
const addMember = (m: TeamMember): void => {
const name = m.name?.trim();
if (!name) return;
const existing = memberMap.get(name);
memberMap.set(name, {
name,
role: m.role?.trim() || existing?.role,
color: m.color?.trim() || existing?.color,
});
};
if (config && Array.isArray(config.members)) {
for (const member of config.members) {
if (member && typeof member.name === 'string') {
addMember(member);
}
}
}
const members = Array.from(memberMap.values());
const summary: TeamSummary = {
teamName,
displayName,
description,
memberCount: memberMap.size,
taskCount: 0,
lastActivity: null,
...(members.length > 0 ? { members } : {}),
...(color ? { color } : {}),
...(projectPath ? { projectPath } : {}),
...(leadSessionId ? { leadSessionId } : {}),
...(projectPathHistory ? { projectPathHistory } : {}),
...(sessionHistory ? { sessionHistory } : {}),
...(deletedAt ? { deletedAt } : {}),
};
return summary;
} catch {
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
logger.debug(`Skipping team dir without valid config: ${teamName}`);
return null;
}
const inboxDir = path.join(teamsDir, entry.name, 'inboxes');
try {
const inboxEntries = await fs.promises.readdir(inboxDir);
for (const inbox of inboxEntries) {
if (!inbox.endsWith('.json') || inbox.startsWith('.')) {
continue;
}
const inboxName = inbox.slice(0, -'.json'.length).trim();
if (inboxName.length > 0 && !memberMap.has(inboxName)) {
memberMap.set(inboxName, { name: inboxName });
}
}
} catch {
// Inbox folder may not exist yet.
}
for (const name of removedNames) {
memberMap.delete(name);
}
const memberCount = memberMap.size;
const members = Array.from(memberMap.values());
summaries.push({
teamName: entry.name,
displayName: config.name,
description: typeof config.description === 'string' ? config.description : '',
color:
typeof config.color === 'string' && config.color.trim().length > 0
? config.color
: undefined,
memberCount,
members: members.length > 0 ? members : undefined,
taskCount: 0,
lastActivity: null,
projectPath:
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined,
leadSessionId:
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId
: undefined,
projectPathHistory: Array.isArray(config.projectPathHistory)
? config.projectPathHistory
: undefined,
sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined,
deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined,
});
} catch {
logger.debug(`Skipping team dir without valid config: ${entry.name}`);
}
}
);
return summaries;
return perTeam.filter((t): t is TeamSummary => t !== null);
}
async getConfig(teamName: string): Promise<TeamConfig | null> {

View file

@ -12,7 +12,6 @@ import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
@ -170,10 +169,21 @@ export class TeamDataService {
}
async getTeamData(teamName: string): Promise<TeamData> {
const startedAt = Date.now();
const marks: Record<string, number> = {};
const mark = (label: string): void => {
marks[label] = Date.now();
};
const msSince = (label: string): number => {
const t = marks[label];
return typeof t === 'number' ? t - startedAt : -1;
};
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
mark('config');
const warnings: string[] = [];
@ -185,6 +195,7 @@ export class TeamDataService {
warnings.push('Tasks failed to load');
tasksLoaded = false;
}
mark('tasks');
let inboxNames: string[] = [];
try {
@ -192,6 +203,7 @@ export class TeamDataService {
} catch {
warnings.push('Inboxes failed to load');
}
mark('inboxNames');
let messages: InboxMessage[] = [];
try {
@ -199,6 +211,7 @@ export class TeamDataService {
} catch {
warnings.push('Messages failed to load');
}
mark('messages');
try {
const leadTexts = await this.extractLeadSessionTexts(config);
@ -208,6 +221,7 @@ export class TeamDataService {
} catch {
warnings.push('Lead session texts failed to load');
}
mark('leadTexts');
try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
@ -217,6 +231,7 @@ export class TeamDataService {
} catch {
warnings.push('Sent messages failed to load');
}
mark('sentMessages');
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
@ -226,6 +241,7 @@ export class TeamDataService {
} catch {
warnings.push('Member metadata failed to load');
}
mark('metaMembers');
let kanbanState: KanbanState = {
teamName,
@ -239,6 +255,7 @@ export class TeamDataService {
warnings.push('Kanban state failed to load');
canRunKanbanGc = false;
}
mark('kanbanState');
if (canRunKanbanGc && tasksLoaded) {
try {
@ -248,6 +265,7 @@ export class TeamDataService {
warnings.push('Kanban state cleanup failed');
}
}
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column;
@ -262,9 +280,11 @@ export class TeamDataService {
tasksWithKanban,
messages
);
mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
// Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) {
@ -278,6 +298,7 @@ export class TeamDataService {
warnings.push('Comment sync from messages failed');
}
}
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => {
const col = kanbanState.tasks[task.id]?.column;
@ -291,6 +312,22 @@ export class TeamDataService {
} catch {
warnings.push('Processes failed to load');
}
mark('processes');
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
logger.warn(
`[getTeamData] slow team=${teamName} total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
);
}
// Auto-track teams with alive processes for periodic health checks
const hasAlive = processes.some((p) => !p.stoppedAt);
@ -477,28 +514,51 @@ export class TeamDataService {
const leadCwd = leadEntry?.cwd ?? config.projectPath;
if (!leadCwd) return;
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
try {
return await Promise.race([
p,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
};
let leadBranch: string | null = null;
try {
leadBranch = await gitIdentityResolver.getBranch(leadCwd);
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
// Branch is best-effort; never block team:getData on it.
leadBranch = await withTimeout(gitIdentityResolver.getBranch(leadCwd), 2000);
} catch {
// Lead cwd may not be a git repo — skip enrichment entirely
return;
}
await Promise.all(
members.map(async (member) => {
if (!member.cwd || member.cwd === leadCwd) return;
try {
const branch = await gitIdentityResolver.getBranch(member.cwd);
if (branch && branch !== leadBranch) {
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
member.gitBranch = branch;
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
if (candidates.length === 0) return;
const concurrency = process.platform === 'win32' ? 4 : 8;
for (let i = 0; i < candidates.length; i += concurrency) {
const batch = candidates.slice(i, i + concurrency);
await Promise.all(
batch.map(async (member) => {
if (!member.cwd) return;
try {
const branch = await withTimeout(gitIdentityResolver.getBranch(member.cwd), 2000);
if (branch && branch !== leadBranch) {
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
member.gitBranch = branch;
}
} catch {
// Member cwd may not be a git repo — skip silently
}
} catch {
// Member cwd may not be a git repo — skip silently
}
})
);
})
);
}
}
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
@ -707,6 +767,14 @@ export class TeamDataService {
await this.taskWriter.updateOwner(teamName, taskId, owner);
}
async updateTaskFields(
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
): Promise<void> {
await this.taskWriter.updateFields(teamName, taskId, fields);
}
async setTaskNeedsClarification(
teamName: string,
taskId: string,
@ -902,6 +970,11 @@ export class TeamDataService {
const TASK_ID_PATTERN = /#(\d+)/g;
let synced = false;
const tasksById = new Map<string, TeamTask>();
for (const t of tasks) {
tasksById.set(t.id, t);
}
// Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>();
@ -920,7 +993,7 @@ export class TeamDataService {
}
for (const taskId of taskIds) {
const task = tasks.find((t) => t.id === taskId);
const task = tasksById.get(taskId);
if (!task) continue;
const commentId = `msg-${msg.messageId}`;
@ -961,58 +1034,76 @@ export class TeamDataService {
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
const texts: InboxMessage[] = [];
const stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
// Optimization: read from the end of the JSONL file (we only need the last N texts).
// The full file can be huge; scanning from the start causes long stalls on Windows.
const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
const textsReversed: InboxMessage[] = [];
const handle = await fs.promises.open(jsonlPath, 'r');
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
const stat = await handle.stat();
const fileSize = stat.size;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
while (textsReversed.length < MAX_LEAD_TEXTS && scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start);
const chunk = buffer.toString('utf8');
const lines = chunk.split(/\r?\n/);
// If we started mid-file, the first line may be partial — drop it.
const fromIndex = start > 0 ? 1 : 0;
for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim();
if (!trimmed) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
const text = block.text.trim();
if (text.length < MIN_TEXT_LENGTH) continue;
textsReversed.push({
from: leadName,
text,
timestamp,
read: true,
source: 'lead_session',
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
const text = block.text.trim();
if (text.length < MIN_TEXT_LENGTH) continue;
texts.push({
from: leadName,
text,
timestamp,
read: true,
source: 'lead_session',
});
}
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
} finally {
rl.close();
stream.destroy();
await handle.close();
}
// Keep only the last N texts
if (texts.length > MAX_LEAD_TEXTS) {
return texts.slice(-MAX_LEAD_TEXTS);
}
return texts;
// Convert back to chronological order (old behavior) and keep the last N texts.
textsReversed.reverse();
const texts = textsReversed;
return texts.length > MAX_LEAD_TEXTS ? texts.slice(-MAX_LEAD_TEXTS) : texts;
}
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {

View file

@ -15,6 +15,7 @@ export class TeamInboxWriter {
const payload: InboxMessage = {
from: request.from ?? 'user',
to: request.member,
text: request.text,
timestamp: new Date().toISOString(),
read: false,

View file

@ -11,6 +11,8 @@ interface TeamMembersMetaFile {
members: TeamMember[];
}
const MAX_META_FILE_BYTES = 256 * 1024;
function normalizeMember(member: TeamMember): TeamMember | null {
const trimmedName = member.name?.trim();
if (!trimmedName) {
@ -35,6 +37,14 @@ export class TeamMembersMetaStore {
async getMembers(teamName: string): Promise<TeamMember[]> {
const metaPath = this.getMetaPath(teamName);
try {
const stat = await fs.promises.stat(metaPath);
if (stat.isFile() && stat.size > MAX_META_FILE_BYTES) {
return [];
}
} catch {
// ignore - readFile below will handle ENOENT and throw on other errors
}
let raw: string;
try {
raw = await fs.promises.readFile(metaPath, 'utf8');

View file

@ -1,10 +1,12 @@
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import {
encodePath,
extractBaseDir,
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
getHomeDir,
getProjectsBasePath,
getTasksBasePath,
getTeamsBasePath,
@ -194,10 +196,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
stdio: ['ignore', 'pipe', 'ignore'],
});
const chunks: Buffer[] = [];
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
timeoutHandle = null;
child.kill();
reject(new Error('shell env resolve timeout'));
// SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc)
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
/* already dead */
}
}, 3000);
if (!settled) {
settled = true;
reject(new Error('shell env resolve timeout'));
}
}, SHELL_ENV_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
@ -208,13 +222,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
reject(error);
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
resolve(Buffer.concat(chunks).toString('utf8'));
if (!settled) {
settled = true;
resolve(Buffer.concat(chunks).toString('utf8'));
}
});
});
return parseNullSeparatedEnv(envDump);
@ -929,7 +949,7 @@ export class TeamProvisioningService {
);
}
try {
child = spawn(
child = spawnCli(
claudePath,
[
'--input-format',
@ -963,7 +983,7 @@ export class TeamProvisioningService {
run.child = child;
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
if (child.stdin) {
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
@ -1033,7 +1053,7 @@ export class TeamProvisioningService {
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
if (readyOnTimeout) {
return; // cleanupRun already called inside tryCompleteAfterTimeout
}
@ -1092,34 +1112,44 @@ export class TeamProvisioningService {
// Extract leadSessionId for session resume on reconnect.
// If a valid JSONL file exists for the previous session, we can resume it
// so the lead retains full context of prior work.
// When clearContext is true, skip resume entirely to start a fresh session.
let previousSessionId: string | undefined;
try {
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (
typeof configParsed.leadSessionId === 'string' &&
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const projectPath =
typeof configParsed.projectPath === 'string' && configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: request.cwd;
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
if (request.clearContext) {
logger.info(
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
);
} else {
try {
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (
typeof configParsed.leadSessionId === 'string' &&
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const projectPath =
typeof configParsed.projectPath === 'string' &&
configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: request.cwd;
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
}
}
} catch {
logger.debug(
`[${request.teamName}] Failed to extract leadSessionId from config for resume`
);
}
} catch {
logger.debug(`[${request.teamName}] Failed to extract leadSessionId from config for resume`);
}
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
@ -1241,7 +1271,7 @@ export class TeamProvisioningService {
// --resume is for existing sessions and may show an interactive picker if not found.
try {
child = spawn(claudePath, launchArgs, {
child = spawnCli(claudePath, launchArgs, {
cwd: request.cwd,
env: {
...shellEnv,
@ -1263,7 +1293,7 @@ export class TeamProvisioningService {
run.child = child;
// Send launch prompt
if (child.stdin) {
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
@ -1332,7 +1362,7 @@ export class TeamProvisioningService {
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
if (readyOnTimeout) {
return;
}
@ -1383,7 +1413,7 @@ export class TeamProvisioningService {
run.cancelRequested = true;
run.processKilled = true;
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
run.onProgress(progress);
this.cleanupRun(run);
@ -1812,7 +1842,7 @@ export class TeamProvisioningService {
run.processKilled = true;
run.cancelRequested = true;
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress);
this.cleanupRun(run);
@ -1954,7 +1984,7 @@ export class TeamProvisioningService {
// Kill the process on provisioning error
run.processKilled = true;
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
this.cleanupRun(run);
} else if (run.provisioningComplete) {
// Post-provisioning error: process alive, waiting for input
@ -1970,7 +2000,9 @@ export class TeamProvisioningService {
* Process stays alive for subsequent tasks.
*/
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
if (run.cancelRequested) return;
// Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing.
if (run.provisioningComplete || run.cancelRequested) return;
run.provisioningComplete = true;
this.setLeadActivity(run, 'idle');
@ -2016,7 +2048,7 @@ export class TeamProvisioningService {
run.onProgress(progress);
run.processKilled = true;
run.child?.stdin?.end();
run.child?.kill();
killProcessTree(run.child);
this.cleanupRun(run);
return;
}
@ -2046,6 +2078,11 @@ export class TeamProvisioningService {
run.timeoutHandle = null;
}
this.stopFilesystemMonitor(run);
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
if (run.child) {
run.child.stdout?.removeAllListeners('data');
run.child.stderr?.removeAllListeners('data');
}
this.activeByTeam.delete(run.teamName);
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
@ -2429,7 +2466,10 @@ export class TeamProvisioningService {
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
const shellEnv = await resolveInteractiveShellEnv();
const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || os.homedir();
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled.
const electronHome = getHomeDir();
const home = shellEnv.HOME?.trim() || electronHome;
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
const xdgConfigHome =
@ -2443,6 +2483,7 @@ export class TeamProvisioningService {
...process.env,
...shellEnv,
HOME: home,
USERPROFILE: home,
USER: user,
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
SHELL: shell,
@ -2579,6 +2620,8 @@ export class TeamProvisioningService {
projectPath: string,
detectedSessionId: string | null
): Promise<void> {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
@ -2616,7 +2659,11 @@ export class TeamProvisioningService {
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
}
config.sessionHistory = sessionHistory;
if (sessionHistory.length > MAX_SESSION_HISTORY) {
config.sessionHistory = sessionHistory.slice(-MAX_SESSION_HISTORY);
} else {
config.sessionHistory = sessionHistory;
}
// Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
@ -2631,7 +2678,10 @@ export class TeamProvisioningService {
)
: [];
pathHistory.push(projectPath);
config.projectPathHistory = pathHistory;
config.projectPathHistory =
pathHistory.length > MAX_PROJECT_PATH_HISTORY
? pathHistory.slice(-MAX_PROJECT_PATH_HISTORY)
: pathHistory;
}
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
@ -3116,7 +3166,7 @@ export class TeamProvisioningService {
timeoutMs: number
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(claudePath, args, {
const child = spawnCli(claudePath, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
@ -3125,7 +3175,7 @@ export class TeamProvisioningService {
const stderrChunks: Buffer[] = [];
const timeoutHandle = setTimeout(() => {
child.kill();
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
}, timeoutMs);

View file

@ -190,6 +190,35 @@ export class TeamTaskWriter {
});
}
async updateFields(
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
if (fields.subject !== undefined) {
task.subject = fields.subject;
}
if (fields.description !== undefined) {
task.description = fields.description;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async setNeedsClarification(
teamName: string,
taskId: string,

View file

@ -1,39 +1,5 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
/**
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV fallback for safety.
* Re-export from canonical location.
* Kept to avoid breaking existing imports new code should import from @main/utils/atomicWrite.
*/
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
const dir = path.dirname(targetPath);
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(tmpPath, data, 'utf8');
try {
const fd = await fs.promises.open(tmpPath, 'r+');
await fd.sync();
await fd.close();
} catch {
// fsync is best-effort.
}
try {
await fs.promises.rename(tmpPath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await fs.promises.copyFile(tmpPath, targetPath);
await fs.promises.unlink(tmpPath).catch(() => undefined);
} else {
throw error;
}
}
} catch (error) {
await fs.promises.unlink(tmpPath).catch(() => undefined);
throw error;
}
}
export { atomicWriteAsync } from '@main/utils/atomicWrite';

View file

@ -0,0 +1,41 @@
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
/**
* Async atomic write: write tmp file then rename over target.
* Uses best-effort fsync and EXDEV fallback for safety.
*/
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
const dir = path.dirname(targetPath);
const tmpPath = path.join(dir, `.tmp.${randomUUID()}`);
try {
await fs.promises.mkdir(dir, { recursive: true });
await fs.promises.writeFile(tmpPath, data, 'utf8');
let fd: fs.promises.FileHandle | null = null;
try {
fd = await fs.promises.open(tmpPath, 'r+');
await fd.sync();
} catch {
// fsync is best-effort.
} finally {
await fd?.close();
}
try {
await fs.promises.rename(tmpPath, targetPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
await fs.promises.copyFile(tmpPath, targetPath);
await fs.promises.unlink(tmpPath).catch(() => undefined);
} else {
throw error;
}
}
} catch (error) {
await fs.promises.unlink(tmpPath).catch(() => undefined);
throw error;
}
}

View file

@ -0,0 +1,198 @@
import {
type ChildProcess,
exec,
execFile,
type ExecFileOptions,
type ExecOptions,
spawn,
type SpawnOptions,
} from 'child_process';
import path from 'path';
/**
* Promise wrapper for execFile that always returns { stdout, stderr }.
* Unlike promisify(execFile), this works correctly with mocked execFile
* (promisify relies on a custom symbol that mocks don't have).
*/
function execFileAsync(
cmd: string,
args: string[],
options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(cmd, args, options, (err, stdout, stderr) => {
if (err)
reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
);
else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
});
}
/**
* Promise wrapper for exec. Used exclusively as a Windows shell fallback
* when execFile fails with EINVAL on non-ASCII binary paths. The command
* string is built from a known binary path + args, NOT from user input.
*/
function execShellAsync(
cmd: string,
options: ExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line sonarjs/os-command, security/detect-child-process -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
exec(cmd, options, (err, stdout, stderr) => {
if (err)
reject(
err instanceof Error ? err : new Error(typeof err === 'string' ? err : 'Unknown error')
);
else resolve({ stdout: String(stdout), stderr: String(stderr) });
});
});
}
/**
* Returns true if the string contains any non-ASCII character.
*/
function containsNonAscii(str: string): boolean {
return [...str].some((c) => c.charCodeAt(0) > 127);
}
/**
* On Windows, creating a process whose *path* contains non-ASCII
* characters will often fail with `spawn EINVAL`. Detect that case so
* callers can automatically fall back to launching via a shell.
*/
function needsShell(binaryPath: string): boolean {
if (process.platform !== 'win32') return false;
if (!binaryPath) return false;
return containsNonAscii(binaryPath);
}
/**
* Quote an argument for cmd.exe shell invocation on Windows.
*
* cmd.exe rules:
* - Double-quote args containing spaces or special characters
* - Inside double quotes, escape literal `"` as `""`
* - `%` is expanded as env var even inside double quotes escape as `%%`
* - `^`, `&`, `|`, `<`, `>` are safe inside double quotes
*
* Our callers only pass controlled strings (binary paths, CLI flags),
* NOT arbitrary user input.
*/
function quoteArg(arg: string): string {
if (/[^A-Za-z0-9_\-/.]/.test(arg)) {
const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""');
return `"${escaped}"`;
}
return arg;
}
/**
* Execute a CLI binary, falling back to running the command through a
* shell on Windows if the normal path-based spawn fails. `binaryPath`
* may be `null` which causes `claude` (lookup via PATH) to be used.
*
* The return value matches the shape of Node's `execFile` promise: an
* object with `stdout` and `stderr` strings.
*/
export async function execCli(
binaryPath: string | null,
args: string[],
options: ExecFileOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const target = binaryPath || 'claude';
// attempt the normal execFile path first
if (!needsShell(target)) {
try {
const result = await execFileAsync(target, args, options);
return { stdout: String(result.stdout), stderr: String(result.stderr) };
} catch (err: unknown) {
// fall through to shell fallback only when the error matches the
// Windows "invalid argument" problem; otherwise rethrow.
const code =
err && typeof err === 'object' && 'code' in err
? (err as { code?: string }).code
: undefined;
if (code !== 'EINVAL') {
throw err;
}
}
}
// shell fallback (Windows only; others shouldn't reach here)
const cmd = [target, ...args].map(quoteArg).join(' ');
const shellResult = await execShellAsync(cmd, options as unknown as ExecOptions);
return { stdout: String(shellResult.stdout), stderr: String(shellResult.stderr) };
}
/**
* Spawn a child process. If the initial `spawn()` call throws
* synchronously with EINVAL on Windows, retry using a shell-based
* command string. The returned `ChildProcess` is whatever the
* underlying call returned; listeners may safely be attached to it.
*/
export function spawnCli(
binaryPath: string,
args: string[],
options: SpawnOptions = {}
): ReturnType<typeof spawn> {
if (process.platform === 'win32' && needsShell(binaryPath)) {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return spawn(cmd, { ...options, shell: true });
}
try {
return spawn(binaryPath, args, options);
} catch (err: unknown) {
const code =
err && typeof err === 'object' && 'code' in err ? (err as { code?: string }).code : undefined;
if (process.platform === 'win32' && code === 'EINVAL') {
const cmd = [binaryPath, ...args].map(quoteArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback)
return spawn(cmd, { ...options, shell: true });
}
throw err;
}
}
/**
* Kill a child process and its entire process tree.
*
* On Windows with `shell: true`, `child.kill()` only kills the intermediate
* `cmd.exe` shell, leaving the actual process (e.g. `claude.cmd`) orphaned.
* `taskkill /T /F /PID` recursively kills the entire process tree.
*
* On macOS/Linux, processes are killed directly (no shell wrapper), so
* the standard `child.kill(signal)` works correctly.
*/
export function killProcessTree(
child: ChildProcess | null | undefined,
signal?: NodeJS.Signals
): void {
if (!child?.pid) {
// Process is null, never started, or already exited
return;
}
if (process.platform === 'win32') {
try {
const taskkillPath = path.join(
process.env.SystemRoot ?? 'C:\\Windows',
'System32',
'taskkill.exe'
);
execFile(taskkillPath, ['/T', '/F', '/PID', String(child.pid)], () => {
// Best-effort — ignore errors (process may have already exited)
});
return;
} catch {
// taskkill failed, fall through to standard kill
}
}
child.kill(signal);
}

View file

@ -223,9 +223,42 @@ export function buildTodoPath(claudeBasePath: string, sessionId: string): string
// =============================================================================
/**
* Get the user's home directory.
* Try Electron's app.getPath('home') which correctly handles Unicode paths
* on Windows (Cyrillic, CJK, etc.) unlike Node's os.homedir() / env vars
* that can suffer from UTF-8 vs system codepage mismatches.
*
* Returns null when Electron app is unavailable (e.g. in tests).
*/
function getHomeDir(): string {
function getElectronHome(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- Lazy require to avoid hard dependency on electron in test environments
const electron = require('electron') as {
app?: { getPath: (name: string) => string };
};
const app = electron.app;
if (app && typeof app.getPath === 'function') {
const home = app.getPath('home');
if (home) return home;
}
} catch {
// Not in Electron context (tests, standalone builds, etc.)
}
return null;
}
/**
* Get the user's home directory.
*
* Priority:
* 1. Electron app.getPath('home') correct Unicode handling on all platforms
* 2. HOME env var (Unix) / USERPROFILE (Windows)
* 3. HOMEDRIVE + HOMEPATH (Windows fallback)
* 4. os.homedir() (Node.js built-in)
*/
export function getHomeDir(): string {
const electronHome = getElectronHome();
if (electronHome) return electronHome;
const windowsHome =
process.env.HOMEDRIVE && process.env.HOMEPATH
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`

View file

@ -6,10 +6,9 @@
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getClaudeBasePath } from './pathDecoder';
import { getClaudeBasePath, getHomeDir } from './pathDecoder';
/**
* Sensitive file patterns that should never be accessible.
@ -67,7 +66,7 @@ function normalizeForCompare(input: string, isWindows: boolean): string {
return isWindows ? normalized.toLowerCase() : normalized;
}
function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
export function isPathWithinRoot(targetPath: string, rootPath: string): boolean {
return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep);
}
@ -85,7 +84,7 @@ function resolveRealPathIfExists(inputPath: string): string | null {
* @param normalizedPath - The normalized absolute path to check
* @returns true if path matches a sensitive pattern
*/
function matchesSensitivePattern(normalizedPath: string): boolean {
export function matchesSensitivePattern(normalizedPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath));
}
@ -149,7 +148,7 @@ export function validateFilePath(
// Expand ~ to home directory
const expandedPath = filePath.startsWith('~')
? path.join(os.homedir(), filePath.slice(1))
? path.join(getHomeDir(), filePath.slice(1))
: filePath;
// Must be absolute path
@ -212,7 +211,7 @@ export function validateOpenPathUserSelected(targetPath: string): PathValidation
}
const expandedPath = targetPath.startsWith('~')
? path.join(os.homedir(), targetPath.slice(1))
? path.join(getHomeDir(), targetPath.slice(1))
: targetPath;
const normalizedPath = path.resolve(path.normalize(expandedPath));
@ -256,7 +255,7 @@ export function validateOpenPath(
// Expand ~ to home directory
const expandedPath = targetPath.startsWith('~')
? path.join(os.homedir(), targetPath.slice(1))
? path.join(getHomeDir(), targetPath.slice(1))
: targetPath;
const normalizedPath = path.resolve(path.normalize(expandedPath));
@ -303,3 +302,67 @@ export function validateOpenPath(
return { valid: true, normalizedPath };
}
// =============================================================================
// Editor-specific validation utilities
// =============================================================================
const MAX_FILENAME_LENGTH = 255;
/** Characters forbidden in file/directory names. */
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
const INVALID_FILENAME_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
/**
* Validates a file or directory name for creation.
* Prevents path traversal, control chars, and OS-invalid characters.
*/
export function validateFileName(name: string): PathValidationResult {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmed = name.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'Name cannot be empty' };
}
if (trimmed.length > MAX_FILENAME_LENGTH) {
return { valid: false, error: `Name exceeds ${MAX_FILENAME_LENGTH} characters` };
}
if (trimmed === '.' || trimmed === '..') {
return { valid: false, error: 'Invalid name' };
}
if (INVALID_FILENAME_CHARS.test(trimmed)) {
return { valid: false, error: 'Name contains invalid characters' };
}
return { valid: true };
}
/** Blocked device/pseudo-filesystem path prefixes. */
const DEVICE_PATH_PREFIXES = ['/dev/', '/proc/', '/sys/'];
const WINDOWS_DEVICE_PREFIX = '\\\\.\\';
/**
* Returns true if the path points to a device or pseudo-filesystem
* (/dev/, /proc/, /sys/, \\\\.\\).
*/
export function isDevicePath(filePath: string): boolean {
const lower = filePath.toLowerCase();
if (DEVICE_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
return true;
}
return filePath.startsWith(WINDOWS_DEVICE_PREFIX);
}
/**
* Returns true if the path contains a `.git/` segment.
* Used to block writes to git internals.
*/
export function isGitInternalPath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/');
return normalized.includes('/.git/') || normalized.endsWith('/.git');
}

View file

@ -239,6 +239,9 @@ export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
/** Update task fields (subject, description) */
export const TEAM_UPDATE_TASK_FIELDS = 'team:updateTaskFields';
/** Soft-delete a team (sets deletedAt in config) */
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
@ -396,3 +399,49 @@ export const REVIEW_SAVE_DECISIONS = 'review:saveDecisions';
/** Clear review decisions from disk */
export const REVIEW_CLEAR_DECISIONS = 'review:clearDecisions';
// =============================================================================
// Editor Channels
// =============================================================================
/** Initialize editor, set activeProjectRoot in module-level state */
export const EDITOR_OPEN = 'editor:open';
/** Cleanup: reset activeProjectRoot, stop watcher */
export const EDITOR_CLOSE = 'editor:close';
/** Recursive directory reading (depth=1, lazy) */
export const EDITOR_READ_DIR = 'editor:readDir';
/** Read file content with binary detection */
export const EDITOR_READ_FILE = 'editor:readFile';
/** Write file content (atomic write) */
export const EDITOR_WRITE_FILE = 'editor:writeFile';
/** Create a new file */
export const EDITOR_CREATE_FILE = 'editor:createFile';
/** Create a new directory */
export const EDITOR_CREATE_DIR = 'editor:createDir';
/** Delete file or directory (move to Trash) */
export const EDITOR_DELETE_FILE = 'editor:deleteFile';
/** Move file or directory to a new location */
export const EDITOR_MOVE_FILE = 'editor:moveFile';
/** Search in files (literal string search) */
export const EDITOR_SEARCH_IN_FILES = 'editor:searchInFiles';
/** List all project files (for Quick Open) */
export const EDITOR_LIST_FILES = 'editor:listFiles';
/** Get git status for current project */
export const EDITOR_GIT_STATUS = 'editor:gitStatus';
/** Enable/disable file watcher for current project */
export const EDITOR_WATCH_DIR = 'editor:watchDir';
/** File change event from watcher (main -> renderer) */
export const EDITOR_CHANGE = 'editor:change';

View file

@ -10,6 +10,20 @@ import {
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
CONTEXT_SWITCH,
EDITOR_CHANGE,
EDITOR_CLOSE,
EDITOR_CREATE_DIR,
EDITOR_CREATE_FILE,
EDITOR_DELETE_FILE,
EDITOR_GIT_STATUS,
EDITOR_LIST_FILES,
EDITOR_MOVE_FILE,
EDITOR_OPEN,
EDITOR_READ_DIR,
EDITOR_READ_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
HTTP_SERVER_GET_STATUS,
HTTP_SERVER_START,
HTTP_SERVER_STOP,
@ -77,6 +91,7 @@ import {
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
TEAM_UPDATE_TASK_FIELDS,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
TERMINAL_DATA,
@ -179,6 +194,20 @@ import type {
UpdateKanbanPatch,
WslClaudeRootCandidate,
} from '@shared/types';
import type {
CreateDirResponse,
CreateFileResponse,
DeleteFileResponse,
EditorFileChangeEvent,
GitStatusResult,
MoveFileResponse,
QuickOpenFile,
ReadDirResult,
ReadFileResult,
SearchInFilesOptions,
SearchInFilesResult,
WriteFileResponse,
} from '@shared/types/editor';
import type { PtySpawnOptions } from '@shared/types/terminal';
// =============================================================================
@ -636,6 +665,13 @@ const electronAPI: ElectronAPI = {
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
},
updateTaskFields: async (
teamName: string,
taskId: string,
fields: { subject?: string; description?: string }
) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_FIELDS, teamName, taskId, fields);
},
startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId);
},
@ -920,6 +956,38 @@ const electronAPI: ElectronAPI = {
};
},
},
// ===== Editor API =====
editor: {
open: (projectPath: string) => invokeIpcWithResult<void>(EDITOR_OPEN, projectPath),
close: () => invokeIpcWithResult<void>(EDITOR_CLOSE),
readDir: (dirPath: string, maxEntries?: number) =>
invokeIpcWithResult<ReadDirResult>(EDITOR_READ_DIR, dirPath, maxEntries),
readFile: (filePath: string) => invokeIpcWithResult<ReadFileResult>(EDITOR_READ_FILE, filePath),
writeFile: (filePath: string, content: string, baselineMtimeMs?: number) =>
invokeIpcWithResult<WriteFileResponse>(EDITOR_WRITE_FILE, filePath, content, baselineMtimeMs),
createFile: (parentDir: string, fileName: string) =>
invokeIpcWithResult<CreateFileResponse>(EDITOR_CREATE_FILE, parentDir, fileName),
createDir: (parentDir: string, dirName: string) =>
invokeIpcWithResult<CreateDirResponse>(EDITOR_CREATE_DIR, parentDir, dirName),
deleteFile: (filePath: string) =>
invokeIpcWithResult<DeleteFileResponse>(EDITOR_DELETE_FILE, filePath),
moveFile: (sourcePath: string, destDir: string) =>
invokeIpcWithResult<MoveFileResponse>(EDITOR_MOVE_FILE, sourcePath, destDir),
searchInFiles: (options: SearchInFilesOptions) =>
invokeIpcWithResult<SearchInFilesResult>(EDITOR_SEARCH_IN_FILES, options),
listFiles: () => invokeIpcWithResult<QuickOpenFile[]>(EDITOR_LIST_FILES),
gitStatus: () => invokeIpcWithResult<GitStatusResult>(EDITOR_GIT_STATUS),
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
callback(data);
ipcRenderer.on(EDITOR_CHANGE, listener);
return (): void => {
ipcRenderer.removeListener(EDITOR_CHANGE, listener);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -8,7 +8,7 @@ import { ErrorBoundary } from './components/common/ErrorBoundary';
import { TabbedLayout } from './components/layout/TabbedLayout';
import { useTheme } from './hooks/useTheme';
import { api } from './api';
import { initializeNotificationListeners, useStore } from './store';
import { useStore } from './store';
export const App = (): React.JSX.Element => {
// Initialize theme on app load
@ -23,26 +23,17 @@ export const App = (): React.JSX.Element => {
}
}, []);
// Initialize context system (before notification listeners)
useEffect(() => {
void useStore.getState().initializeContextSystem();
}, []);
// Refresh available contexts when SSH connection state changes
// Initialize context system lazily when SSH connection state changes.
// Local-only users never pay the cost of IndexedDB init + context IPC calls.
useEffect(() => {
if (!api.ssh?.onStatus) return;
const cleanup = api.ssh.onStatus(() => {
void useStore.getState().initializeContextSystem();
void useStore.getState().fetchAvailableContexts();
});
return cleanup;
}, []);
// Initialize IPC event listeners (notifications, file changes)
useEffect(() => {
const cleanup = initializeNotificationListeners();
return cleanup;
}, []);
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>

View file

@ -63,6 +63,7 @@ import type {
WslClaudeRootCandidate,
} from '@shared/types';
import type { AgentConfig } from '@shared/types/api';
import type { EditorAPI } from '@shared/types/editor';
import type { TerminalAPI } from '@shared/types/terminal';
export class HttpAPIClient implements ElectronAPI {
@ -703,6 +704,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
throw new Error('Team task owner update is not available in browser mode');
},
updateTaskFields: async (
_teamName: string,
_taskId: string,
_fields: { subject?: string; description?: string }
): Promise<void> => {
throw new Error('Team task fields update is not available in browser mode');
},
startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => {
throw new Error('Team start task is not available in browser mode');
},
@ -903,4 +911,53 @@ export class HttpAPIClient implements ElectronAPI {
onData: (): (() => void) => () => {},
onExit: (): (() => void) => () => {},
};
// ---------------------------------------------------------------------------
// Editor (not available in browser mode)
// ---------------------------------------------------------------------------
editor: EditorAPI = {
open: async () => {
throw new Error('Editor not available in browser mode');
},
close: async () => {
throw new Error('Editor not available in browser mode');
},
readDir: async () => {
throw new Error('Editor not available in browser mode');
},
readFile: async () => {
throw new Error('Editor not available in browser mode');
},
writeFile: async () => {
throw new Error('Editor not available in browser mode');
},
createFile: async () => {
throw new Error('Editor not available in browser mode');
},
createDir: async () => {
throw new Error('Editor not available in browser mode');
},
deleteFile: async () => {
throw new Error('Editor not available in browser mode');
},
moveFile: async () => {
throw new Error('Editor not available in browser mode');
},
searchInFiles: async () => {
throw new Error('Editor not available in browser mode');
},
listFiles: async () => {
throw new Error('Editor not available in browser mode');
},
gitStatus: async () => {
throw new Error('Editor not available in browser mode');
},
watchDir: async () => {
throw new Error('Editor not available in browser mode');
},
onEditorChange: () => {
return () => {};
},
};
}

View file

@ -64,7 +64,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered,
selectSearchMatch,
setTabVisibleAIGroup,
teams,
openTeamTab,
openSessionReport,
} = useStore(
@ -79,7 +78,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered,
selectSearchMatch: s.selectSearchMatch,
setTabVisibleAIGroup: s.setTabVisibleAIGroup,
teams: s.teams,
openTeamTab: s.openTeamTab,
openSessionReport: s.openSessionReport,
}))
@ -126,12 +124,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
const pendingNavigation = thisTab?.pendingNavigation;
const teamBySessionId = useStore((s) => s.teamBySessionId);
// Look up whether this session belongs to a team
const sessionTeam = useMemo(() => {
if (!sessionDetail?.session?.id) return null;
const sid = sessionDetail.session.id;
return teams.find((t) => t.leadSessionId === sid || t.sessionHistory?.includes(sid)) ?? null;
}, [teams, sessionDetail?.session?.id]);
const sid = sessionDetail?.session?.id;
if (!sid) return null;
return teamBySessionId[sid] ?? null;
}, [teamBySessionId, sessionDetail?.session?.id]);
// Compute all accumulated context injections (phase-aware)
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {

View file

@ -268,6 +268,14 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
/** Default components without search highlighting */
const defaultComponents = createViewerMarkdownComponents(null);
// Markdown + syntax highlighting can freeze the renderer on some inputs
// (very large text, huge code blocks, pathological markdown). Keep the UI responsive:
// - for medium/large content: disable syntax highlighting
// - for very large content: show a raw preview instead of parsing markdown
const DISABLE_HIGHLIGHT_CHARS = 12_000;
const MAX_MARKDOWN_CHARS = 60_000;
const LARGE_PREVIEW_CHARS = 30_000;
// =============================================================================
// Component
// =============================================================================
@ -281,6 +289,12 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
copyable = false,
bare = false,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
// Only subscribe to search store when itemId is provided
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => ({
@ -290,6 +304,128 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}))
);
// Guard: very large markdown can freeze the renderer (remark/rehype + highlighting).
// For large content, default to a lightweight raw preview with manual expansion.
if (isTooLarge || showRaw) {
const shown = content.slice(0, Math.min(rawLimit, content.length));
const isTruncated = shown.length < content.length;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</button>
</div>
</div>
)}
</div>
</div>
);
}
// Create search context (fresh each render so counter starts at 0)
const searchCtx =
searchQuery && itemId
@ -345,7 +481,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<div className="min-w-0 break-words p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS}
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
urlTransform={(url) => url}
components={components}
>

View file

@ -0,0 +1,182 @@
/**
* Generic file tree component with render-props for customization.
*
* Used by EditorFileTree (FileTreeEntry) and ReviewFileTree (FileChangeSummary).
* ARIA: role="tree", role="treeitem", aria-expanded, role="group".
*/
import React, { useCallback } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
// =============================================================================
// Types
// =============================================================================
interface FileTreeProps<T> {
nodes: TreeNode<T>[];
activeNodePath: string | null;
onNodeClick: (node: TreeNode<T>) => void;
expandedPaths: Record<string, boolean>;
onToggleExpand: (fullPath: string) => void;
renderLeafNode?: (node: TreeNode<T>, isSelected: boolean, depth: number) => React.ReactNode;
renderFolderLabel?: (node: TreeNode<T>, isOpen: boolean, depth: number) => React.ReactNode;
renderNodeIcon?: (node: TreeNode<T>) => React.ReactNode;
/** Optional data attributes placed on each <li> for event delegation (e.g. context menu) */
getNodeDataAttrs?: (node: TreeNode<T>) => Record<string, string>;
maxDepth?: number;
}
const MAX_VISUAL_DEPTH = 12;
const INDENT_PX = 12;
// =============================================================================
// Component
// =============================================================================
export const FileTree = <T,>(props: Readonly<FileTreeProps<T>>): React.ReactElement => {
const { nodes, maxDepth = MAX_VISUAL_DEPTH } = props;
return (
<ul role="tree" className="select-none text-sm">
{nodes.map((node) => (
<TreeItem key={node.fullPath} node={node} depth={0} maxDepth={maxDepth} {...props} />
))}
</ul>
);
};
// =============================================================================
// TreeItem (recursive)
// =============================================================================
interface TreeItemProps<T> extends FileTreeProps<T> {
node: TreeNode<T>;
depth: number;
}
const TreeItemInner = <T,>({
node,
depth,
activeNodePath,
onNodeClick,
expandedPaths,
onToggleExpand,
renderLeafNode,
renderFolderLabel,
renderNodeIcon,
getNodeDataAttrs,
maxDepth = MAX_VISUAL_DEPTH,
nodes: _nodes,
...rest
}: Readonly<TreeItemProps<T>>): React.ReactElement => {
const visualDepth = Math.min(depth, maxDepth);
const isSelected = activeNodePath === node.fullPath;
const dataAttrs = getNodeDataAttrs?.(node);
const handleClick = useCallback(() => {
if (node.isFile) {
onNodeClick(node);
} else {
onToggleExpand(node.fullPath);
}
}, [node, onNodeClick, onToggleExpand]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
},
[handleClick]
);
// Leaf node (file)
if (node.isFile) {
if (renderLeafNode) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
<li role="treeitem" aria-selected={isSelected} {...dataAttrs}>
{renderLeafNode(node, isSelected, visualDepth)}
</li>
);
}
return (
<li
role="treeitem"
aria-selected={isSelected}
className={`flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 hover:bg-surface-raised ${
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
}`}
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
title={node.fullPath}
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
{...dataAttrs}
>
{renderNodeIcon?.(node)}
<span className="truncate">{node.name}</span>
</li>
);
}
// Folder node
const isExpanded = expandedPaths[node.fullPath] === true;
return (
// eslint-disable-next-line react/jsx-props-no-spreading -- data attributes from getNodeDataAttrs require spreading
<li role="treeitem" aria-expanded={isExpanded} aria-selected={isSelected} {...dataAttrs}>
{renderFolderLabel ? (
renderFolderLabel(node, isExpanded, visualDepth)
) : (
<div
className="flex cursor-pointer items-center gap-1 truncate px-2 py-0.5 text-text-secondary hover:bg-surface-raised"
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
title={depth >= maxDepth ? node.fullPath : undefined}
>
{isExpanded ? (
<ChevronDown className="size-3.5 shrink-0 text-text-muted" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-text-muted" />
)}
{renderNodeIcon?.(node)}
<span className="truncate">{node.name}</span>
</div>
)}
{isExpanded && node.children.length > 0 && (
<ul role="group">
{node.children.map((child) => (
<TreeItemInner
key={child.fullPath}
node={child}
depth={depth + 1}
activeNodePath={activeNodePath}
onNodeClick={onNodeClick}
expandedPaths={expandedPaths}
onToggleExpand={onToggleExpand}
renderLeafNode={renderLeafNode}
renderFolderLabel={renderFolderLabel}
renderNodeIcon={renderNodeIcon}
getNodeDataAttrs={getNodeDataAttrs}
maxDepth={maxDepth}
nodes={[]}
{...rest}
/>
))}
</ul>
)}
</li>
);
};
const TreeItem = React.memo(TreeItemInner) as typeof TreeItemInner;

View file

@ -2,7 +2,7 @@
* UpdateDialog - Modal dialog shown when a new version is available.
*
* Prompts the user to download the update or dismiss it.
* Release notes may be HTML from the updater; we normalize to text and render as markdown.
* Release notes (markdown from GitHub) are rendered with ReactMarkdown.
*/
import { useEffect, useRef } from 'react';
@ -14,31 +14,6 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { X } from 'lucide-react';
import remarkGfm from 'remark-gfm';
/**
* Normalize release notes: strip HTML tags and convert block elements to newlines.
* Uses DOMParser for proper HTML entity decoding (handles all entities like &mdash;, &#39;, etc.)
*/
function normalizeReleaseNotes(html: string): string {
if (!html?.trim()) return '';
// Convert block elements to newlines for better formatting
const processed = html
.replace(/<\/p>\s*/gi, '\n\n')
.replace(/<br\s*\/?>\s*/gi, '\n')
.replace(/<\/div>\s*/gi, '\n')
.replace(/<\/li>\s*/gi, '\n')
.replace(/<\/h[1-6]>\s*/gi, '\n\n');
// Use DOMParser to decode HTML entities and strip remaining tags
// This properly handles all HTML entities (&nbsp;, &mdash;, &#39;, etc.)
const parser = new DOMParser();
const doc = parser.parseFromString(processed, 'text/html');
const text = doc.body.textContent || '';
// Normalize multiple newlines
return text.replace(/\n{3,}/g, '\n\n').trim();
}
export const UpdateDialog = (): React.JSX.Element | null => {
const showUpdateDialog = useStore((s) => s.showUpdateDialog);
const availableVersion = useStore((s) => s.availableVersion);
@ -141,14 +116,14 @@ export const UpdateDialog = (): React.JSX.Element | null => {
)}
</div>
{/* Release notes — normalize HTML then render as markdown */}
{/* Release notes */}
{releaseNotes && (
<div
className="prose prose-sm mb-4 max-h-48 overflow-y-auto rounded border p-2 text-xs"
className="prose prose-sm prose-invert mb-4 max-h-60 overflow-y-auto rounded border p-3 text-xs"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
color: 'var(--color-text-secondary)',
}}
>
<ReactMarkdown
@ -156,7 +131,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
rehypePlugins={REHYPE_PLUGINS}
components={markdownComponents}
>
{normalizeReleaseNotes(releaseNotes)}
{releaseNotes}
</ReactMarkdown>
</div>
)}

View file

@ -7,9 +7,10 @@
* Only rendered in Electron mode.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { formatBytes } from '@renderer/utils/formatters';
@ -51,38 +52,6 @@ const DetailLine = ({ text }: { text: string | null }): React.JSX.Element | null
);
};
/** Mini log panel shown during the installing phase */
const LogPanel = ({ logs }: { logs: string[] }): React.JSX.Element | null => {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
if (logs.length === 0) return null;
return (
<div
ref={scrollRef}
className="mt-2 max-h-24 overflow-y-auto rounded border font-mono text-xs leading-relaxed"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface)',
padding: '6px 8px',
color: 'var(--color-text-muted)',
}}
>
{logs.map((line, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>&rsaquo;</span> {line}
</div>
))}
</div>
);
};
/** Error display with multi-line support */
const ErrorDisplay = ({
error,
@ -151,7 +120,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
downloadTotal,
installerError,
installerDetail,
installerLogs,
installerRawChunks,
completedVersion,
fetchCliStatus,
installCli,
@ -321,7 +290,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
Installing Claude CLI...
</span>
</div>
<LogPanel logs={installerLogs} />
<TerminalLogPanel chunks={installerRawChunks} />
</div>
);
}
@ -436,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onExit={() => {
void fetchCliStatus();
}}
autoCloseOnSuccessMs={4000}
successMessage="Login complete"
failureMessage="Login failed"
/>
)}
</>

View file

@ -146,7 +146,7 @@ const RepositoryCard = ({
(e: React.MouseEvent) => {
e.stopPropagation();
if (projectPath) {
void api.openPath(projectPath);
void api.openPath(projectPath, projectPath);
}
},
[projectPath]

View file

@ -236,14 +236,26 @@ export const DateGroupedSessions = (): React.JSX.Element => {
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Fetch project data on mount
// Fetch project data on mount or when viewMode changes.
// Loading guards in the store actions prevent duplicate IPC calls
// when the centralized init chain has already started a fetch.
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
const projectsLoading = useStore((s) => s.projectsLoading);
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
if (viewMode === 'grouped' && repositoryGroups.length === 0 && !repositoryGroupsLoading) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
}, [
viewMode,
repositoryGroups.length,
projects.length,
repositoryGroupsLoading,
projectsLoading,
fetchRepositoryGroups,
fetchProjects,
]);
// Project combobox options
const projectComboboxOptions = useMemo((): ComboboxOption[] => {

View file

@ -128,12 +128,14 @@ export const GlobalTaskList = ({
saveGroupingMode(mode);
};
// Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching.
useEffect(() => {
if (!hasFetchedRef.current) {
if (!hasFetchedRef.current && !globalTasksLoading) {
hasFetchedRef.current = true;
void fetchAllTasks();
}
}, [fetchAllTasks]);
}, [fetchAllTasks, globalTasksLoading]);
// Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => {

View file

@ -64,7 +64,7 @@ export const SidebarTaskItem = ({
showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members);
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg =
task.kanbanColumn === 'approved'

View file

@ -45,7 +45,7 @@ export const MemberBadge = ({
className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{name}
{name === 'team-lead' ? 'lead' : name}
</span>
);

File diff suppressed because it is too large Load diff

View file

@ -142,13 +142,34 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
.filter((member): member is NonNullable<typeof member> => member !== null);
}
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
const TEAM_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MEMBER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
function validateTeamNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!TEAM_NAME_RE.test(trimmed) || trimmed.length > 64) {
return 'Use kebab-case [a-z0-9-], max 64 chars';
}
return null;
}
function validateMemberNameInline(name: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (!MEMBER_NAME_RE.test(trimmed)) {
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
}
return null;
}
function validateRequest(
request: TeamCreateRequest,
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
if (!TEAM_NAME_RE.test(request.teamName) || request.teamName.length > 64) {
return {
valid: false,
errors: {
@ -180,8 +201,7 @@ function validateRequest(
},
};
}
const memberNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
if (request.members.some((member) => !memberNamePattern.test(member.name.trim()))) {
if (request.members.some((member) => !MEMBER_NAME_RE.test(member.name.trim()))) {
return {
valid: false,
errors: {
@ -692,6 +712,8 @@ export const CreateTeamDialog = ({
/>
{existingTeamNames.includes(teamName.trim()) ? (
<p className="text-[11px] text-red-300">Team name already exists</p>
) : validateTeamNameInline(teamName) ? (
<p className="text-[11px] text-red-300">{validateTeamNameInline(teamName)}</p>
) : fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
) : null}
@ -727,20 +749,27 @@ export const CreateTeamDialog = ({
borderLeftColor: memberColorSet.border,
}}
>
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
onChange={(event) => updateMemberName(member.id, event.target.value)}
placeholder="member-name"
style={
member.name.trim()
? {
color: memberColorSet.text,
}
: undefined
}
/>
<div className="space-y-0.5">
<Input
className="h-8 text-xs"
value={member.name}
aria-label={`Member ${index + 1} name`}
onChange={(event) => updateMemberName(member.id, event.target.value)}
placeholder="member-name"
style={
member.name.trim()
? {
color: memberColorSet.text,
}
: undefined
}
/>
{validateMemberNameInline(member.name) ? (
<p className="text-[10px] text-red-300">
{validateMemberNameInline(member.name)}
</p>
) : null}
</div>
<div className="space-y-1">
<Select
value={member.roleSelection || NO_ROLE}
@ -791,9 +820,15 @@ export const CreateTeamDialog = ({
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{fieldErrors.members ? (
<p className="text-[11px] text-red-300">{fieldErrors.members}</p>
) : null}
{(() => {
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
if (hasDuplicates)
return <p className="text-[11px] text-red-300">Member names must be unique</p>;
if (fieldErrors.members)
return <p className="text-[11px] text-red-300">{fieldErrors.members}</p>;
return null;
})()}
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { ExternalLink } from 'lucide-react';
@ -6,7 +6,7 @@ import { useShallow } from 'zustand/react/shallow';
import { TaskDetailDialog } from './TaskDetailDialog';
import type { TeamTaskWithKanban } from '@shared/types';
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
/**
* Global wrapper around TaskDetailDialog.
@ -17,36 +17,77 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
const {
globalTaskDetail,
closeGlobalTaskDetail,
selectedTeamName,
selectedTeamData,
selectedTeamLoading,
selectTeam,
openTeamTab,
setPendingReviewRequest,
globalTasks,
} = useStore(
useShallow((s) => ({
globalTaskDetail: s.globalTaskDetail,
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
selectedTeamLoading: s.selectedTeamLoading,
selectTeam: s.selectTeam,
openTeamTab: s.openTeamTab,
setPendingReviewRequest: s.setPendingReviewRequest,
globalTasks: s.globalTasks,
}))
);
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
if (!selectedTeamData) return map;
for (const t of selectedTeamData.tasks) map.set(t.id, t);
return map;
}, [selectedTeamData]);
const activeMembers = useMemo(
() => selectedTeamData?.members.filter((m) => !m.removedAt) ?? [],
[selectedTeamData]
);
const teamName = globalTaskDetail?.teamName ?? '';
const taskId = globalTaskDetail?.taskId ?? '';
// Load full team data in the background to enable "as before" details (logs/changes/members).
useEffect(() => {
if (!globalTaskDetail) return;
if (!teamName) return;
// Avoid re-triggering selectTeam in a loop while the fetch is in flight.
// selectedTeamName is set immediately by selectTeam(), but selectedTeamData
// remains null until IPC resolves.
if (selectedTeamName === teamName) {
if (selectedTeamData || selectedTeamLoading) return;
// Retry once if we are on the right team but have no data and not loading (e.g. prior error).
}
void selectTeam(teamName, { skipProjectAutoSelect: true });
}, [
globalTaskDetail,
selectTeam,
selectedTeamData,
selectedTeamLoading,
selectedTeamName,
teamName,
]);
const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData;
const isThisTeamLoading =
selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData;
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
if (!globalTaskDetail) return map;
if (isFullTeamLoaded && selectedTeamData) {
for (const t of selectedTeamData.tasks) map.set(t.id, t);
return map;
}
for (const t of globalTasks) {
if (t.teamName === globalTaskDetail.teamName) {
map.set(t.id, t);
}
}
return map;
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
const activeMembers = useMemo(
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
[isFullTeamLoaded, selectedTeamData]
);
const handleOpenTeam = useCallback((): void => {
closeGlobalTaskDetail();
openTeamTab(teamName, undefined, taskId);
@ -63,20 +104,24 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
if (!globalTaskDetail) return null;
const task = taskMap.get(taskId) ?? null;
const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId];
const task = (taskMap.get(taskId) as GlobalTask | undefined) ?? null;
const kanbanTaskState = isFullTeamLoaded
? selectedTeamData?.kanbanState.tasks[taskId]
: undefined;
return (
<TaskDetailDialog
open
task={selectedTeamLoading ? null : task}
variant={isFullTeamLoaded ? 'team' : 'global'}
loading={!isFullTeamLoaded && isThisTeamLoading}
task={task}
teamName={teamName}
kanbanTaskState={kanbanTaskState}
taskMap={taskMap}
members={activeMembers}
onClose={closeGlobalTaskDetail}
onOwnerChange={undefined}
onViewChanges={handleViewChanges}
onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined}
headerExtra={
<button
type="button"

View file

@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Dialog,
DialogContent,
@ -12,19 +13,13 @@ import {
} from '@renderer/components/ui/dialog';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from 'lucide-react';
import { ProjectPathSelector } from './ProjectPathSelector';
@ -71,6 +66,7 @@ export const LaunchTeamDialog = ({
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
const [clearContext, setClearContext] = useState(false);
const resetFormState = (): void => {
setLocalError(null);
@ -82,6 +78,7 @@ export const LaunchTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setSelectedModel('');
setClearContext(false);
};
// Warm up CLI on open
@ -243,7 +240,8 @@ export const LaunchTeamDialog = ({
teamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
model: selectedModel || undefined,
clearContext: clearContext || undefined,
});
resetFormState();
onClose();
@ -357,17 +355,56 @@ export const LaunchTeamDialog = ({
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
selectedModel === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setSelectedModel(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="clear-context"
checked={clearContext}
onCheckedChange={(checked) => setClearContext(checked === true)}
/>
<Label
htmlFor="clear-context"
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
>
<RotateCcw className="size-3 shrink-0" />
Clear context (fresh session)
</Label>
</div>
{clearContext && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
<p className="text-amber-300/90">
The team lead will start a new session without resuming previous context. All
accumulated session memory and conversation history will not be available.
</p>
</div>
</div>
)}
</div>
</div>

View file

@ -39,6 +39,8 @@ interface SendMessageDialogProps {
open: boolean;
members: ResolvedTeamMember[];
defaultRecipient?: string;
/** Pre-filled message text (e.g. from editor selection action) */
defaultText?: string;
quotedMessage?: QuotedMessage;
sending: boolean;
sendError: string | null;
@ -53,6 +55,7 @@ export const SendMessageDialog = ({
open,
members,
defaultRecipient,
defaultText,
quotedMessage,
sending,
sendError,
@ -74,6 +77,9 @@ export const SendMessageDialog = ({
setSummary('');
setQuote(quotedMessage);
setPrevResult(lastResult);
if (defaultText) {
textDraft.setValue(defaultText);
}
}
if (open !== prevOpen) {
setPrevOpen(open);

Some files were not shown because too many files have changed in this diff Show more