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.
This commit is contained in:
parent
7192ca68fb
commit
d7fc71a5e6
13 changed files with 1300 additions and 133 deletions
|
|
@ -28,20 +28,23 @@
|
|||
|
||||
## Навигация по плану
|
||||
|
||||
| Файл | Содержимое |
|
||||
|------|------------|
|
||||
| [architecture.md](architecture.md) | Архитектура, безопасность, state, IPC API, сервисы, компоненты, CM6, shortcuts, CSS |
|
||||
| [iter-0-refactoring.md](iter-0-refactoring.md) | PR 0: Обязательные рефакторинги R1-R4 (отдельный PR) |
|
||||
| [iter-1-walking-skeleton.md](iter-1-walking-skeleton.md) | Итерация 1: Read-only файловый браузер |
|
||||
| [iter-2-editable-save.md](iter-2-editable-save.md) | Итерация 2: Editable CodeMirror + сохранение |
|
||||
| [iter-3-multi-tab-crud.md](iter-3-multi-tab-crud.md) | Итерация 3: Multi-tab + создание/удаление файлов |
|
||||
| [iter-4-search-shortcuts.md](iter-4-search-shortcuts.md) | Итерация 4: Горячие клавиши, поиск, UX polish |
|
||||
| [iter-5-git-watching.md](iter-5-git-watching.md) | Итерация 5: Git status, file watching, conflict detection |
|
||||
| [file-list.md](file-list.md) | Риски, бенчмарки, полный список файлов |
|
||||
| # | Файл | Содержимое |
|
||||
|---|------|------------|
|
||||
| — | [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, пересмотр позже) |
|
||||
|
||||
## Общая статистика
|
||||
|
||||
- **Новые файлы**: ~30
|
||||
- **Модификации**: ~17 существующих файлов
|
||||
- **Новые файлы**: ~35
|
||||
- **Модификации**: ~18 существующих файлов
|
||||
- **Тесты**: ~15 новых тестовых файлов
|
||||
- **Итерации**: 6 (PR 0 + 5 итераций)
|
||||
- **Ресёрч**: 5/5 завершён (R1-R5, см. [research-tasks.md](research-tasks.md))
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ src/renderer/components/team/editor/
|
|||
├── 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)
|
||||
```
|
||||
|
|
@ -214,6 +217,8 @@ export interface EditorSlice {
|
|||
editorFileTreeLoading: boolean;
|
||||
editorFileTreeError: string | null;
|
||||
|
||||
editorExpandedDirs: Set<string>; // Сохраняется при re-open (H7)
|
||||
|
||||
openEditor: (projectPath: string) => Promise<void>;
|
||||
closeEditor: () => void;
|
||||
// closeEditor() выполняет полный cleanup:
|
||||
|
|
@ -247,7 +252,7 @@ export interface EditorSlice {
|
|||
// В store -- только dirty flags, loading и статусы сохранения.
|
||||
// ═══════════════════════════════════════════════════
|
||||
editorFileLoading: Record<string, boolean>; // per-file loading indicator
|
||||
editorModifiedFiles: Set<string>; // dirty markers (НЕ содержимое!)
|
||||
editorModifiedFiles: Record<string, boolean>; // dirty markers (НЕ содержимое!). Record вместо Set — Zustand не отслеживает мутации Set
|
||||
editorSaving: Record<string, boolean>;
|
||||
editorSaveError: Record<string, string>;
|
||||
|
||||
|
|
@ -259,7 +264,7 @@ export interface EditorSlice {
|
|||
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; // derived getter
|
||||
hasUnsavedChanges: () => boolean; // Object.keys(editorModifiedFiles).length > 0
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// Группа 4: File operations (итерация 3)
|
||||
|
|
@ -282,6 +287,52 @@ interface EditorFileTab {
|
|||
}
|
||||
```
|
||||
|
||||
### 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; },
|
||||
/** Для saveFile() — контент из кешированного state */
|
||||
getContent(filePath: string): string | null {
|
||||
return stateCache?.get(filePath)?.doc.toString() ?? null;
|
||||
},
|
||||
/** Для saveAllFiles() — контент всех modified файлов */
|
||||
getAllModifiedContent(modifiedFiles: Set<string>): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const fp of modifiedFiles) {
|
||||
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).
|
||||
|
||||
### EditorState pooling (Map в useRef)
|
||||
|
||||
Контент файлов живёт ТОЛЬКО в CodeMirror EditorState. Один активный EditorView на весь редактор.
|
||||
|
|
@ -396,7 +447,8 @@ interface ReadFileResult {
|
|||
|
||||
interface GitFileStatus {
|
||||
path: string;
|
||||
status: 'modified' | 'untracked' | 'staged' | 'deleted';
|
||||
status: 'modified' | 'untracked' | 'staged' | 'deleted' | 'conflict';
|
||||
// 'conflict' = merge conflicts (git porcelain codes UU, AA, DD)
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
|
|
@ -681,6 +733,14 @@ interface EditorExtensionOptions {
|
|||
// 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 [
|
||||
|
|
@ -774,20 +834,53 @@ Diff-специфичные стили (`.cm-changedLine`, `.cm-deletedChunk`, `
|
|||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Действие | Итерация |
|
||||
|----------|---------|----------|
|
||||
| `Cmd+S` | Сохранить активный файл | 2 |
|
||||
| `Cmd+Shift+S` | Сохранить все | 2 |
|
||||
| `Cmd+W` | Закрыть активный tab | 3 |
|
||||
| `Cmd+P` | Quick Open (fuzzy search файлов) | 4 |
|
||||
| `Cmd+F` | Поиск в файле (CM6 search) | 2 |
|
||||
| `Cmd+Shift+F` | Поиск по содержимому файлов | 4 |
|
||||
| `Cmd+Shift+[` / `Cmd+Shift+]` | Переключение табов влево/вправо | 4 |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Переключение табов (MRU) | 4 |
|
||||
| `Cmd+B` | Toggle file tree sidebar | 4 |
|
||||
| `Cmd+G` | Go to line (CM6 gotoLine) | 4 |
|
||||
| `Cmd+Z` / `Cmd+Shift+Z` | Undo/Redo (CM6 native) | 2 |
|
||||
| `Escape` | Закрыть overlay (с confirm при unsaved) | 1 |
|
||||
| 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
|
||||
|
|
|
|||
137
docs/iterations/edit-project/file-list.md
Normal file
137
docs/iterations/edit-project/file-list.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Риски, бенчмарки, полный список файлов
|
||||
|
||||
## Риски
|
||||
|
||||
| # | Риск | Вероятность | Импакт | Итерация | Митигация |
|
||||
|---|------|------------|--------|----------|-----------|
|
||||
| 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 | Возможен autosave в localStorage/IndexedDB (P2 фича) |
|
||||
| 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 применён) |
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
```
|
||||
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 раз при наборе
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный список файлов
|
||||
|
||||
### Новые файлы (~30)
|
||||
|
||||
| # | Файл | Итерация | Описание |
|
||||
|---|------|----------|----------|
|
||||
| 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 --porcelain |
|
||||
| 6 | `src/main/services/editor/EditorFileWatcher.ts` | 5 | FileWatcher (~250-300 LOC, burst coalescing + ENOSPC fallback) |
|
||||
| 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 |
|
||||
| 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/EditorContextMenu.tsx` | 3 | Context menu |
|
||||
| 28 | `src/renderer/components/team/editor/NewFileDialog.tsx` | 3 | Inline-input |
|
||||
| 29 | `src/renderer/components/team/editor/QuickOpenDialog.tsx` | 4 | Cmd+P dialog |
|
||||
| 30 | `src/renderer/components/team/editor/SearchInFilesPanel.tsx` | 4 | Cmd+Shift+F |
|
||||
| 31 | `src/renderer/components/team/editor/EditorBreadcrumb.tsx` | 4 | Breadcrumb |
|
||||
| 32 | `src/renderer/components/team/editor/EditorShortcutsHelp.tsx` | 4 | Shortcuts modal |
|
||||
| 33 | `src/renderer/components/team/editor/fileIcons.ts` | 4 | Иконки файлов |
|
||||
| 34 | `src/renderer/components/team/editor/GitStatusBadge.tsx` | 5 | M/U/A/C(conflict) бейджи |
|
||||
| 35 | `src/renderer/utils/editorBridge.ts` | 2 | Module-level singleton: Store ↔ CM6 refs bridge (R3) |
|
||||
|
||||
### Модификации существующих файлов (~17)
|
||||
|
||||
| # | Файл | Итерация | Изменение |
|
||||
|---|------|----------|-----------|
|
||||
| 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` | 5 | `mainWindow.on('closed')` → `cleanupEditorState()`. `shutdownServices()` → `cleanupEditorState()` |
|
||||
| 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 |
|
||||
87
docs/iterations/edit-project/iter-0-refactoring.md
Normal file
87
docs/iterations/edit-project/iter-0-refactoring.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# 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)
|
||||
|
||||
## Критерии готовности
|
||||
|
||||
- `pnpm typecheck` проходит
|
||||
- Тесты `ReviewFileTree` и `CodeMirrorDiffView` проходят (zero behavior change)
|
||||
- ChangeReviewDialog работает корректно (manual check)
|
||||
- Новые unit-тесты для `fileTreeBuilder.ts` и `ipcWrapper.ts`
|
||||
113
docs/iterations/edit-project/iter-1-walking-skeleton.md
Normal file
113
docs/iterations/edit-project/iter-1-walking-skeleton.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Итерация 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`)
|
||||
|
||||
## 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` | Заглушка для ошибок чтения |
|
||||
|
||||
## Изменения в существующих файлах
|
||||
|
||||
| # | Файл | Изменение |
|
||||
|---|------|-----------|
|
||||
| 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. **B1**: Экспортировать `isPathWithinRoot()` (приватная, строка ~30) — нужна для 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. `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)
|
||||
2. `ProjectFileService.readFile()`: `fs.lstat()` -> `isFile()` ДО чтения. `stats.size <= 2MB`. Block device paths. Post-read realpath verify (SEC-3, SEC-4)
|
||||
3. `activeProjectRoot` в module-level state, НЕ от renderer (SEC-5)
|
||||
4. 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: null bytes в первых 8KB
|
||||
- Дедупликация 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** -- самый понятный этап, минимум неизвестных.
|
||||
82
docs/iterations/edit-project/iter-2-editable-save.md
Normal file
82
docs/iterations/edit-project/iter-2-editable-save.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Итерация 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
|
||||
|
||||
## 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
|
||||
|
||||
## Тестирование
|
||||
|
||||
| # | Что тестировать | Файл |
|
||||
|---|----------------|------|
|
||||
| 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 | Manual: открыть файл -> отредактировать -> Cmd+S -> dirty indicator сбрасывается | — |
|
||||
|
||||
## Критерии готовности
|
||||
|
||||
- [ ] Файл редактируется в CodeMirror (не read-only)
|
||||
- [ ] Cmd+S сохраняет файл через atomic write
|
||||
- [ ] Dirty indicator на вкладке
|
||||
- [ ] Status bar показывает позицию курсора и язык
|
||||
- [ ] При закрытии overlay с unsaved changes -- confirmation dialog
|
||||
- [ ] Benchmark: 0 re-render FileTreePanel/TabBar при наборе текста
|
||||
|
||||
## Оценка
|
||||
|
||||
- **Надёжность решения: 7/10** -- atomic write и EditorState pooling добавляют сложность.
|
||||
- **Уверенность: 8/10** -- паттерны известны, но dirty tracking через CM6 updateListener требует тестирования.
|
||||
82
docs/iterations/edit-project/iter-3-multi-tab-crud.md
Normal file
82
docs/iterations/edit-project/iter-3-multi-tab-crud.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Итерация 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 перед удалением
|
||||
|
||||
## 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** -- паттерны файловых операций отработаны.
|
||||
101
docs/iterations/edit-project/iter-4-search-shortcuts.md
Normal file
101
docs/iterations/edit-project/iter-4-search-shortcuts.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Итерация 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)
|
||||
|
||||
## 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
|
||||
|
||||
## 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.
|
||||
|
||||
## Изменения в существующих файлах (доп.)
|
||||
|
||||
| # | Файл | Изменение |
|
||||
|---|------|-----------|
|
||||
| 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, но каждый компонент изолирован.
|
||||
96
docs/iterations/edit-project/iter-5-git-watching.md
Normal file
96
docs/iterations/edit-project/iter-5-git-watching.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Итерация 5: Git status, file watching, расширенные возможности
|
||||
|
||||
> Зависит от: [Итерация 4](iter-4-search-shortcuts.md)
|
||||
|
||||
## Цель
|
||||
|
||||
Git status в дереве файлов. Live refresh при изменениях на диске. Conflict detection при сохранении. Line wrap toggle.
|
||||
|
||||
## IPC каналы
|
||||
|
||||
| Канал | Описание |
|
||||
|-------|----------|
|
||||
| `editor:gitStatus` | `git --no-optional-locks status --porcelain -z --untracked-files=normal --ignore-submodules`, кеш 5 сек |
|
||||
| `editor:watchDir` | Запуск file watcher (opt-in, НЕ по умолчанию) |
|
||||
| `editor:change` | Event: файл изменился на диске (main -> renderer) |
|
||||
|
||||
## Новые файлы
|
||||
|
||||
| # | Файл | Описание |
|
||||
|---|------|----------|
|
||||
| 1 | `src/main/services/editor/EditorFileWatcher.ts` | FileWatcher (~250-300 LOC, не ~60!). fs.watch + burst coalescing (200ms debounce + batch) + ENOSPC fallback to polling (Linux). Фильтрация node_modules/.git/dist |
|
||||
| 2 | `src/main/services/editor/GitStatusService.ts` | `git --no-optional-locks status --porcelain -z` парсинг, кеш 5 сек. Переиспользовать `isGitRepo()` из `GitDiffFallback.ts` (~200-250 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)
|
||||
- `fs.watch({ recursive: true })` + фильтрация (node_modules/.git/dist) + burst coalescing 200ms
|
||||
- **macOS**: FSEvents reliable (надёжность 9/10)
|
||||
- **Linux**: inotify (надёжность 6/10). При `ENOSPC` → fallback на polling (5-10 сек). НЕ падать, деградировать
|
||||
- Git status кешировать на 5 секунд. Invalidate по file watcher event
|
||||
- Git command: `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
|
||||
- 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` -- парсинг `git status --porcelain` вывода | `test/main/services/editor/GitStatusService.test.ts` |
|
||||
| 2 | `EditorFileWatcher` -- debounce, event types | `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) в файловом дереве
|
||||
- [ ] Auto-refresh при изменениях на диске (при включённом watcher)
|
||||
- [ ] Conflict detection при сохранении
|
||||
- [ ] Line wrap toggle
|
||||
|
||||
## Оценка
|
||||
|
||||
- **Надёжность решения: 7/10** (было 6/10) -- R4/R5 ресёрч закрыл ключевые пробелы: `--no-optional-locks`, ENOSPC fallback, burst coalescing. Race conditions остаются, но митигированы.
|
||||
- **Уверенность: 8/10** (было 7/10) -- паттерны FileWatcher + GitDiffFallback изучены, переиспользование кода подтверждено.
|
||||
147
docs/iterations/edit-project/research-tasks.md
Normal file
147
docs/iterations/edit-project/research-tasks.md
Normal 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**
|
||||
171
docs/iterations/edit-project/wireframes-draft.md
Normal file
171
docs/iterations/edit-project/wireframes-draft.md
Normal 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 │ ... │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue