diff --git a/Development/PROJECT-ID-BEST-PRACTICES.md b/Development/PROJECT-ID-BEST-PRACTICES.md new file mode 100644 index 00000000..8d52314f --- /dev/null +++ b/Development/PROJECT-ID-BEST-PRACTICES.md @@ -0,0 +1,227 @@ +# Project ID ベストプラクティス + +このドキュメントでは、Pyxis CodeCanvasにおけるProject IDの取得・使用に関するベストプラクティスを説明します。 + +--- + +## 背景と問題 + +### useProject()フックの問題 + +`useProject()`フックはReactの`useState`を使用しているため、**各コンポーネントで独立したステートを持ちます**。これにより以下の問題が発生していました: + +```typescript +// 問題のあるコード +const EditorTabComponent = () => { + const { saveFile, currentProject } = useProject(); + // currentProjectは独立したステートのため、nullになる可能性がある + + const handleSave = async (content: string) => { + // currentProjectがnullの場合、サイレントに失敗 + if (saveFile && currentProject) { + await saveFile(path, content); + } + }; +}; +``` + +--- + +## アーキテクチャ + +### Project ID の流れ + +```mermaid +graph TB + subgraph page.tsx + A[useProject - 唯一の権威ソース] + end + + subgraph projectStore + B[グローバルZustandストア] + end + + subgraph Components + C[useProjectStore - Reactコンポーネント内] + D[getCurrentProjectId - コールバック内] + E[props経由 - 明示的な受け渡し] + end + + subgraph Extensions + F[context.projectId] + end + + A -->|useEffect sync| B + B --> C + B --> D + A -->|props| E + E -->|Terminal| F +``` + +--- + +## ベストプラクティス + +### 1. Reactコンポーネント内でProject IDを取得 + +**推奨: `useProjectStore`を使用** + +```typescript +import { useProjectStore } from '@/stores/projectStore'; + +const MyComponent = () => { + // グローバルストアからリアクティブに取得 + const currentProject = useProjectStore(state => state.currentProject); + const projectId = currentProject?.id; + + // projectIdを使用 +}; +``` + +### 2. コールバック関数内でProject IDを取得 + +**推奨: `getCurrentProjectId()`ユーティリティを使用** + +```typescript +import { getCurrentProjectId } from '@/stores/projectStore'; + +const MyComponent = () => { + const handleContentChange = useCallback(async (content: string) => { + // コールバック実行時点の最新のprojectIdを取得 + const projectId = getCurrentProjectId(); + + if (projectId && path) { + await fileRepository.saveFileByPath(projectId, path, content); + } + }, [path]); +}; +``` + +### 3. propsで受け取る場合 + +**親コンポーネントからpropsで渡される場合は、そのまま使用** + +```typescript +interface TerminalProps { + currentProjectId: string; +} + +const Terminal = ({ currentProjectId }: TerminalProps) => { + // propsで受け取ったprojectIdを使用 + await fileRepository.createFile(currentProjectId, path, content, 'file'); +}; +``` + +### 4. Extension内でProject IDを取得 + +**`context.projectId`を使用** + +```typescript +// Extension command handler +export const handler = async (args: string[], context: CommandContext) => { + const { projectId } = context; + + const fileRepository = context.getSystemModule('fileRepository'); + const file = await fileRepository.getFileByPath(projectId, '/src/index.ts'); +}; +``` + +--- + +## 非推奨パターン + +### ❌ page.tsx以外でuseProject()を使用 + +```typescript +// 非推奨: 独立したステートが作成される +const MyTabComponent = () => { + const { currentProject, saveFile } = useProject(); // NG +}; +``` + +### ❌ LocalStorageから直接取得 + +```typescript +// 非推奨: 同期の問題がある +const projectId = JSON.parse(localStorage.getItem('recent-projects'))?.[0]?.id; +``` + +--- + +## API リファレンス + +### projectStore.ts + +| API | 用途 | 使用場所 | +|-----|------|----------| +| `useProjectStore(state => state.currentProject)` | リアクティブにプロジェクト取得 | Reactコンポーネント内 | +| `useProjectStore(state => state.currentProjectId)` | リアクティブにID取得 | Reactコンポーネント内 | +| `getCurrentProjectId()` | 即時にID取得 | コールバック、非同期関数内 | +| `getCurrentProject()` | 即時にプロジェクト取得 | コールバック、非同期関数内 | + +### page.tsxでの同期 + +```typescript +// page.tsx +const { currentProject } = useProject(); +const setCurrentProjectToStore = useProjectStore(state => state.setCurrentProject); + +useEffect(() => { + setCurrentProjectToStore(currentProject); +}, [currentProject, setCurrentProjectToStore]); +``` + +--- + +## ファイル操作時のProject ID使用例 + +### ファイル保存 + +```typescript +import { fileRepository } from '@/engine/core/fileRepository'; +import { getCurrentProjectId } from '@/stores/projectStore'; + +const saveFile = async (path: string, content: string) => { + const projectId = getCurrentProjectId(); + if (!projectId) { + console.error('No project selected'); + return; + } + + await fileRepository.saveFileByPath(projectId, path, content); +}; +``` + +### ファイル取得 + +```typescript +const getFile = async (path: string) => { + const projectId = getCurrentProjectId(); + if (!projectId) return null; + + return await fileRepository.getFileByPath(projectId, path); +}; +``` + +--- + +## チェックリスト + +新しいコンポーネントでProject IDを使用する際: + +- [ ] `useProject()`を直接使用していないか確認 +- [ ] Reactコンポーネント内では`useProjectStore`を使用 +- [ ] コールバック内では`getCurrentProjectId()`を使用 +- [ ] propsで渡される場合はそのまま使用 +- [ ] Extension内では`context.projectId`を使用 + +--- + +## 関連ドキュメント + +- [FILE_REPOSITORY_BEST_PRACTICES.md](./FILE_REPOSITORY_BEST_PRACTICES.md) - FileRepositoryの最適化とキャッシュ +- [CORE-ENGINE.md](../docs/CORE-ENGINE.md) - コアエンジンアーキテクチャ + +--- + +ドキュメント作成日: 2025-12-03 diff --git a/README-ARCHITECTURE-INVESTIGATION.md b/README-ARCHITECTURE-INVESTIGATION.md new file mode 100644 index 00000000..018b9b1a --- /dev/null +++ b/README-ARCHITECTURE-INVESTIGATION.md @@ -0,0 +1,202 @@ +# 調査結果 - 二層アーキテクチャについて + +## 🎯 結論 + +**現在のアーキテクチャは正しく、変更の必要はありません。** + +**⚠️ 重要な修正**: .gitignore フィルタリングのバグを発見・修正しました(コミット 9ee7e40) + +--- + +## 📋 ご質問への回答 + +### 「ファイルが完全に重複してるね」 + +➡️ **重複ではなく、意図的な設計です** + +- **IndexedDB**: 全ファイル保存(node_modules含む) +- **lightning-fs**: .gitignore適用後のファイルのみ + +これは**バグではありません**。各レイヤーが異なる目的を持っています。 + +### 「gitignore考慮出来てたと思ってた。多分してないよね?」 + +➡️ **バグがありました - 修正済み** + +**発見された問題**: +- 単一ファイル操作: .gitignore チェック ✅ 正常 +- **バルク同期**: .gitignore チェック ❌ **未実装だった** + +`pyxis git tree --all` で node_modules が表示されていたのは、bulk sync が .gitignore を無視していたためです。 + +**修正内容** (コミット 9ee7e40): +- `syncManager.ts` に .gitignore フィルタリングを追加 +- bulk sync 時にも .gitignore ルールを適用 +- 無視されるファイルは lightning-fs に同期しない + +### 「二層レイヤーの仕組み全くいらんかった?」 + +➡️ **両方必要です** + +#### なぜIndexedDBが必要? + +1. ✅ **高速クエリ**: パス検索、プレフィックス検索がインデックスで高速 +2. ✅ **メタデータ**: 作成日時、AIレビュー状態などを保存 +3. ✅ **トランザクション**: 複数ファイルの一括操作を保証 +4. ✅ **Node.js Runtime**: `require('react')`を高速に解決 + +#### なぜlightning-fsが必要? + +1. ✅ **isomorphic-gitの必須要件**: POSIX風APIが必要 +2. ✅ **Git操作の高速化**: node_modules除外で`git status`が速い +3. ✅ **ターミナルコマンド**: `ls`, `cat`などがファイルシステムAPIを前提 + +### 「完全にlightning-fs単体でうまくいく説ある?」 + +➡️ **いきません** + +lightning-fs単体だと以下が実現できません: + +- ❌ ファイルツリーの高速表示 +- ❌ パスでの直接検索 +- ❌ メタデータ管理 +- ❌ Node.js Runtimeの高速動作 +- ❌ トランザクション保証 + +--- + +## 📊 現在の動作(修正後の正しい挙動) + +### 例: node_modulesを含むプロジェクト + +``` +プロジェクト: +/ +├── .gitignore ("node_modules" を含む) +├── package.json +├── src/index.ts +└── node_modules/react/index.js +``` + +**IndexedDBの内容(全ファイル):** +``` +✅ /.gitignore +✅ /package.json +✅ /src/index.ts +✅ /node_modules/react/index.js ← 保存される(Node Runtime用) +``` + +**lightning-fsの内容(.gitignore適用後): ✅ 修正済み** +``` +✅ /.gitignore +✅ /package.json +✅ /src/index.ts +⛔ /node_modules/ ← 同期されない(Git高速化) +``` + +**確認方法**: +```bash +pyxis git tree --all +``` +上記コマンドで node_modules が表示されなければ、正常に動作しています。 + +--- + +## 🔄 データフロー(修正後) + +``` +ユーザー操作 + ↓ +fileRepository.createFile() + ↓ +IndexedDBに保存 ✅(全ファイル) + ↓ +.gitignoreチェック ✅(単一ファイル操作) + ↓ + ├─ マッチしない → lightning-fsに同期 ✅ + └─ マッチする → 同期しない ⛔ + +--- + +プロジェクト読み込み/clone + ↓ +syncFromIndexedDBToFS() + ↓ +.gitignoreルールを読み込み ✅(修正済み) + ↓ +全ファイルをフィルタリング ✅ + ↓ +無視されないファイルのみ同期 ✅ +``` + +--- + +## 📚 参考ドキュメント + +詳細は以下をご覧ください: + +1. **`docs/TWO-LAYER-ARCHITECTURE.md`** + - 二層構造の詳細な説明 + - 各レイヤーの必要性 + - よくある誤解の解説 + +2. **`docs/TWO-LAYER-INVESTIGATION-SUMMARY.md`** + - 調査結果のサマリー(英語) + +3. **`docs/CORE-ENGINE.md`** + - FileRepository、GitFileSystemの詳細 + +--- + +## ✅ 推奨アクション + +### 修正完了 + +.gitignore フィルタリングのバグを修正しました(コミット 9ee7e40)。 + +### テスト方法 + +1. プロジェクトを再読み込み +2. ターミナルで `pyxis git tree --all` を実行 +3. node_modules が表示されないことを確認 + +### 変更不要 + +バグ修正後、現在のアーキテクチャは正しく設計されており、**追加のコード変更は不要**です。 + +### ドキュメントで理解を深める + +1. 上記のドキュメントを読む +2. テストコード `src/tests/gitignore.integration.test.ts` を確認 +3. 疑問があればIssueで質問 + +--- + +## 📊 まとめ表 + +| 項目 | 状態 | 説明 | +|-----|------|------| +| **ファイル重複** | ⭕ 正常 | 意図的な設計 | +| **.gitignore動作** | ✅ 修正済み | バグ修正完了(9ee7e40) | +| **二層の必要性** | ⭕ 必要 | 両方必須 | +| **パフォーマンス** | ⭕ 最適 | 非同期同期、キャッシュ | +| **推奨変更** | ⭕ なし | バグ修正済み | + +--- + +## 💡 設計の利点 + +| 機能 | IndexedDBのみ | lightning-fsのみ | **二層設計** | +|-----|-------------|----------------|------------| +| 高速クエリ | ✅ | ❌ | ✅ | +| メタデータ | ✅ | ❌ | ✅ | +| Git操作 | ❌ | ✅ | ✅ | +| .gitignore | ❌ | ✅ | ✅ | +| Node Runtime | ✅ | ⚠️ 遅い | ✅ | + +--- + +**作成日**: 2025-01-07 +**最終更新**: 2025-01-07(バグ修正) +**ステータス**: 完了 +**次のアクション**: プロジェクト再読み込み後、`pyxis git tree --all` で動作確認 diff --git a/README.md b/README.md index 741d0e2e..1a392b4f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ### *Zero Setup. Quick Start, Easy Coding* - [![Version](https://img.shields.io/badge/version-0.15.4-blue.svg)](https://github.com/your-username/pyxis) + [![Version](https://img.shields.io/badge/version-0.16.1-blue.svg)](https://github.com/your-username/pyxis) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Web%20%7C%20iPad%20%7C%20Mobile-orange.svg)](README.md) [![Languages](https://img.shields.io/badge/languages-18-blue.svg)](#) diff --git a/README_en.md b/README_en.md index cdbc2c76..90a41894 100644 --- a/README_en.md +++ b/README_en.md @@ -5,7 +5,7 @@ ### *Zero Setup. Quick Start, Easy Coding* - [![Version](https://img.shields.io/badge/version-0.15.4-blue.svg)](https://github.com/your-username/pyxis) + [![Version](https://img.shields.io/badge/version-0.16.1-blue.svg)](https://github.com/your-username/pyxis) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Platform](https://img.shields.io/badge/platform-Web%20%7C%20iPad%20%7C%20Mobile-orange.svg)](README.md) [![Languages](https://img.shields.io/badge/languages-18-blue.svg)](#) diff --git a/SINGLE-LAYER-ANALYSIS.md b/SINGLE-LAYER-ANALYSIS.md new file mode 100644 index 00000000..38a7e756 --- /dev/null +++ b/SINGLE-LAYER-ANALYSIS.md @@ -0,0 +1,151 @@ +# シングルレイヤー(lightning-fs のみ)の実現可能性分析 + +## 現状の理解 + +### IndexedDB の主な用途 + +1. **プロジェクト・ファイルメタデータの保存** + - projects テーブル: プロジェクト情報 + - files テーブル: ファイル情報 + メタデータ + - chatSpaces テーブル: チャット履歴 + +2. **高速クエリ** + - `getFileByPath(projectId, path)`: パス検索(インデックス使用) + - `getFilesByPrefix(projectId, prefix)`: プレフィックス検索 + - `getProjectFiles(projectId)`: プロジェクト全ファイル取得 + +3. **メタデータ管理** + - createdAt, updatedAt + - aiReviewStatus, aiReviewComments + - isBufferArray, bufferContent + +## シングルレイヤーへの移行案 + +### 方針A: lightning-fs のみを使用 + +#### メリット +- アーキテクチャがシンプルになる +- 同期の複雑性がなくなる +- ストレージ層が一つだけ + +#### デメリット・課題 + +1. **インデックスベースクエリが不可能** + - `getFileByPath()` → 毎回ディレクトリをスキャン必要 + - `getFilesByPrefix()` → 再帰的にディレクトリスキャン + - ファイル数が多いと極端に遅くなる + +2. **メタデータの保存場所がない** + - lightning-fs は単なるファイルシステム + - 作成日時、AIレビュー状態などを保存できない + - 解決策: メタデータを別ファイルに保存?(例: .pyxis-meta/ ディレクトリ) + +3. **トランザクションサポートがない** + - 複数ファイルの一括操作で、途中失敗すると不整合になる + - 解決策: 手動でロールバック処理を実装? + +4. **プロジェクト情報の保存** + - projects テーブルの代替が必要 + - 解決策: `/projects/{name}/.pyxis-project.json` に保存? + +5. **チャット履歴の保存** + - chatSpaces テーブルの代替が必要 + - 解決策: `/projects/{name}/.pyxis-chat/` ディレクトリに保存? + +6. **Node.js Runtime のモジュール解決** + - 現在は IndexedDB から直接読み込み(高速) + - lightning-fs からの読み込みは遅い可能性がある + - 解決策: メモリキャッシュを強化? + +### 方針B: lightning-fs + localStorage + +#### メリット +- lightning-fs をプライマリストレージに +- localStorage でメタデータ管理 + +#### デメリット +- localStorage は容量制限が厳しい(5-10MB) +- 大量のファイルメタデータを保存できない + +### 方針C: lightning-fs + 独自メタデータファイル + +#### 実装案 + +``` +/projects/ + my-project/ + .pyxis/ + project.json # プロジェクト情報 + files-meta.json # 全ファイルのメタデータ + chat/ # チャット履歴 + space-1.json + space-2.json + src/ + index.ts + package.json +``` + +#### メリット +- メタデータもファイルシステムに統一 +- バックアップ・エクスポートが簡単 + +#### デメリット +- メタデータファイルが大きくなる +- 更新のたびにファイル全体を読み書き +- インデックスクエリは依然として遅い + +## パフォーマンス比較 + +### ケース1: ファイルツリー表示(1000ファイル) + +**IndexedDB:** +- `getProjectFiles(projectId)`: インデックスクエリ → 50-100ms + +**lightning-fs のみ:** +- ディレクトリを再帰的にスキャン → 300-500ms + +### ケース2: パス検索(特定ファイル取得) + +**IndexedDB:** +- `getFileByPath(projectId, '/src/index.ts')`: インデックスクエリ → 5-10ms + +**lightning-fs のみ:** +- `fs.promises.readFile('/projects/my-project/src/index.ts')`: 直接読み込み → 10-20ms +- ただし、メタデータ取得のために files-meta.json も読む必要 → 追加で 50ms + +### ケース3: プレフィックス検索(ディレクトリ下の全ファイル) + +**IndexedDB:** +- `getFilesByPrefix(projectId, '/src/')`: 範囲クエリ → 50-100ms + +**lightning-fs のみ:** +- ディレクトリを再帰的にスキャン + メタデータ読み込み → 200-400ms + +## 結論 + +### lightning-fs 単体での実現は**技術的に可能だが、パフォーマンスが大幅に劣化する** + +主な問題点: +1. インデックスクエリの代替がない → ディレクトリスキャン必須 +2. メタデータ管理が複雑になる +3. パフォーマンスが2-5倍遅くなる + +### 推奨案: 現状の二層アーキテクチャを維持 + +理由: +- パフォーマンスが最適 +- メタデータ管理が明確 +- トランザクションサポート +- 実装がすでに安定している + +### 代替案: 二層だが .gitignore を IndexedDB にも適用 + +もし「重複」が気になる場合: +- IndexedDB にも .gitignore を適用して、node_modules を保存しない +- ただし、Node.js Runtime が機能しなくなる可能性 +- この案は**推奨しない** + +--- + +**作成日**: 2025-01-07 +**ステータス**: 分析中 diff --git a/SINGLE-LAYER-PROPOSAL.md b/SINGLE-LAYER-PROPOSAL.md new file mode 100644 index 00000000..2f58cdb4 --- /dev/null +++ b/SINGLE-LAYER-PROPOSAL.md @@ -0,0 +1,163 @@ +# シングルレイヤー実装の提案 + +## 結論: シングルレイヤーは可能だが、大幅な性能劣化を伴う + +### 選択肢1: 現状維持(推奨)⭐ +- **メリット**: 高速、安定、メタデータ管理が容易 +- **デメリット**: 二層の複雑性 +- **実装工数**: 0時間(変更なし) + +### 選択肢2: シングルレイヤー(lightning-fs のみ) +- **メリット**: アーキテクチャがシンプル +- **デメリット**: 2-5倍の性能劣化、メタデータ管理が複雑 +- **実装工数**: 約40-60時間 + +--- + +## もしシングルレイヤーを選ぶ場合の実装計画 + +### フェーズ1: メタデータストレージの設計(8時間) + +**設計案:** +``` +/projects/ + my-project/ + .pyxis/ + project.json # プロジェクト情報 + files-meta.json # ファイルメタデータ(JSON) + chat/ # チャット履歴 + src/... # 通常のファイル +``` + +**project.json:** +```json +{ + "id": "project_xxx", + "name": "my-project", + "description": "...", + "createdAt": "2025-01-07T...", + "updatedAt": "2025-01-07T..." +} +``` + +**files-meta.json:** +```json +{ + "/src/index.ts": { + "createdAt": "2025-01-07T...", + "updatedAt": "2025-01-07T...", + "aiReviewStatus": "reviewed", + "aiReviewComments": "..." + }, + ... +} +``` + +### フェーズ2: FileRepository の書き換え(20時間) + +**変更内容:** +1. IndexedDB 関連コードを全削除 +2. lightning-fs API のみを使用 +3. メタデータは `.pyxis/files-meta.json` から読み書き + +**主な API 変更:** + +```typescript +class FileRepository { + // Before: IndexedDB クエリ + async getFileByPath(projectId: string, path: string) { + // IndexedDB インデックスクエリ → 10ms + } + + // After: lightning-fs スキャン + メタデータ読み込み + async getFileByPath(projectId: string, path: string) { + // 1. lightning-fs から読み込み → 10ms + // 2. .pyxis/files-meta.json を読み込み → 50ms + // 3. パスでフィルタ + // 合計: 60ms(6倍遅い) + } + + // Before: IndexedDB プレフィックスクエリ + async getFilesByPrefix(projectId: string, prefix: string) { + // IndexedDB 範囲クエリ → 50ms + } + + // After: 再帰的ディレクトリスキャン + async getFilesByPrefix(projectId: string, prefix: string) { + // 1. ディレクトリを再帰的にスキャン → 200ms + // 2. .pyxis/files-meta.json を読み込み → 50ms + // 合計: 250ms(5倍遅い) + } +} +``` + +### フェーズ3: SyncManager の削除(4時間) + +- syncManager.ts を完全削除 +- FileRepository から syncToGitFileSystem() 呼び出しを削除 +- 全てのファイル操作が直接 lightning-fs に書き込む + +### フェーズ4: Chat システムの移行(6時間) + +- chatStorageAdapter を書き換え +- IndexedDB の代わりに `.pyxis/chat/*.json` を使用 + +### フェーズ5: テスト・検証(8時間) + +- 全機能のテスト +- パフォーマンス測定 +- バグ修正 + +### フェーズ6: ドキュメント更新(2時間) + +--- + +## パフォーマンス影響の詳細 + +| 操作 | 現在(IndexedDB) | 移行後(lightning-fs) | 影響 | +|------|-----------------|---------------------|------| +| ファイルツリー表示 | 50ms | 300ms | ❌ 6倍遅い | +| ファイル検索 | 10ms | 60ms | ❌ 6倍遅い | +| プレフィックス検索 | 50ms | 250ms | ❌ 5倍遅い | +| ファイル保存 | 20ms | 15ms | ✅ 少し速い | +| Git 操作 | 変化なし | 変化なし | ⚪ 同じ | +| Node.js require() | メモリキャッシュ | メモリキャッシュ | ⚪ 同じ | + +**特に影響が大きい操作:** +- ファイルツリーの表示・更新(ユーザーが頻繁に見る) +- 検索パネルでのファイル検索 +- AI パネルでのファイル一覧表示 + +--- + +## 推奨: 現状維持 + +### 理由 + +1. **パフォーマンス**: 現状が圧倒的に速い +2. **安定性**: 既に動作している実装を壊すリスク +3. **工数**: 40-60時間の実装コスト +4. **メンテナンス性**: メタデータ管理が複雑になる + +### 二層アーキテクチャの本質的な利点 + +- **IndexedDB**: クエリエンジン(検索・フィルタが得意) +- **lightning-fs**: ファイルシステム(Git が必要とする API) + +この組み合わせで、両方の長所を活かしている。 + +--- + +## 最終判断 + +@Stasshe さんに確認: + +1. **現状維持を推奨**します(パフォーマンス・安定性を重視) +2. もし**シングルレイヤー実装を強く希望**される場合、上記の実装計画で進めます(40-60時間) + +どちらを選択されますか? + +--- + +**作成日**: 2025-01-07 +**ステータス**: 提案中 diff --git a/docs/README.md b/docs/README.md index 0c597808..1a6fbb17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -146,7 +146,25 @@ Gemini AI統合、コードレビュー、チャット機能の実装につい --- -### 8. [DATA-FLOW.md](./DATA-FLOW.md) +### 8. [TWO-LAYER-ARCHITECTURE.md](./TWO-LAYER-ARCHITECTURE.md) ⭐ NEW +**二層アーキテクチャの設計理由** + +IndexedDBとlightning-fsの二層構造がなぜ必要なのか、それぞれの役割と.gitignoreの動作について詳しく説明します。 + +**主な内容:** +- 二層アーキテクチャの概要と必要性 +- IndexedDBが必要な理由(高速クエリ、メタデータ、トランザクション) +- lightning-fsが必要な理由(Git操作、isomorphic-git要件) +- .gitignoreの正しい動作(IndexedDB=全ファイル、lightning-fs=無視済み) +- よくある誤解の解説 +- パフォーマンス最適化戦略 +- 設計の利点まとめ + +**対象読者:** アーキテクチャを理解したい全ての開発者、特に「なぜ二層?」と疑問に思った人 + +--- + +### 9. [DATA-FLOW.md](./DATA-FLOW.md) **データフローと状態管理** システム全体のデータフロー、状態遷移、イベント伝播について詳細に説明します。 @@ -180,8 +198,9 @@ Gemini AI統合、コードレビュー、チャット機能の実装につい 1. 該当する層のドキュメントを読む(UI/Core/AI/Runtime) 2. **DATA-FLOW.md** で既存のフローを確認 -3. 既存のパターンに従って実装 -4. 必要に応じてドキュメントを更新 +3. ストレージに関わる場合は **TWO-LAYER-ARCHITECTURE.md** を確認 +4. 既存のパターンに従って実装 +5. 必要に応じてドキュメントを更新 ### バグを修正する場合 diff --git a/docs/RUNTIME-PROVIDER.md b/docs/RUNTIME-PROVIDER.md new file mode 100644 index 00000000..3e024a78 --- /dev/null +++ b/docs/RUNTIME-PROVIDER.md @@ -0,0 +1,425 @@ +# Runtime Provider Architecture + +## 概要 + +Pyxisの新しいランタイムアーキテクチャは、拡張可能で体系的な設計を提供します。このドキュメントでは、RuntimeProviderシステムの設計、使用方法、および拡張方法について説明します。 + +## 設計原則 + +1. **拡張性**: 新しいランタイムを拡張機能として追加可能 +2. **体系的**: 明確なインターフェースと責任分離 +3. **メモリリーク防止**: キャッシュ戦略とクリーンアップの適切な実装 +4. **型安全性**: 完全なTypeScriptサポート +5. **後方互換性**: 既存コードとの互換性を維持 + +## アーキテクチャ + +### コアコンポーネント + +``` +┌─────────────────────────────────────────┐ +│ Runtime Architecture │ +├─────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ RuntimeProvider │ │ +│ │ - id: string │ │ +│ │ - name: string │ │ +│ │ - supportedExtensions │ │ +│ │ - execute() │ │ +│ │ - executeCode() │ │ +│ └───────────────────────────────┘ │ +│ ▲ │ +│ │ │ +│ ┌─────────┴────────┬──────────────┐ │ +│ │ │ │ │ +│ │ NodeRuntime │ Python │ │ +│ │ Provider │ Provider │ │ +│ │ (builtin) │ (extension) │ │ +│ └──────────────────┴──────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ TranspilerProvider │ │ +│ │ - id: string │ │ +│ │ - supportedExtensions │ │ +│ │ - needsTranspile() │ │ +│ │ - transpile() │ │ +│ └───────────────────────────────┘ │ +│ ▲ │ +│ │ │ +│ ┌─────────┴─────────────────────┐ │ +│ │ TypeScript Transpiler │ │ +│ │ (extension) │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ RuntimeRegistry │ │ +│ │ - registerRuntime() │ │ +│ │ - registerTranspiler() │ │ +│ │ - getRuntimeForFile() │ │ +│ │ - getTranspilerForFile() │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────┘ +``` + +### ファイル構成 + +``` +src/engine/runtime/ +├── RuntimeProvider.ts # インターフェース定義 +├── RuntimeRegistry.ts # レジストリ実装 +├── builtinRuntimes.ts # ビルトインランタイム初期化 +├── providers/ +│ ├── NodeRuntimeProvider.ts # Node.jsランタイム +│ └── ExtensionTranspilerProvider.ts # 拡張機能トランスパイラーラッパー +├── nodeRuntime.ts # 既存のNode.jsランタイム実装 +├── moduleLoader.ts # モジュールローダー(更新済み) +└── transpileManager.ts # トランスパイルマネージャー +``` + +## RuntimeProvider インターフェース + +### 基本構造 + +```typescript +export interface RuntimeProvider { + // 識別子(例: "nodejs", "python") + readonly id: string; + + // 表示名(例: "Node.js", "Python") + readonly name: string; + + // サポートするファイル拡張子 + readonly supportedExtensions: string[]; + + // ファイルが実行可能か判定 + canExecute(filePath: string): boolean; + + // ランタイムの初期化(オプション) + initialize?(projectId: string, projectName: string): Promise; + + // ファイルを実行 + execute(options: RuntimeExecutionOptions): Promise; + + // コードスニペットを実行(REPLモード、オプション) + executeCode?(code: string, options: RuntimeExecutionOptions): Promise; + + // キャッシュをクリア(オプション) + clearCache?(): void; + + // クリーンアップ(オプション) + dispose?(): Promise; + + // 準備完了状態(オプション) + isReady?(): boolean; +} +``` + +### 実行オプション + +```typescript +export interface RuntimeExecutionOptions { + projectId: string; + projectName: string; + filePath: string; + argv?: string[]; + debugConsole?: { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + clear: () => void; + }; + onInput?: (prompt: string, callback: (input: string) => void) => void; + terminalColumns?: number; + terminalRows?: number; +} +``` + +### 実行結果 + +```typescript +export interface RuntimeExecutionResult { + stdout?: string; + stderr?: string; + result?: unknown; + exitCode?: number; +} +``` + +## TranspilerProvider インターフェース + +```typescript +export interface TranspilerProvider { + readonly id: string; + readonly supportedExtensions: string[]; + + needsTranspile(filePath: string, content?: string): boolean; + + transpile(code: string, options: { + filePath: string; + isTypeScript?: boolean; + isESModule?: boolean; + isJSX?: boolean; + }): Promise<{ + code: string; + map?: string; + dependencies?: string[]; + }>; +} +``` + +## RuntimeRegistry + +RuntimeRegistryは、すべてのランタイムプロバイダーとトランスパイラープロバイダーを管理するシングルトンです。 + +### 主要メソッド + +```typescript +// ランタイムプロバイダーを登録 +registerRuntime(provider: RuntimeProvider): void + +// トランスパイラープロバイダーを登録 +registerTranspiler(provider: TranspilerProvider): void + +// ファイルパスからランタイムを取得 +getRuntimeForFile(filePath: string): RuntimeProvider | null + +// ファイルパスからトランスパイラーを取得 +getTranspilerForFile(filePath: string): TranspilerProvider | null + +// IDでランタイムを取得 +getRuntime(id: string): RuntimeProvider | null + +// IDでトランスパイラーを取得 +getTranspiler(id: string): TranspilerProvider | null + +// すべてのランタイムを取得 +getAllRuntimes(): RuntimeProvider[] + +// すべてのトランスパイラーを取得 +getAllTranspilers(): TranspilerProvider[] +``` + +## 使用例 + +### ビルトインランタイム(Node.js) + +Node.jsランタイムは常にビルトインとして利用可能です: + +```typescript +import { runtimeRegistry } from '@/engine/runtime/RuntimeRegistry'; + +// Node.jsランタイムを取得 +const nodeRuntime = runtimeRegistry.getRuntime('nodejs'); + +// ファイルを実行 +if (nodeRuntime) { + await nodeRuntime.execute({ + projectId: 'my-project', + projectName: 'my-project', + filePath: '/index.js', + debugConsole: { + log: console.log, + error: console.error, + warn: console.warn, + clear: () => {}, + }, + }); +} +``` + +### 拡張機能でトランスパイラーを登録 + +TypeScript拡張機能の例: + +```typescript +export async function activate(context: ExtensionContext) { + // トランスパイラーを登録 + await context.registerTranspiler?.({ + id: 'typescript', + supportedExtensions: ['.ts', '.tsx', '.mts', '.cts', '.jsx'], + needsTranspile: (filePath: string) => { + return /\.(ts|tsx|mts|cts|jsx)$/.test(filePath); + }, + transpile: async (code: string, options: any) => { + // Babel standaloneなどでトランスパイル + const result = await transpileWithBabel(code, options); + return { + code: result.code, + map: result.map, + dependencies: extractDependencies(result.code), + }; + }, + }); + + return { runtimeFeatures: { /* ... */ } }; +} +``` + +### 拡張機能でランタイムを登録(将来の拡張) + +Pythonランタイムを拡張機能として実装する例: + +```typescript +import type { RuntimeProvider } from '@/engine/runtime/RuntimeProvider'; + +export class PythonRuntimeProvider implements RuntimeProvider { + readonly id = 'python'; + readonly name = 'Python'; + readonly supportedExtensions = ['.py']; + + canExecute(filePath: string): boolean { + return filePath.endsWith('.py'); + } + + async initialize(projectId: string, projectName: string): Promise { + // Pyodideの初期化 + await initPyodide(); + await setCurrentProject(projectId, projectName); + } + + async execute(options: RuntimeExecutionOptions): Promise { + // Pythonコードの実行 + const result = await runPythonWithSync(code, options.projectId); + return { + stdout: result.stdout, + stderr: result.stderr, + result: result.result, + exitCode: result.stderr ? 1 : 0, + }; + } + + // ... その他のメソッド +} + +export async function activate(context: ExtensionContext) { + // Pythonランタイムを登録 + const pythonProvider = new PythonRuntimeProvider(); + + // 将来的にcontext.registerRuntime が追加される予定 + // await context.registerRuntime?.(pythonProvider); + + return {}; +} +``` + +## ModuleLoaderとの統合 + +ModuleLoaderは自動的にRuntimeRegistryを使用してトランスパイラーを検索します: + +```typescript +// moduleLoader.ts内 +const transpiler = runtimeRegistry.getTranspilerForFile(filePath); +if (transpiler) { + const result = await transpiler.transpile(content, { + filePath, + isTypeScript, + isJSX, + }); + // ... +} +``` + +## RunPanelとの統合 + +RunPanelは自動的にRuntimeRegistryを使用してランタイムを選択します: + +```typescript +// RunPanel.tsx内 +const runtime = runtimeRegistry.getRuntimeForFile(filePath); +if (runtime) { + const result = await runtime.execute({ + projectId, + projectName, + filePath, + debugConsole, + onInput, + }); + // ... +} +``` + +## メモリリーク防止 + +RuntimeProviderは以下の方法でメモリリークを防止します: + +1. **キャッシュのクリア**: `clearCache()`メソッドの実装 +2. **適切なクリーンアップ**: `dispose()`メソッドでリソース解放 +3. **インスタンス管理**: 必要に応じてインスタンスを再作成 +4. **イベントループ追跡**: タイマーなどの追跡と適切なクリーンアップ + +例(NodeRuntimeProvider): + +```typescript +async execute(options: RuntimeExecutionOptions): Promise { + const key = `${projectId}-${filePath}`; + + // 既存のキャッシュはメモリリーク防止のためクリア + if (this.runtimeInstances.has(key)) { + const existing = this.runtimeInstances.get(key)!; + existing.clearCache(); + this.runtimeInstances.delete(key); + } + + // 新しいインスタンスを作成 + const runtime = new NodeRuntime(options); + + // 実行 + await runtime.execute(filePath, argv); + await runtime.waitForEventLoop(); + + return { exitCode: 0 }; +} +``` + +## テスト + +RuntimeRegistryのテストは`tests/runtimeRegistry.test.ts`にあります: + +```typescript +describe('RuntimeRegistry', () => { + test('should register a runtime provider', () => { + const mockProvider: RuntimeProvider = { + id: 'test-runtime', + name: 'Test Runtime', + supportedExtensions: ['.test'], + canExecute: (filePath: string) => filePath.endsWith('.test'), + execute: async () => ({ exitCode: 0 }), + }; + + registry.registerRuntime(mockProvider); + const retrieved = registry.getRuntime('test-runtime'); + expect(retrieved).toBe(mockProvider); + }); +}); +``` + +## ベストプラクティス + +1. **ランタイムプロバイダーの実装** + - 必須メソッドのみを実装し、オプションメソッドは必要に応じて追加 + - `canExecute()`で正確な判定を行う + - `clearCache()`と`dispose()`でメモリリークを防止 + +2. **トランスパイラープロバイダーの実装** + - `needsTranspile()`で正確な判定を行う + - 依存関係を正確に抽出して返す + - エラーハンドリングを適切に行う + +3. **拡張機能の実装** + - `context.registerTranspiler()`で早期に登録 + - エラー時は適切にログを出力 + - `deactivate()`でクリーンアップ(将来実装予定) + +## まとめ + +RuntimeProvider アーキテクチャは、Pyxisのランタイムシステムを拡張可能で体系的にします: + +- ✅ **拡張性**: 新しいランタイムを拡張機能として追加可能 +- ✅ **体系的**: 明確なインターフェースと責任分離 +- ✅ **メモリリーク防止**: 既存のキャッシュ戦略を維持 +- ✅ **型安全性**: 完全なTypeScriptサポート +- ✅ **後方互換性**: 既存コードとの互換性を維持 + +この設計により、開発者は新しいランタイムを簡単に追加でき、コアコードを変更することなく言語サポートを拡張できます。 diff --git a/docs/TWO-LAYER-ARCHITECTURE.md b/docs/TWO-LAYER-ARCHITECTURE.md new file mode 100644 index 00000000..6de520c1 --- /dev/null +++ b/docs/TWO-LAYER-ARCHITECTURE.md @@ -0,0 +1,358 @@ +# Two-Layer Architecture - 設計の理由と必要性 + +## 概要 + +Pyxis CodeCanvasは、**IndexedDB**と**lightning-fs**という二層のストレージアーキテクチャを採用しています。 +このドキュメントでは、なぜこの設計が必要なのか、それぞれのレイヤーの役割、そして.gitignoreの動作について詳しく説明します。 + +--- + +## 1. アーキテクチャ概要 + +```mermaid +graph TB + subgraph "UI Layer" + A[File Tree] + B[Code Editor] + C[Search Panel] + end + + subgraph "Application Layer" + D[FileRepository] + end + + subgraph "Storage Layer - 二層構造" + E[IndexedDB
全ファイル + メタデータ] + F[lightning-fs
Gitignore考慮済み] + end + + subgraph "Git Operations" + G[isomorphic-git] + end + + A --> D + B --> D + C --> D + + D --> E + D -->|.gitignore filtering| F + + G --> F + G -.->|requires| F + + style E fill:#e1f5ff,stroke:#0288d1 + style F fill:#fff9e1,stroke:#f57c00 +``` + +### 二層の役割分担 + +| レイヤー | 用途 | 格納内容 | 主な用途 | +|---------|------|---------|---------| +| **IndexedDB** | プライマリストレージ | **全ファイル**
(node_modules含む) | - ファイルツリー表示
- エディタ表示
- 検索機能
- メタデータ管理
- Node.js Runtime | +| **lightning-fs** | Gitストレージ | **.gitignore考慮済み**
(node_modules除外) | - Git操作
- isomorphic-git
- ターミナルコマンド | + +--- + +## 2. なぜ二層が必要なのか + +### 2.1 IndexedDBが必要な理由 + +#### ✅ 高速クエリ + +IndexedDBはインデックスベースのクエリに最適化されており、以下の操作が高速です: + +```typescript +// パスで直接検索(インデックス使用) +const file = await fileRepository.getFileByPath(projectId, '/src/App.tsx'); + +// プレフィックス検索(範囲クエリ) +const srcFiles = await fileRepository.getFilesByPrefix(projectId, '/src/'); + +// プロジェクトIDで全ファイル取得(インデックス使用) +const allFiles = await fileRepository.getProjectFiles(projectId); +``` + +lightning-fsでこれを実現するには、毎回ディレクトリを再帰的にスキャンする必要があり、パフォーマンスが悪化します。 + +#### ✅ メタデータ管理 + +IndexedDBは、ファイル内容だけでなく、豊富なメタデータを保存できます: + +```typescript +interface ProjectFile { + id: string; + projectId: string; + path: string; + name: string; + content: string; + type: 'file' | 'folder'; + parentPath: string; + createdAt: Date; // 作成日時 + updatedAt: Date; // 更新日時 + isBufferArray: boolean; // バイナリファイル判定 + bufferContent?: ArrayBuffer; + aiReviewStatus?: string; // AI レビュー状態 + aiReviewComments?: string; // AI コメント +} +``` + +lightning-fsは単なるファイルシステムであり、これらのメタデータを保存できません。 + +#### ✅ トランザクション保証 + +IndexedDBはACID特性を持ち、複数のファイル操作を原子的に実行できます: + +```typescript +// 複数ファイルを一括作成(全て成功 or 全て失敗) +await fileRepository.createFilesBulk(projectId, [ + { path: '/package.json', content: '...', type: 'file' }, + { path: '/src/index.ts', content: '...', type: 'file' }, + { path: '/src/utils.ts', content: '...', type: 'file' }, +]); +``` + +#### ✅ Node.js Runtime のモジュール解決 + +ブラウザ内Node.js Runtimeは、`require()`や`import`でモジュールを解決する際にIndexedDBから高速に読み込みます: + +```typescript +// node_modules/react/index.js を高速読み込み +const reactModule = await fileRepository.getFileByPath( + projectId, + '/node_modules/react/index.js' +); +``` + +これがlightning-fsだと、毎回ファイルシステムAPIを通して読み込む必要があり、遅くなります。 + +### 2.2 lightning-fsが必要な理由 + +#### ✅ isomorphic-gitの要件 + +`isomorphic-git`は**POSIX風のファイルシステムAPI**を必須とします。IndexedDBでは提供できません: + +```typescript +import git from 'isomorphic-git'; + +// isomorphic-gitはFSインスタンスを必要とする +await git.commit({ + fs: gitFileSystem.getFS(), // lightning-fsのインスタンス + dir: '/projects/my-project', + message: 'Initial commit', + author: { name: 'User', email: 'user@example.com' } +}); +``` + +lightning-fsは`fs.promises`互換のAPIを提供するため、isomorphic-gitと完璧に統合できます。 + +#### ✅ Git操作に不要なファイルを除外 + +`.gitignore`の役割は、**Gitの追跡から除外すること**です。 + +- `node_modules/` は数万ファイルになることもある +- これらを全てGitで追跡すると、`git status`や`git diff`が極端に遅くなる +- `.gitignore`に従って、lightning-fsには**同期しない**ことで、Git操作を高速に保つ + +#### ✅ ターミナルコマンドの互換性 + +Unixコマンド(`ls`, `cat`, `cd`など)は、ファイルシステムAPIを前提としています: + +```typescript +// ls コマンドの実装 +const entries = await fs.promises.readdir('/projects/my-project/src'); +``` + +IndexedDBにはディレクトリの概念がないため、このようなAPIを実装するのは非効率です。 + +--- + +## 3. .gitignoreの動作 + +### 3.1 現在の実装 + +```mermaid +sequenceDiagram + participant UI as UI Component + participant Repo as FileRepository + participant IDB as IndexedDB + participant Sync as SyncManager + participant GitFS as lightning-fs + + UI->>Repo: createFile("node_modules/react/index.js") + Repo->>IDB: ✅ 保存(ALL FILES) + IDB-->>Repo: Success + + Repo->>Sync: syncToGitFileSystem() + Sync->>Sync: shouldIgnorePathForGit()? + + alt .gitignoreにマッチ + Sync-->>Repo: ⛔ スキップ(同期しない) + else .gitignoreにマッチしない + Sync->>GitFS: ✅ 書き込み + GitFS-->>Sync: Success + end +``` + +### 3.2 各レイヤーの内容 + +**例: node_modulesを持つプロジェクト** + +``` +プロジェクト構造: +/ +├── .gitignore ("node_modules" を含む) +├── package.json +├── src/ +│ └── index.ts +└── node_modules/ + └── react/ + └── index.js (数千ファイル...) +``` + +**IndexedDBの内容(全て格納):** +``` +✅ /.gitignore +✅ /package.json +✅ /src/index.ts +✅ /node_modules/react/index.js ← 全て保存 +✅ /node_modules/...(全ファイル) +``` + +**lightning-fsの内容(.gitignore適用済み):** +``` +✅ /.gitignore +✅ /package.json +✅ /src/index.ts +⛔ /node_modules/ ← .gitignoreで除外、同期されない +``` + +### 3.3 なぜnode_modulesをIndexedDBに保存するのか + +1. **Node.js Runtimeがrequire/importで必要** + ```typescript + import React from 'react'; // IndexedDBから読み込み + ``` + +2. **エディタでの参照ジャンプ** + - 型定義ファイル(.d.ts)を開く + - ライブラリのソースコードを閲覧 + +3. **検索機能** + - プロジェクト全体を検索する際、node_modules内も検索対象にできる + +4. **完全なプロジェクト状態の保持** + - プロジェクトのスナップショットとして全ファイルを保存 + +--- + +## 4. よくある誤解 + +### ❌ 誤解1: 「ファイルが完全に重複している」 + +**実態:** +- IndexedDB: 全ファイル(プロジェクトの完全な状態) +- lightning-fs: .gitignore適用後(Gitに必要なファイルのみ) + +これは**意図的な設計**であり、重複ではありません。 + +### ❌ 誤解2: 「.gitignoreが機能していない」 + +**実態:** +- .gitignoreは**完璧に機能している** +- `shouldIgnorePathForGit()`がチェックを行っている +- 無視されたファイルはlightning-fsに同期されない + +### ❌ 誤解3: 「二層は不要で、lightning-fs単体でいける」 + +**実態:** +- IndexedDBなしでは、以下が実現できない: + - 高速クエリ(パス検索、プレフィックス検索) + - メタデータ管理(作成日時、AIレビュー状態など) + - Node.js Runtimeの高速モジュール解決 + - トランザクション保証 + - ファイルツリーの効率的な構築 + +--- + +## 5. パフォーマンス最適化 + +### 5.1 同期の最適化 + +```typescript +// 単一ファイル変更: 個別同期 +await fileRepository.saveFile(file); +// → syncSingleFileToFS() が呼ばれる(高速) + +// 大量ファイル作成: 一括同期 +await fileRepository.createFilesBulk(projectId, files); +// → syncFromIndexedDBToFS() が呼ばれる(差分のみ同期) +``` + +### 5.2 .gitignore キャッシュ + +```typescript +// .gitignoreルールをキャッシュ(5分間有効) +private gitignoreCache: Map; +private readonly GITIGNORE_CACHE_TTL_MS = 5 * 60 * 1000; +``` + +.gitignoreファイルを毎回読み込むのではなく、キャッシュすることで高速化しています。 + +### 5.3 非同期同期 + +```typescript +// IndexedDB書き込みは即座に完了 +await fileRepository.saveFile(file); // ← ここで完了 + +// lightning-fsへの同期はバックグラウンドで実行 +this.syncToGitFileSystem(...).catch(error => { + coreWarn('[FileRepository] Background sync failed (non-critical):', error); +}); +``` + +ユーザーはIndexedDB書き込みの完了を待つだけで、lightning-fsへの同期は非同期で行われます。 + +--- + +## 6. 設計の利点まとめ + +| 機能 | IndexedDBのみ | lightning-fsのみ | **二層設計** | +|-----|-------------|----------------|-----------| +| 高速クエリ | ✅ | ❌ | ✅ | +| メタデータ管理 | ✅ | ❌ | ✅ | +| Git操作 | ❌ | ✅ | ✅ | +| .gitignore適用 | ❌ | ✅ | ✅ | +| Node.js Runtime | ✅ | ⚠️ 遅い | ✅ | +| トランザクション | ✅ | ❌ | ✅ | + +--- + +## 7. 結論 + +**二層アーキテクチャは、必要不可欠な設計です。** + +- **IndexedDB**: プロジェクト全体の高速ストレージ、メタデータ管理 +- **lightning-fs**: Git操作専用、.gitignore適用済み +- **.gitignore**: 正しく機能しており、lightning-fsへの同期を制御 +- **"重複"**: 意図的な設計であり、各レイヤーの役割が異なる + +この設計により、以下を同時に実現しています: + +1. 高速なファイル検索とエディタ操作 +2. 効率的なGit操作(node_modules除外) +3. 完全なプロジェクト状態の保持 +4. ブラウザ内Node.js Runtimeのサポート + +--- + +## Related Documents + +- [SYSTEM-OVERVIEW.md](./SYSTEM-OVERVIEW.md) - システム全体のアーキテクチャ +- [CORE-ENGINE.md](./CORE-ENGINE.md) - Core Engineの詳細設計 +- [DATA-FLOW.md](./DATA-FLOW.md) - データフローと状態管理 + +--- + +**Last Updated**: 2025-01-07 +**Version**: 1.0 +**Status**: Initial Release - 二層アーキテクチャの必要性を説明 diff --git a/extensions/_shared/systemModuleTypes.ts b/extensions/_shared/systemModuleTypes.ts index 585f8a2c..a0300631 100644 --- a/extensions/_shared/systemModuleTypes.ts +++ b/extensions/_shared/systemModuleTypes.ts @@ -59,6 +59,22 @@ export interface NormalizeCjsEsmModule { }>; } +/** + * pathUtils - パス操作ユーティリティ + */ +export interface PathUtilsModule { + /** パスを正規化(toAppPathのエイリアス) */ + normalizePath(path: string | null | undefined): string; + /** アプリ内部形式のパスに変換 */ + toAppPath(path: string | null | undefined): string; + /** 親ディレクトリのパスを取得 */ + getParentPath(path: string | null | undefined): string; + /** Git形式のパスに変換 */ + toGitPath(path: string | null | undefined): string; + /** Git形式のパスから変換 */ + fromGitPath(path: string | null | undefined): string; +} + /** * コマンド実行時のコンテキスト * (types.tsのCommandContextと重複を避けるため、ここでは最小限の定義) @@ -248,6 +264,7 @@ export interface StreamShell { export interface SystemModuleMap { fileRepository: FileRepository; normalizeCjsEsm: NormalizeCjsEsmModule; + pathUtils: PathUtilsModule; commandRegistry: CommandRegistry; /** Terminal/CLI commands provider exposed to extensions */ systemBuiltinCommands: { diff --git a/extensions/_shared/types.ts b/extensions/_shared/types.ts index 3a5defc0..2f39c55d 100644 --- a/extensions/_shared/types.ts +++ b/extensions/_shared/types.ts @@ -193,6 +193,28 @@ export interface ExtensionContext { // This keeps the extension-facing API concise and aligned with the engine's types. getSystemModule: GetSystemModule; + /** トランスパイラーを登録(transpiler拡張機能用) */ + registerTranspiler?: (config: { + id: string; + supportedExtensions: string[]; + needsTranspile?: (filePath: string) => boolean; + transpile: (code: string, options: any) => Promise<{ code: string; map?: string; dependencies?: string[] }>; + }) => Promise; + + /** ランタイムを登録(language-runtime拡張機能用) */ + registerRuntime?: (config: { + id: string; + name: string; + supportedExtensions: string[]; + canExecute: (filePath: string) => boolean; + initialize?: (projectId: string, projectName: string) => Promise; + execute: (options: any) => Promise; + executeCode?: (code: string, options: any) => Promise; + clearCache?: () => void; + dispose?: () => Promise; + isReady?: () => boolean; + }) => Promise; + /** 他の拡張機能との通信 (オプション・未実装) */ messaging?: { send: (targetId: string, message: unknown) => Promise; diff --git a/extensions/python-runtime/index.ts b/extensions/python-runtime/index.ts new file mode 100644 index 00000000..1034316b --- /dev/null +++ b/extensions/python-runtime/index.ts @@ -0,0 +1,486 @@ +/** + * Pyxis Python Runtime Extension + * + * Python runtime using Pyodide for browser-based Python execution + */ + +import type { ExtensionContext, ExtensionActivation } from '../_shared/types'; + +// Pyodide interface types +interface PyodideInterface { + runPythonAsync(code: string): Promise; + FS: { + readdir(path: string): string[]; + readFile(path: string, options: { encoding: string }): string; + writeFile(path: string, content: string): void; + mkdir(path: string): void; + rmdir(path: string): void; + unlink(path: string): void; + isDir(mode: number): boolean; + stat(path: string): { mode: number }; + }; + loadPackage(packages: string[]): Promise; + globals?: any; +} + +// Global Pyodide instance +let pyodideInstance: PyodideInterface | null = null; +let currentProjectId: string | null = null; + +export async function activate(context: ExtensionContext): Promise { + context.logger.info('Python Runtime Extension activating...'); + + // Initialize Pyodide + async function initPyodide(): Promise { + if (pyodideInstance) { + return pyodideInstance; + } + + // @ts-ignore - loadPyodide is loaded from CDN + const pyodide = await window.loadPyodide({ + stdout: (msg: string) => context.logger.info(msg), + stderr: (msg: string) => context.logger.error(msg), + }); + + pyodideInstance = pyodide; + return pyodide; + } + + // Parse .gitignore patterns + function parseGitignore(content: string): string[] { + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(pattern => { + // Convert .gitignore pattern to simple regex pattern + // Remove leading slash + if (pattern.startsWith('/')) { + pattern = pattern.substring(1); + } + return pattern; + }); + } + + // Check if a path matches any gitignore pattern + function isIgnored(filePath: string, patterns: string[]): boolean { + // Remove leading slash for comparison + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + + for (const pattern of patterns) { + // Handle directory patterns (ending with /) + if (pattern.endsWith('/')) { + const dirPattern = pattern.slice(0, -1); + if (normalizedPath.startsWith(dirPattern + '/') || normalizedPath === dirPattern) { + return true; + } + } + // Handle wildcard patterns + else if (pattern.includes('*')) { + const regexPattern = pattern + .replace(/\./g, '\\.') + .replace(/\*\*/g, '.*') + .replace(/\*/g, '[^/]*') + .replace(/\?/g, '.'); + const regex = new RegExp(`^${regexPattern}$`); + if (regex.test(normalizedPath)) { + return true; + } + } + // Handle exact match + else if (normalizedPath === pattern || normalizedPath.startsWith(pattern + '/')) { + return true; + } + } + + return false; + } + + // Sync files from IndexedDB to Pyodide + // Convert project path to Pyodide path, stripping /pyodide prefix if present + function normalizePathToPyodide(projectPath: string): string { + if (!projectPath) return projectPath; + // ensure leading slash + const p = projectPath.startsWith('/') ? projectPath : `/${projectPath}`; + if (p === '/pyodide') return '/'; + if (p.startsWith('/pyodide/')) return p.replace('/pyodide', ''); + return p; + } + + // Convert Pyodide path back to project path, stripping /pyodide prefix if present + function normalizePathFromPyodide(pyodideRelativePath: string): string { + if (!pyodideRelativePath) return pyodideRelativePath; + // ensure leading slash + const p = pyodideRelativePath.startsWith('/') ? pyodideRelativePath : `/${pyodideRelativePath}`; + if (p === '/pyodide') return '/'; + if (p.startsWith('/pyodide/')) return p.replace('/pyodide', ''); + return p; + } + + async function syncFilesToPyodide(projectId: string): Promise { + if (!pyodideInstance) return; + + const fileRepository = await context.getSystemModule('fileRepository'); + await fileRepository.init(); + + try { + // Get all files from the project + const files = await fileRepository.getProjectFiles(projectId); + + // Parse .gitignore if it exists + let gitignorePatterns: string[] = []; + const gitignoreFile = files.find(f => f.path === '/.gitignore' || f.path === '.gitignore'); + if (gitignoreFile && gitignoreFile.content) { + gitignorePatterns = parseGitignore(gitignoreFile.content); + } + + // Clear /home directory (but keep . and ..) + try { + const homeContents = pyodideInstance.FS.readdir('/home'); + for (const item of homeContents) { + if (item !== '.' && item !== '..') { + try { + pyodideInstance.FS.unlink(`/home/${item}`); + } catch { + try { + // Try to remove as directory if unlink fails + pyodideInstance.FS.rmdir(`/home/${item}`); + } catch { + // Ignore errors + } + } + } + } + } catch { + // If /home doesn't exist, create it + try { + pyodideInstance.FS.mkdir('/home'); + } catch { + // Already exists, ignore + } + } + + // Write each file to Pyodide filesystem under /home + let syncedCount = 0; + let ignoredCount = 0; + + for (const file of files) { + if (file.type === 'file' && file.path && file.content) { + // Skip files matching .gitignore patterns + if (isIgnored(file.path, gitignorePatterns)) { + ignoredCount++; + continue; + } + + try { + // Normalize path: strip /pyodide prefix if present + const normalizedProjectPath = normalizePathToPyodide(file.path); + const pyodidePath = `/home${normalizedProjectPath}`; + + // Create directory structure + const dirPath = pyodidePath.substring(0, pyodidePath.lastIndexOf('/')); + if (dirPath && dirPath !== '/home') { + createDirectoryRecursive(pyodideInstance, dirPath); + } + + // Write the file + pyodideInstance.FS.writeFile(pyodidePath, file.content); + syncedCount++; + } catch (error) { + context.logger.warn(`Failed to sync file ${file.path}:`, error); + } + } + } + + context.logger.info( + `✅ Synced ${syncedCount} files to Pyodide` + + (ignoredCount > 0 ? ` (${ignoredCount} ignored by .gitignore)` : '') + ); + } catch (error) { + context.logger.error('Failed to sync files to Pyodide:', error); + } + } + + // Helper to create directories recursively + function createDirectoryRecursive(pyodide: PyodideInterface, path: string): void { + const parts = path.split('/').filter(p => p); + let currentPath = ''; + + for (const part of parts) { + currentPath += '/' + part; + try { + pyodide.FS.mkdir(currentPath); + } catch { + // Directory already exists, ignore + } + } + } + + // List of available Pyodide packages + const pyodidePackages = [ + 'numpy', 'pandas', 'matplotlib', 'scipy', 'sklearn', 'sympy', 'networkx', + 'seaborn', 'statsmodels', 'micropip', 'bs4', 'lxml', 'pyyaml', 'requests', + 'pyodide', 'pyparsing', 'dateutil', 'jedi', 'pytz', 'sqlalchemy', 'pyarrow', + 'bokeh', 'plotly', 'altair', 'openpyxl', 'xlrd', 'xlsxwriter', 'jsonschema', + 'pillow', 'pygments', 'pytest', 'tqdm', 'scikit-image', 'scikit-learn', + 'shapely', 'zipp', + ]; + + // Execute Python code with auto-loading and sync back + async function runPythonWithSync(code: string, projectId: string): Promise { + const pyodide = await initPyodide(); + await syncFilesToPyodide(projectId); + + // Auto-load packages based on import statements + const importRegex = /^\s*import\s+([\w_]+)|^\s*from\s+([\w_]+)\s+import/gm; + const packages = new Set(); + let match; + while ((match = importRegex.exec(code)) !== null) { + if (match[1]) packages.add(match[1]); + if (match[2]) packages.add(match[2]); + } + + const toLoad = Array.from(packages).filter(pkg => pyodidePackages.includes(pkg)); + if (toLoad.length > 0) { + try { + context.logger.info(`📦 Loading Pyodide packages: ${toLoad.join(', ')}`); + await pyodide.loadPackage(toLoad); + } catch (e) { + context.logger.warn(`⚠️ Failed to load some packages: ${toLoad.join(', ')}`, e); + } + } + + // Capture stdout using StringIO + let stdout = ''; + let stderr = ''; + const captureCode = ` +import sys +import io +_pyxis_stdout = sys.stdout +_pyxis_stringio = io.StringIO() +sys.stdout = _pyxis_stringio +try: + exec("""${code.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}""", globals()) + _pyxis_result = _pyxis_stringio.getvalue() +finally: + sys.stdout = _pyxis_stdout +del _pyxis_stringio +del _pyxis_stdout +`; + + try { + await pyodide.runPythonAsync(captureCode); + stdout = (pyodide as any).globals.get('_pyxis_result') || ''; + (pyodide as any).globals.set('_pyxis_result', undefined); + } catch (error: any) { + stderr = error.message || String(error); + } + + // Sync files back to IndexedDB after execution + await syncFilesFromPyodide(projectId); + + return { result: stdout.trim(), stdout: stdout.trim(), stderr: stderr.trim() }; + } + + // Sync files from Pyodide back to IndexedDB + async function syncFilesFromPyodide(projectId: string): Promise { + if (!pyodideInstance) return; + + const fileRepository = await context.getSystemModule('fileRepository'); + await fileRepository.init(); + + const pathUtils = await context.getSystemModule('pathUtils'); + + try { + // Get existing files from IndexedDB + const existingFiles = await fileRepository.getProjectFiles(projectId); + const existingPaths = new Map(existingFiles.map(f => [f.path, f])); + + // Parse .gitignore if it exists + let gitignorePatterns: string[] = []; + const gitignoreFile = existingFiles.find(f => f.path === '/.gitignore' || f.path === '.gitignore'); + if (gitignoreFile && gitignoreFile.content) { + gitignorePatterns = parseGitignore(gitignoreFile.content); + } + + // Scan /home directory for files + const pyodideFiles = scanPyodideDirectory(pyodideInstance, '/home', ''); + + let syncedCount = 0; + let newFilesCount = 0; + let updatedFilesCount = 0; + let ignoredCount = 0; + + // Sync files from Pyodide to IndexedDB + for (const file of pyodideFiles) { + // Normalize the path: strip /pyodide prefix if present + const projectPath = normalizePathFromPyodide(file.path); + + // Skip files matching .gitignore patterns + if (isIgnored(projectPath, gitignorePatterns)) { + ignoredCount++; + continue; + } + + const existingFile = existingPaths.get(projectPath); + + if (existingFile) { + // Update existing file if content changed + if (existingFile.content !== file.content) { + await fileRepository.updateFileContent(existingFile.id, file.content); + updatedFilesCount++; + syncedCount++; + } + } else { + // Only create new files that were created during Python execution + // Skip if the file path looks like a Python script that was already in the project + // This prevents creating duplicates of source files + const isPythonSource = projectPath.endsWith('.py'); + const wasInOriginalProject = existingFiles.some(f => f.path === projectPath); + + if (!isPythonSource || !wasInOriginalProject) { + await fileRepository.createFile(projectId, projectPath, file.content, 'file'); + newFilesCount++; + syncedCount++; + } + } + } + + if (syncedCount > 0 || ignoredCount > 0) { + context.logger.info( + `✅ Synced ${syncedCount} files from Pyodide (${newFilesCount} new, ${updatedFilesCount} updated)` + + (ignoredCount > 0 ? ` - ${ignoredCount} ignored by .gitignore` : '') + ); + } + } catch (error) { + context.logger.error('Failed to sync files from Pyodide:', error); + } + } + + // Recursively scan Pyodide directory + function scanPyodideDirectory( + pyodide: PyodideInterface, + pyodidePath: string, + relativePath: string + ): Array<{ path: string; content: string }> { + const results: Array<{ path: string; content: string }> = []; + + try { + const contents = pyodide.FS.readdir(pyodidePath); + + for (const item of contents) { + if (item === '.' || item === '..') continue; + + const fullPyodidePath = `${pyodidePath}/${item}`; + const fullRelativePath = relativePath ? `${relativePath}/${item}` : `/${item}`; + + try { + const stat = pyodide.FS.stat(fullPyodidePath); + + if (pyodide.FS.isDir(stat.mode)) { + results.push(...scanPyodideDirectory(pyodide, fullPyodidePath, fullRelativePath)); + } else { + const content = pyodide.FS.readFile(fullPyodidePath, { encoding: 'utf8' }); + results.push({ path: fullRelativePath, content }); + } + } catch (error) { + context.logger.warn(`Failed to process: ${fullPyodidePath}`, error); + } + } + } catch (error) { + context.logger.warn(`Failed to read directory: ${pyodidePath}`, error); + } + + return results; + } + + // Register the Python runtime provider + await context.registerRuntime?.({ + id: 'python', + name: 'Python', + supportedExtensions: ['.py'], + + canExecute(filePath: string): boolean { + return filePath.endsWith('.py'); + }, + + async initialize(projectId: string, projectName: string): Promise { + context.logger.info(`🐍 Initializing Python runtime for project: ${projectName}`); + currentProjectId = projectId; + await initPyodide(); + await syncFilesToPyodide(projectId); + }, + + async execute(options: any): Promise { + const { projectId, filePath } = options; + + try { + context.logger.info(`🐍 Executing Python file: ${filePath}`); + + // Get the file repository to read the file + const fileRepository = await context.getSystemModule('fileRepository'); + await fileRepository.init(); + + // Read the Python file + const file = await fileRepository.getFileByPath(projectId, filePath); + if (!file || !file.content) { + throw new Error(`File not found: ${filePath}`); + } + + // Execute the Python code + const result = await runPythonWithSync(file.content, projectId); + + return { + stdout: result.stdout, + stderr: result.stderr, + result: result.result, + exitCode: result.stderr ? 1 : 0, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + context.logger.error(`❌ Python execution failed: ${errorMessage}`); + return { + stderr: errorMessage, + exitCode: 1, + }; + } + }, + + async executeCode(code: string, options: any): Promise { + try { + context.logger.info('🐍 Executing Python code snippet'); + + // Execute the Python code + const pyodide = await initPyodide(); + const result = await pyodide.runPythonAsync(code); + + return { + result: String(result), + exitCode: 0, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + context.logger.error(`❌ Python code execution failed: ${errorMessage}`); + return { + stderr: errorMessage, + exitCode: 1, + }; + } + }, + + isReady(): boolean { + return pyodideInstance !== null; + }, + }); + + context.logger.info('✅ Python Runtime Extension activated'); + + return {}; +} + +/** + * Extension deactivation + */ +export async function deactivate(): Promise { + console.log('[Python Runtime] Deactivating...'); +} diff --git a/extensions/python-runtime/manifest.json b/extensions/python-runtime/manifest.json new file mode 100644 index 00000000..16da108e --- /dev/null +++ b/extensions/python-runtime/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "pyxis.python-runtime", + "name": "Python Runtime", + "version": "1.0.0", + "type": "language-runtime", + "description": "Python runtime using Pyodide. Supports Python code execution in the browser.", + "author": "Pyxis Team", + "defaultEnabled": true, + "icon": "/extensions/pyxis/python-runtime/icon.svg", + "homepage": "https://github.com/Stasshe/Pyxis-CodeCanvas", + "dependencies": [], + "entry": "index.js", + "files": [], + "metadata": { + "publishedAt": "2025-12-07T00:00:00Z", + "updatedAt": "2025-12-07T00:00:00Z", + "tags": ["python", "runtime", "pyodide", "language"] + } +} diff --git a/extensions/python-runtime/package.json b/extensions/python-runtime/package.json new file mode 100644 index 00000000..593fe87e --- /dev/null +++ b/extensions/python-runtime/package.json @@ -0,0 +1,9 @@ +{ + "name": "python-runtime", + "version": "1.0.0", + "private": true, + "description": "Python runtime extension using Pyodide", + "dependencies": { + "pyodide": "^0.29.0" + } +} diff --git a/extensions/typescript-runtime/index.ts b/extensions/typescript-runtime/index.ts index 45ebbbb6..dda9404c 100644 --- a/extensions/typescript-runtime/index.ts +++ b/extensions/typescript-runtime/index.ts @@ -163,14 +163,14 @@ export async function activate(context: ExtensionContext): Promise { - const { filePath = 'unknown.ts', isTypeScript, isJSX } = options; + const { filePath = 'unknown.ts', isTypeScript } = options; context.logger.info(`🔄 Transpiling: ${filePath}`); try { - // TypeScriptまたはJSXの場合: Web Workerでトランスパイル - if (isTypeScript || isJSX) { - const result = await transpileWithWorker(code, filePath, isTypeScript || false, isJSX || false); + // TypeScriptの場合: Web Workerでトランスパイル + if (isTypeScript) { + const result = await transpileWithWorker(code, filePath, true, false); context.logger.info(`✅ Transpiled: ${filePath} (${code.length} -> ${result.code.length} bytes, ${result.dependencies.length} deps)`); @@ -211,16 +211,25 @@ export async function activate(context: ExtensionContext): Promise { - return /\.(ts|tsx|mts|cts|jsx)$/.test(filePath); + return /\.(ts|mts|cts)$/.test(filePath); }, }; + // RuntimeRegistryに登録 + await context.registerTranspiler?.({ + id: 'typescript', + supportedExtensions: runtimeFeatures.supportedExtensions, + needsTranspile: runtimeFeatures.needsTranspile, + transpile: runtimeFeatures.transpiler, + }); + context.logger.info('✅ TypeScript transpiler registered with RuntimeRegistry'); + context.logger.info('✅ TypeScript Runtime Extension activated'); return { diff --git a/locales/ar/common.json b/locales/ar/common.json index 2c297a33..95082095 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -211,7 +211,8 @@ "preview": "معاينة", "reset": "إعادة تعيين", "zoomIn": "تكبير", - "zoomOut": "تصغير" + "zoomOut": "تصغير", + "mermaidPlaceholder": "قم بالتمرير لعرض مخطط Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/de/common.json b/locales/de/common.json index 8026f867..8e1e1914 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -211,7 +211,8 @@ "preview": "Vorschau", "reset": "Zurücksetzen", "zoomIn": "Vergrößern", - "zoomOut": "Verkleinern" + "zoomOut": "Verkleinern", + "mermaidPlaceholder": "Scrollen Sie, um das Mermaid-Diagramm anzuzeigen" }, "menu": { "extensions": "Erweiterungen", diff --git a/locales/en/common.json b/locales/en/common.json index c86b5047..92bd0cb7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -211,7 +211,8 @@ "preview": "Preview", "reset": "Reset", "zoomIn": "Zoom in", - "zoomOut": "Zoom out" + "zoomOut": "Zoom out", + "mermaidPlaceholder": "Scroll to view Mermaid diagram" }, "menu": { "extensions": "Extensions", @@ -336,6 +337,18 @@ "title": "Theme" } }, + "paneNavigator": { + "title": "Pane Navigator", + "emptyPane": "Empty", + "splitVertical": "Split Vertical", + "splitHorizontal": "Split Horizontal", + "deletePane": "Delete Pane", + "navigate": "Navigate", + "activate": "Activate", + "close": "Close", + "tab": "tab", + "tabs": "tabs" + }, "tabBar": { "closeTab": "Close tab", "moveToPane": "Move to pane", diff --git a/locales/es/common.json b/locales/es/common.json index b8479ae1..ded5c69f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -211,7 +211,8 @@ "preview": "Vista previa", "reset": "Restablecer", "zoomIn": "Acercar", - "zoomOut": "Alejar" + "zoomOut": "Alejar", + "mermaidPlaceholder": "Desplácese para ver el diagrama de Mermaid" }, "menu": { "extensions": "Extensiones", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6d142fd1..63c8c172 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -211,7 +211,8 @@ "preview": "Aperçu", "reset": "Réinitialiser", "zoomIn": "Agrandir", - "zoomOut": "Rétrécir" + "zoomOut": "Rétrécir", + "mermaidPlaceholder": "Faites défiler pour voir le diagramme Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/hi/common.json b/locales/hi/common.json index fe3f86ac..6456a6cf 100644 --- a/locales/hi/common.json +++ b/locales/hi/common.json @@ -211,7 +211,8 @@ "preview": "पूर्वावलोकन", "reset": "रीसेट", "zoomIn": "ज़ूम इन", - "zoomOut": "ज़ूम आउट" + "zoomOut": "ज़ूम आउट", + "mermaidPlaceholder": "Mermaid आरेख देखने के लिए स्क्रॉल करें" }, "menu": { "extensions": "Extensions", diff --git a/locales/id/common.json b/locales/id/common.json index d7db2c23..cf9c1ba0 100644 --- a/locales/id/common.json +++ b/locales/id/common.json @@ -211,7 +211,8 @@ "preview": "Pratinjau", "reset": "Reset", "zoomIn": "Perbesar", - "zoomOut": "Perkecil" + "zoomOut": "Perkecil", + "mermaidPlaceholder": "Gulir untuk melihat diagram Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/it/common.json b/locales/it/common.json index ef4e3e98..88a89059 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -211,7 +211,8 @@ "preview": "Anteprima", "reset": "Reimposta", "zoomIn": "Zoom avanti", - "zoomOut": "Zoom indietro" + "zoomOut": "Zoom indietro", + "mermaidPlaceholder": "Scorri per visualizzare il diagramma Mermaid" }, "menu": { "extensions": "Estensioni", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1092a9d2..f7871a81 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -211,7 +211,8 @@ "preview": "プレビュー", "reset": "リセット", "zoomIn": "ズームイン", - "zoomOut": "ズームアウト" + "zoomOut": "ズームアウト", + "mermaidPlaceholder": "スクロールするとMermaid図が表示されます" }, "menu": { "extensions": "拡張機能", @@ -336,6 +337,18 @@ "title": "テーマ" } }, + "paneNavigator": { + "title": "ペインナビゲーター", + "emptyPane": "空", + "splitVertical": "縦に分割", + "splitHorizontal": "横に分割", + "deletePane": "ペインを削除", + "navigate": "移動", + "activate": "選択", + "close": "閉じる", + "tab": "タブ", + "tabs": "タブ" + }, "tabBar": { "closeTab": "タブを閉じる", "moveToPane": "ペインに移動", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1f0e3b59..daad3179 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -211,7 +211,8 @@ "preview": "미리보기", "reset": "리셋", "zoomIn": "확대", - "zoomOut": "축소" + "zoomOut": "축소", + "mermaidPlaceholder": "스크롤하여 Mermaid 다이어그램 보기" }, "menu": { "extensions": "Extensions", diff --git a/locales/nl/common.json b/locales/nl/common.json index ab69bc6a..9ed3054c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -211,7 +211,8 @@ "preview": "Voorbeeld", "reset": "Resetten", "zoomIn": "Inzoomen", - "zoomOut": "Uitzoomen" + "zoomOut": "Uitzoomen", + "mermaidPlaceholder": "Scroll om het Mermaid-diagram te bekijken" }, "menu": { "extensions": "Extensies", diff --git a/locales/pl/common.json b/locales/pl/common.json index 37d7731b..3108ed98 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -211,7 +211,8 @@ "preview": "Podgląd", "reset": "Resetuj", "zoomIn": "Powiększ", - "zoomOut": "Pomniejsz" + "zoomOut": "Pomniejsz", + "mermaidPlaceholder": "Przewiń, aby zobaczyć diagram Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/pt/common.json b/locales/pt/common.json index d1e73897..8009df13 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -211,7 +211,8 @@ "preview": "Pré-visualizar", "reset": "Redefinir", "zoomIn": "Aproximar", - "zoomOut": "Afastar" + "zoomOut": "Afastar", + "mermaidPlaceholder": "Role para ver o diagrama Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/ru/common.json b/locales/ru/common.json index def10742..5d5fbcd7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -211,7 +211,8 @@ "preview": "Предпросмотр", "reset": "Сброс", "zoomIn": "Увеличить", - "zoomOut": "Уменьшить" + "zoomOut": "Уменьшить", + "mermaidPlaceholder": "Прокрутите, чтобы увидеть диаграмму Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/sv/common.json b/locales/sv/common.json index 5dc7b7b5..a9e895c6 100644 --- a/locales/sv/common.json +++ b/locales/sv/common.json @@ -211,7 +211,8 @@ "preview": "Förhandsvisning", "reset": "Återställ", "zoomIn": "Zooma in", - "zoomOut": "Zooma ut" + "zoomOut": "Zooma ut", + "mermaidPlaceholder": "Scrolla för att visa Mermaid-diagrammet" }, "menu": { "extensions": "Extensions", diff --git a/locales/th/common.json b/locales/th/common.json index c08618f2..614b048e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -211,7 +211,8 @@ "preview": "ดูตัวอย่าง", "reset": "รีเซ็ต", "zoomIn": "ซูมเข้า", - "zoomOut": "ซูมออก" + "zoomOut": "ซูมออก", + "mermaidPlaceholder": "เลื่อนเพื่อดูไดอะแกรม Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/tr/common.json b/locales/tr/common.json index 76a076b7..9ca5e01a 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -211,7 +211,8 @@ "preview": "Önizleme", "reset": "Sıfırla", "zoomIn": "Yakınlaştır", - "zoomOut": "Uzaklaştır" + "zoomOut": "Uzaklaştır", + "mermaidPlaceholder": "Mermaid diyagramını görmek için kaydırın" }, "menu": { "extensions": "Extensions", diff --git a/locales/vi/common.json b/locales/vi/common.json index 88f8e636..3da38f59 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -211,7 +211,8 @@ "preview": "Xem trước", "reset": "Đặt lại", "zoomIn": "Phóng to", - "zoomOut": "Thu nhỏ" + "zoomOut": "Thu nhỏ", + "mermaidPlaceholder": "Cuộn để xem sơ đồ Mermaid" }, "menu": { "extensions": "Extensions", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 65abbf7a..eb5dc5e1 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -211,7 +211,8 @@ "preview": "預覽", "reset": "重設", "zoomIn": "放大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "mermaidPlaceholder": "捲動以檢視 Mermaid 圖" }, "menu": { "extensions": "Extensions", diff --git a/locales/zh/common.json b/locales/zh/common.json index 1c5ff9c6..2ddf2182 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -211,7 +211,8 @@ "preview": "预览", "reset": "重置", "zoomIn": "放大", - "zoomOut": "缩小" + "zoomOut": "缩小", + "mermaidPlaceholder": "滚动以查看 Mermaid 图" }, "menu": { "extensions": "Extensions", diff --git a/package.json b/package.json index 2530a4c1..7840c3ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pyxis", - "version": "0.15.4", + "version": "0.16.1", "private": true, "scripts": { "dev": "pnpm run setup-build && next dev --turbopack", @@ -37,27 +37,27 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "crypto-browserify": "^3.12.1", "diff": "^8.0.2", "error-stack-parser": "^2.1.4", "html2pdf.js": "^0.12.1", - "isomorphic-git": "^1.34.2", + "isomorphic-git": "^1.35.1", "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.546.0", - "mermaid": "^11.12.1", + "mermaid": "^11.12.2", "monaco-editor": "^0.53.0", - "next": "^16.0.1", + "next": "^16.0.7", "os-browserify": "^0.3.0", "pako": "^2.1.0", "path-browserify": "^1.0.1", - "pyodide": "^0.29.0", - "react": "^19.2.0", + "react": "^19.2.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^19.2.0", + "react-dnd-touch-backend": "^16.0.1", + "react-dom": "^19.2.1", "react-markdown": "^10.1.0", "readable-stream": "^4.7.0", "rehype-katex": "^7.0.1", @@ -70,18 +70,18 @@ "tar-stream": "^3.1.7", "vm-browserify": "^1.1.2", "vscode-icons-js": "^11.6.1", - "yaml": "^2.8.1", - "zustand": "^5.0.8" + "yaml": "^2.8.2", + "zustand": "^5.0.9" }, "devDependencies": { "@babel/helper-plugin-utils": "^7.27.1", - "@tailwindcss/postcss": "^4.1.16", + "@tailwindcss/postcss": "^4.1.17", "@types/html2pdf.js": "^0.10.0", "@types/jest": "29.5.3", - "@types/node": "^20.19.24", + "@types/node": "^20.19.25", "@types/pako": "^2.0.4", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@types/tar-stream": "^3.1.4", "boxen": "^5.1.2", @@ -98,7 +98,7 @@ "jest": "29.6.4", "jest-environment-jsdom": "29.6.4", "ora": "^5.4.1", - "prettier": "^3.6.2", + "prettier": "^3.7.4", "tailwindcss": "^3.4.18", "ts-jest": "29.1.0", "ts-prune": "^0.10.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index febb2107..fdd9f46f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,13 +43,13 @@ importers: version: 4.6.2 '@monaco-editor/react': specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.53.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 4.7.0(monaco-editor@0.53.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@types/d3': specifier: ^7.4.3 version: 7.4.3 '@uiw/react-codemirror': specifier: ^4.25.3 - version: 4.25.3(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.19.1)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.6)(codemirror@6.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 4.25.3(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.8)(codemirror@6.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -60,8 +60,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 autoprefixer: - specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.6) + specifier: ^10.4.22 + version: 10.4.22(postcss@8.5.6) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -78,8 +78,8 @@ importers: specifier: ^0.12.1 version: 0.12.1 isomorphic-git: - specifier: ^1.34.2 - version: 1.34.2 + specifier: ^1.35.1 + version: 1.35.1 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -88,16 +88,16 @@ importers: version: 0.16.25 lucide-react: specifier: ^0.546.0 - version: 0.546.0(react@19.2.0) + version: 0.546.0(react@19.2.1) mermaid: - specifier: ^11.12.1 - version: 11.12.1 + specifier: ^11.12.2 + version: 11.12.2 monaco-editor: specifier: ^0.53.0 version: 0.53.0 next: - specifier: ^16.0.1 - version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: ^16.0.7 + version: 16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) os-browserify: specifier: ^0.3.0 version: 0.3.0 @@ -107,24 +107,24 @@ importers: path-browserify: specifier: ^1.0.1 version: 1.0.1 - pyodide: - specifier: ^0.29.0 - version: 0.29.0 react: - specifier: ^19.2.0 - version: 19.2.0 + specifier: ^19.2.1 + version: 19.2.1 react-dnd: specifier: ^16.0.1 - version: 16.0.1(@types/node@20.19.24)(@types/react@19.2.2)(react@19.2.0) + version: 16.0.1(@types/node@20.19.25)(@types/react@19.2.7)(react@19.2.1) react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 + react-dnd-touch-backend: + specifier: ^16.0.1 + version: 16.0.1 react-dom: - specifier: ^19.2.0 - version: 19.2.0(react@19.2.0) + specifier: ^19.2.1 + version: 19.2.1(react@19.2.1) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.2)(react@19.2.0) + version: 10.1.0(@types/react@19.2.7)(react@19.2.1) readable-stream: specifier: ^4.7.0 version: 4.7.0 @@ -159,18 +159,18 @@ importers: specifier: ^11.6.1 version: 11.6.1 yaml: - specifier: ^2.8.1 - version: 2.8.1 + specifier: ^2.8.2 + version: 2.8.2 zustand: - specifier: ^5.0.8 - version: 5.0.8(@types/react@19.2.2)(react@19.2.0) + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.1) devDependencies: '@babel/helper-plugin-utils': specifier: ^7.27.1 version: 7.27.1 '@tailwindcss/postcss': - specifier: ^4.1.16 - version: 4.1.16 + specifier: ^4.1.17 + version: 4.1.17 '@types/html2pdf.js': specifier: ^0.10.0 version: 0.10.0 @@ -178,17 +178,17 @@ importers: specifier: 29.5.3 version: 29.5.3 '@types/node': - specifier: ^20.19.24 - version: 20.19.24 + specifier: ^20.19.25 + version: 20.19.25 '@types/pako': specifier: ^2.0.4 version: 2.0.4 '@types/react': - specifier: ^19.2.2 - version: 19.2.2 + specifier: ^19.2.7 + version: 19.2.7 '@types/react-dom': - specifier: ^19.2.2 - version: 19.2.2(@types/react@19.2.2) + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 @@ -224,13 +224,13 @@ importers: version: 15.3.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-unused-imports: specifier: ^4.3.0 - version: 4.3.0(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)) + version: 4.3.0(@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)) figures: specifier: ^3.2.0 version: 3.2.0 jest: specifier: 29.6.4 - version: 29.6.4(@types/node@20.19.24) + version: 29.6.4(@types/node@20.19.25) jest-environment-jsdom: specifier: 29.6.4 version: 29.6.4 @@ -238,14 +238,14 @@ importers: specifier: ^5.4.1 version: 5.4.1 prettier: - specifier: ^3.6.2 - version: 3.6.2 + specifier: ^3.7.4 + version: 3.7.4 tailwindcss: specifier: ^3.4.18 - version: 3.4.18(yaml@2.8.1) + version: 3.4.18(yaml@2.8.2) ts-jest: specifier: 29.1.0 - version: 29.1.0(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.6.4(@types/node@20.19.24))(typescript@5.9.3) + version: 29.1.0(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.6.4(@types/node@20.19.25))(typescript@5.9.3) ts-prune: specifier: ^0.10.3 version: 0.10.3 @@ -268,6 +268,12 @@ importers: specifier: ^4.4.1 version: 4.5.1 + extensions/python-runtime: + dependencies: + pyodide: + specifier: ^0.29.0 + version: 0.29.0 + extensions/react-preview: dependencies: esbuild-wasm: @@ -283,9 +289,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@antfu/utils@9.3.0': - resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -473,8 +476,8 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} - '@codemirror/autocomplete@6.19.1': - resolution: {integrity: sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==} + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} '@codemirror/commands@6.10.0': resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==} @@ -518,18 +521,18 @@ packages: '@codemirror/theme-one-dark@6.1.3': resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} - '@codemirror/view@6.38.6': - resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} + '@codemirror/view@6.38.8': + resolution: {integrity: sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} - '@emnapi/core@1.7.0': - resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} - '@emnapi/runtime@1.7.0': - resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -712,8 +715,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.1': @@ -747,139 +750,146 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.0.2': - resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.4': - resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.4': - resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.3': - resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.3': - resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.3': - resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.2.3': - resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.2.3': - resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.2.3': - resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.2.3': - resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': - resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.2.3': - resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.34.4': - resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.34.4': - resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.34.4': - resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - '@img/sharp-linux-s390x@0.34.4': - resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.34.4': - resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.34.4': - resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.34.4': - resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.34.4': - resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.4': - resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.4': - resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.4': - resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isomorphic-git/idb-keyval@3.3.2': resolution: {integrity: sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA==} @@ -986,8 +996,8 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@lezer/common@1.3.0': - resolution: {integrity: sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==} + '@lezer/common@1.4.0': + resolution: {integrity: sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==} '@lezer/css@1.3.0': resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==} @@ -1004,8 +1014,8 @@ packages: '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} - '@lezer/lr@1.4.3': - resolution: {integrity: sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==} + '@lezer/lr@1.4.4': + resolution: {integrity: sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==} '@lezer/markdown@1.6.0': resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==} @@ -1025,8 +1035,8 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} - '@monaco-editor/loader@1.6.1': - resolution: {integrity: sha512-w3tEnj9HYEC73wtjdpR089AqkUPskFRcdkxsiSFt3SoUc3OHpmu+leP94CXBm4mHfefmhsdfI0ZQu6qJ0wgtPg==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} '@monaco-editor/react@4.7.0': resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} @@ -1038,56 +1048,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@16.0.1': - resolution: {integrity: sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==} + '@next/env@16.0.7': + resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} '@next/eslint-plugin-next@15.3.4': resolution: {integrity: sha512-lBxYdj7TI8phbJcLSAqDt57nIcobEign5NYIKCiy0hXQhrUbTqLqOaSDi568U6vFg4hJfBdZYsG4iP/uKhCqgg==} - '@next/swc-darwin-arm64@16.0.1': - resolution: {integrity: sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==} + '@next/swc-darwin-arm64@16.0.7': + resolution: {integrity: sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.0.1': - resolution: {integrity: sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==} + '@next/swc-darwin-x64@16.0.7': + resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.0.1': - resolution: {integrity: sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==} + '@next/swc-linux-arm64-gnu@16.0.7': + resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.0.1': - resolution: {integrity: sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==} + '@next/swc-linux-arm64-musl@16.0.7': + resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.0.1': - resolution: {integrity: sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==} + '@next/swc-linux-x64-gnu@16.0.7': + resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.0.1': - resolution: {integrity: sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==} + '@next/swc-linux-x64-musl@16.0.7': + resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.0.1': - resolution: {integrity: sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==} + '@next/swc-win32-arm64-msvc@16.0.7': + resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.1': - resolution: {integrity: sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==} + '@next/swc-win32-x64-msvc@16.0.7': + resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1108,10 +1118,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@react-dnd/asap@5.0.2': resolution: {integrity: sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==} @@ -1124,8 +1130,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/eslint-patch@1.14.1': - resolution: {integrity: sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==} + '@rushstack/eslint-patch@1.15.0': + resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -1139,65 +1145,65 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.1.16': - resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + '@tailwindcss/node@4.1.17': + resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} - '@tailwindcss/oxide-android-arm64@4.1.16': - resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} + '@tailwindcss/oxide-android-arm64@4.1.17': + resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.16': - resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} + '@tailwindcss/oxide-darwin-arm64@4.1.17': + resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.16': - resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} + '@tailwindcss/oxide-darwin-x64@4.1.17': + resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.16': - resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} + '@tailwindcss/oxide-freebsd-x64@4.1.17': + resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': - resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': - resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.1.16': - resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.1.16': - resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.1.16': - resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.1.16': - resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1208,24 +1214,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': - resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.16': - resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.16': - resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} + '@tailwindcss/oxide@4.1.17': + resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} engines: {node: '>= 10'} - '@tailwindcss/postcss@4.1.16': - resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} + '@tailwindcss/postcss@4.1.17': + resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} '@tootallnate/once@2.0.0': resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} @@ -1402,8 +1408,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.24': - resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1417,16 +1423,16 @@ packages: '@types/raf@3.4.3': resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - '@types/react-dom@19.2.2': - resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react@19.2.2': - resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1452,14 +1458,14 @@ packages: '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - '@types/yargs@17.0.34': - resolution: {integrity: sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==} + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.46.3': - resolution: {integrity: sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==} + '@typescript-eslint/eslint-plugin@8.48.1': + resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.3 + '@typescript-eslint/parser': ^8.48.1 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' @@ -1473,15 +1479,15 @@ packages: typescript: optional: true - '@typescript-eslint/parser@8.46.3': - resolution: {integrity: sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==} + '@typescript-eslint/parser@8.48.1': + resolution: {integrity: sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.3': - resolution: {integrity: sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==} + '@typescript-eslint/project-service@8.48.1': + resolution: {integrity: sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' @@ -1490,18 +1496,18 @@ packages: resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/scope-manager@8.46.3': - resolution: {integrity: sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==} + '@typescript-eslint/scope-manager@8.48.1': + resolution: {integrity: sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.3': - resolution: {integrity: sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==} + '@typescript-eslint/tsconfig-utils@8.48.1': + resolution: {integrity: sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.3': - resolution: {integrity: sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==} + '@typescript-eslint/type-utils@8.48.1': + resolution: {integrity: sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1511,8 +1517,8 @@ packages: resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/types@8.46.3': - resolution: {integrity: sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==} + '@typescript-eslint/types@8.48.1': + resolution: {integrity: sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@6.21.0': @@ -1524,14 +1530,14 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.46.3': - resolution: {integrity: sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==} + '@typescript-eslint/typescript-estree@8.48.1': + resolution: {integrity: sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.3': - resolution: {integrity: sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==} + '@typescript-eslint/utils@8.48.1': + resolution: {integrity: sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1541,8 +1547,8 @@ packages: resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/visitor-keys@8.46.3': - resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} + '@typescript-eslint/visitor-keys@8.48.1': + resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@uiw/codemirror-extensions-basic-setup@4.25.3': @@ -1665,20 +1671,20 @@ packages: cpu: [x64] os: [win32] - '@vue/compiler-core@3.5.23': - resolution: {integrity: sha512-nW7THWj5HOp085ROk65LwaoxuzDsjIxr485F4iu63BoxsXoSqKqmsUUoP4A7Gl67DgIgi0zJ8JFgHfvny/74MA==} + '@vue/compiler-core@3.5.25': + resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==} - '@vue/compiler-dom@3.5.23': - resolution: {integrity: sha512-AT8RMw0vEzzzO0JU5gY0F6iCzaWUIh/aaRVordzMBKXRpoTllTT4kocHDssByPsvodNCfump/Lkdow2mT/O5KQ==} + '@vue/compiler-dom@3.5.25': + resolution: {integrity: sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==} - '@vue/compiler-sfc@3.5.23': - resolution: {integrity: sha512-3QTEUo4qg7FtQwaDJa8ou1CUikx5WTtZlY61rRRDu3lK2ZKrGoAGG8mvDgOpDsQ4A1bez9s+WtBB6DS2KuFCPw==} + '@vue/compiler-sfc@3.5.25': + resolution: {integrity: sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==} - '@vue/compiler-ssr@3.5.23': - resolution: {integrity: sha512-Hld2xphbMjXs9Q9WKxPf2EqmE+Rq/FEDnK/wUBtmYq74HCV4XDdSCheAaB823OQXIIFGq9ig/RbAZkF9s4U0Ow==} + '@vue/compiler-ssr@3.5.25': + resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==} - '@vue/shared@3.5.23': - resolution: {integrity: sha512-0YZ1DYuC5o/YJPf6pFdt2KYxVGDxkDbH/1NYJnVJWUkzr8ituBEmFVQRNX2gCaAsFEjEDnLkWpgqlZA7htgS/g==} + '@vue/shared@3.5.25': + resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} @@ -1740,10 +1746,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1752,10 +1754,6 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1836,8 +1834,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + autoprefixer@10.4.22: + resolution: {integrity: sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -1894,8 +1892,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bare-events@2.8.1: - resolution: {integrity: sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: bare-abort-controller: '*' peerDependenciesMeta: @@ -1909,8 +1907,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.25: - resolution: {integrity: sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==} + baseline-browser-mapping@2.9.3: + resolution: {integrity: sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==} hasBin: true binary-extensions@2.3.0: @@ -1960,8 +1958,8 @@ packages: resolution: {integrity: sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==} engines: {node: '>= 0.10'} - browserslist@4.27.0: - resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2015,8 +2013,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001753: - resolution: {integrity: sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==} + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} canvg@3.0.11: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} @@ -2158,14 +2156,11 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-js@3.46.0: - resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2237,8 +2232,8 @@ packages: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} cytoscape-cose-bilkent@4.1.0: resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} @@ -2551,11 +2546,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - electron-to-chromium@1.5.245: - resolution: {integrity: sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==} + electron-to-chromium@1.5.266: + resolution: {integrity: sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==} elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} @@ -2825,9 +2817,6 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2927,16 +2916,12 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2999,10 +2984,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.5: - resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} - hasBin: true - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -3019,10 +3000,6 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -3109,8 +3086,8 @@ packages: hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} - hast-util-to-parse5@8.0.0: - resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -3207,8 +3184,8 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.6: - resolution: {integrity: sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -3295,10 +3272,6 @@ packages: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} - is-git-ref-name-valid@1.0.0: - resolution: {integrity: sha512-2hLTg+7IqMSP9nNp/EVCxzvAOJGsAn0f/cKtF8JaBeivjH5UgE/XZo3iJ0AvibdE7KSF1f/7JbjBTB8Wqgbn/w==} - engines: {node: '>=10'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3390,8 +3363,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-git@1.34.2: - resolution: {integrity: sha512-wPKs5a4sLn18SGd8MPNKe089wTnI4agfAY8et+q0GabtgJyNLRdC3ukHZ4EEC5XnczIwJOZ2xPvvTFgPXm80wg==} + isomorphic-git@1.35.1: + resolution: {integrity: sha512-XNWd4cIwiGhkMs3C4mK21ch/frfzwFKtJuyv1gf0M4gK/2oZf5PTouwim8cp3Z6rkGbpSpQPaI6jGbV/C+048Q==} engines: {node: '>=14.17'} hasBin: true @@ -3426,9 +3399,6 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3578,12 +3548,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true jsdom@20.0.3: @@ -3621,8 +3591,8 @@ packages: engines: {node: '>=6'} hasBin: true - jspdf@3.0.3: - resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==} + jspdf@3.0.4: + resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3651,9 +3621,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - langium@3.3.1: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} @@ -3762,10 +3729,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3797,9 +3760,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3824,8 +3784,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@16.4.1: - resolution: {integrity: sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} hasBin: true @@ -3878,8 +3838,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -3894,8 +3854,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.12.1: - resolution: {integrity: sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==} + mermaid@11.12.2: + resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -4035,10 +3995,6 @@ packages: minimisted@2.0.1: resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -4073,8 +4029,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.0.1: - resolution: {integrity: sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==} + next@16.0.7: + resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -4119,8 +4075,8 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} - nwsapi@2.2.22: - resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -4200,11 +4156,8 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - package-manager-detector@1.5.0: - resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -4255,10 +4208,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4307,9 +4256,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} @@ -4378,8 +4324,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} engines: {node: '>=14'} hasBin: true @@ -4401,9 +4347,6 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - property-information@6.5.0: - resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} - property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -4424,9 +4367,6 @@ packages: resolution: {integrity: sha512-ObIvsTmcrxAWKg+FT1GjfSdDmQc5CabnYe/nn5BCuhr9BVVITeQ24DBdZuG5B2tIiAZ9YonBpnDB7cmHZyd2Rw==} engines: {node: '>=18.0.0'} - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -4445,6 +4385,9 @@ packages: react-dnd-html5-backend@16.0.1: resolution: {integrity: sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==} + react-dnd-touch-backend@16.0.1: + resolution: {integrity: sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==} + react-dnd@16.0.1: resolution: {integrity: sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==} peerDependencies: @@ -4460,10 +4403,10 @@ packages: '@types/react': optional: true - react-dom@19.2.0: - resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + react-dom@19.2.1: + resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} peerDependencies: - react: ^19.2.0 + react: ^19.2.1 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4477,8 +4420,8 @@ packages: '@types/react': '>=18' react: '>=18' - react@19.2.0: - resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + react@19.2.1: + resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -4687,8 +4630,8 @@ packages: engines: {node: '>= 0.10'} hasBin: true - sharp@0.34.4: - resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} shebang-command@2.0.0: @@ -4718,10 +4661,6 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -4799,10 +4738,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4839,10 +4774,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4862,11 +4793,11 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} - style-to-js@1.1.19: - resolution: {integrity: sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - style-to-object@1.0.12: - resolution: {integrity: sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==} + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -4884,8 +4815,8 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} - sucrase@3.35.0: - resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true @@ -4913,8 +4844,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tailwindcss@4.1.16: - resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + tailwindcss@4.1.17: + resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} @@ -5126,8 +5057,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.1.4: - resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + update-browserslist-db@1.2.2: + resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -5262,10 +5193,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5303,8 +5230,8 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.8.1: - resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} hasBin: true @@ -5328,8 +5255,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zustand@5.0.8: - resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} engines: {node: '>=12.20.0'} peerDependencies: '@types/react': '>=18.0.0' @@ -5355,11 +5282,9 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: - package-manager-detector: 1.5.0 + package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@antfu/utils@9.3.0': {} - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5400,7 +5325,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.5 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.27.0 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -5570,48 +5495,48 @@ snapshots: '@chevrotain/utils@11.0.3': {} - '@codemirror/autocomplete@6.19.1': + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@codemirror/commands@6.10.0': dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@codemirror/lang-css@6.3.1': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/css': 1.3.0 '@codemirror/lang-html@6.4.11': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/lang-css': 6.3.1 '@codemirror/lang-javascript': 6.2.4 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@lezer/css': 1.3.0 '@lezer/html': 1.3.12 '@codemirror/lang-javascript@6.2.4': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/lint': 6.9.2 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@lezer/javascript': 1.5.4 '@codemirror/lang-json@6.0.2': @@ -5621,60 +5546,60 @@ snapshots: '@codemirror/lang-markdown@6.5.0': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/lang-html': 6.4.11 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@lezer/markdown': 1.6.0 '@codemirror/lang-python@6.2.1': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/python': 1.1.18 '@codemirror/lang-xml@6.1.0': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@lezer/xml': 1.0.6 '@codemirror/lang-yaml@6.1.2': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/yaml': 1.0.3 '@codemirror/language@6.11.3': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 + '@codemirror/view': 6.38.8 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 style-mod: 4.1.3 '@codemirror/lint@6.9.2': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 + '@codemirror/view': 6.38.8 crelt: 1.0.6 '@codemirror/search@6.5.11': dependencies: '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 + '@codemirror/view': 6.38.8 crelt: 1.0.6 '@codemirror/state@6.5.2': @@ -5685,10 +5610,10 @@ snapshots: dependencies: '@codemirror/language': 6.11.3 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 + '@codemirror/view': 6.38.8 '@lezer/highlight': 1.2.3 - '@codemirror/view@6.38.6': + '@codemirror/view@6.38.8': dependencies: '@codemirror/state': 6.5.2 crelt: 1.0.6 @@ -5698,13 +5623,13 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@emnapi/core@1.7.0': + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.0': + '@emnapi/runtime@1.7.1': dependencies: tslib: 2.8.1 optional: true @@ -5815,7 +5740,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 debug: 4.4.3 @@ -5823,7 +5748,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5851,116 +5776,108 @@ snapshots: '@iconify/types@2.0.0': {} - '@iconify/utils@3.0.2': + '@iconify/utils@3.1.0': dependencies: '@antfu/install-pkg': 1.1.0 - '@antfu/utils': 9.3.0 '@iconify/types': 2.0.0 - debug: 4.4.3 - globals: 15.15.0 - kolorist: 1.8.0 - local-pkg: 1.1.2 mlly: 1.8.0 - transitivePeerDependencies: - - supports-color '@img/colour@1.0.0': optional: true - '@img/sharp-darwin-arm64@0.34.4': + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.34.4': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.2.3': + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.2.3': + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.2.3': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.2.3': + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.3': + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true - '@img/sharp-libvips-linux-s390x@1.2.3': + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.2.3': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.3': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-linux-arm64@0.34.4': + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.34.4': + '@img/sharp-linux-arm@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-ppc64@0.34.4': + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true - '@img/sharp-linux-s390x@0.34.4': + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true - '@img/sharp-linux-x64@0.34.4': + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true - '@img/sharp-linuxmusl-arm64@0.34.4': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.4 optional: true - '@img/sharp-linuxmusl-x64@0.34.4': + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true - '@img/sharp-wasm32@0.34.4': - dependencies: - '@emnapi/runtime': 1.7.0 + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true - '@img/sharp-win32-arm64@0.34.4': + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.7.1 optional: true - '@img/sharp-win32-ia32@0.34.4': + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-x64@0.34.4': + '@img/sharp-win32-ia32@0.34.5': optional: true - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + '@img/sharp-win32-x64@0.34.5': + optional: true '@isomorphic-git/idb-keyval@3.3.2': {} @@ -5976,7 +5893,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.14.2 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} @@ -5984,7 +5901,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -5997,14 +5914,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.24) + jest-config: 29.7.0(@types/node@20.19.25) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -6029,7 +5946,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -6047,7 +5964,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -6069,7 +5986,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -6139,8 +6056,8 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.24 - '@types/yargs': 17.0.34 + '@types/node': 20.19.25 + '@types/yargs': 17.0.35 chalk: 4.1.2 '@jridgewell/gen-mapping@0.3.13': @@ -6172,62 +6089,62 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@lezer/common@1.3.0': {} + '@lezer/common@1.4.0': {} '@lezer/css@1.3.0': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/highlight@1.2.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/html@1.3.12': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/javascript@1.5.4': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/json@1.0.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 - '@lezer/lr@1.4.3': + '@lezer/lr@1.4.4': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/markdown@1.6.0': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 '@lezer/python@1.1.18': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/xml@1.0.6': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@lezer/yaml@1.0.3': dependencies: - '@lezer/common': 1.3.0 + '@lezer/common': 1.4.0 '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 + '@lezer/lr': 1.4.4 '@marijn/find-cluster-break@1.0.2': {} @@ -6235,52 +6152,52 @@ snapshots: dependencies: langium: 3.3.1 - '@monaco-editor/loader@1.6.1': + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0(monaco-editor@0.53.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@monaco-editor/react@4.7.0(monaco-editor@0.53.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@monaco-editor/loader': 1.6.1 + '@monaco-editor/loader': 1.7.0 monaco-editor: 0.53.0 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.7.0 - '@emnapi/runtime': 1.7.0 + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.0.1': {} + '@next/env@16.0.7': {} '@next/eslint-plugin-next@15.3.4': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@16.0.1': + '@next/swc-darwin-arm64@16.0.7': optional: true - '@next/swc-darwin-x64@16.0.1': + '@next/swc-darwin-x64@16.0.7': optional: true - '@next/swc-linux-arm64-gnu@16.0.1': + '@next/swc-linux-arm64-gnu@16.0.7': optional: true - '@next/swc-linux-arm64-musl@16.0.1': + '@next/swc-linux-arm64-musl@16.0.7': optional: true - '@next/swc-linux-x64-gnu@16.0.1': + '@next/swc-linux-x64-gnu@16.0.7': optional: true - '@next/swc-linux-x64-musl@16.0.1': + '@next/swc-linux-x64-musl@16.0.7': optional: true - '@next/swc-win32-arm64-msvc@16.0.1': + '@next/swc-win32-arm64-msvc@16.0.7': optional: true - '@next/swc-win32-x64-msvc@16.0.1': + '@next/swc-win32-x64-msvc@16.0.7': optional: true '@nodelib/fs.scandir@2.1.5': @@ -6297,9 +6214,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@react-dnd/asap@5.0.2': {} '@react-dnd/invariant@4.0.2': {} @@ -6308,7 +6222,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/eslint-patch@1.14.1': {} + '@rushstack/eslint-patch@1.15.0': {} '@sinclair/typebox@0.27.8': {} @@ -6324,7 +6238,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.1.16': + '@tailwindcss/node@4.1.17': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.18.3 @@ -6332,66 +6246,66 @@ snapshots: lightningcss: 1.30.2 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.16 + tailwindcss: 4.1.17 - '@tailwindcss/oxide-android-arm64@4.1.16': + '@tailwindcss/oxide-android-arm64@4.1.17': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.16': + '@tailwindcss/oxide-darwin-arm64@4.1.17': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.16': + '@tailwindcss/oxide-darwin-x64@4.1.17': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.16': + '@tailwindcss/oxide-freebsd-x64@4.1.17': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.16': + '@tailwindcss/oxide-linux-x64-musl@4.1.17': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.16': + '@tailwindcss/oxide-wasm32-wasi@4.1.17': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': optional: true - '@tailwindcss/oxide@4.1.16': + '@tailwindcss/oxide@4.1.17': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.16 - '@tailwindcss/oxide-darwin-arm64': 4.1.16 - '@tailwindcss/oxide-darwin-x64': 4.1.16 - '@tailwindcss/oxide-freebsd-x64': 4.1.16 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 - '@tailwindcss/oxide-linux-x64-musl': 4.1.16 - '@tailwindcss/oxide-wasm32-wasi': 4.1.16 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 - - '@tailwindcss/postcss@4.1.16': + '@tailwindcss/oxide-android-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-x64': 4.1.17 + '@tailwindcss/oxide-freebsd-x64': 4.1.17 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-x64-musl': 4.1.17 + '@tailwindcss/oxide-wasm32-wasi': 4.1.17 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + + '@tailwindcss/postcss@4.1.17': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.16 - '@tailwindcss/oxide': 4.1.16 + '@tailwindcss/node': 4.1.17 + '@tailwindcss/oxide': 4.1.17 postcss: 8.5.6 - tailwindcss: 4.1.16 + tailwindcss: 4.1.17 '@tootallnate/once@2.0.0': {} @@ -6561,7 +6475,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.19.24 + '@types/node': 20.19.25 '@types/hast@3.0.4': dependencies: @@ -6588,7 +6502,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 20.19.24 + '@types/node': 20.19.25 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -6606,7 +6520,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.24': + '@types/node@20.19.25': dependencies: undici-types: 6.21.0 @@ -6619,23 +6533,23 @@ snapshots: '@types/raf@3.4.3': optional: true - '@types/react-dom@19.2.2(@types/react@19.2.2)': + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.7 '@types/react-syntax-highlighter@15.5.13': dependencies: - '@types/react': 19.2.2 + '@types/react': 19.2.7 - '@types/react@19.2.2': + '@types/react@19.2.7': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/stack-utils@2.0.3': {} '@types/tar-stream@3.1.4': dependencies: - '@types/node': 20.19.24 + '@types/node': 20.19.25 '@types/tough-cookie@4.0.5': {} @@ -6650,18 +6564,18 @@ snapshots: '@types/yargs-parser@21.0.3': {} - '@types/yargs@17.0.34': + '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.3 + '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.48.1 + '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.48.1 eslint: 9.39.1(jiti@1.21.7) graphemer: 1.4.0 ignore: 7.0.5 @@ -6684,22 +6598,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.3 + '@typescript-eslint/scope-manager': 8.48.1 + '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.48.1 debug: 4.4.3 eslint: 9.39.1(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.3(typescript@5.9.3)': + '@typescript-eslint/project-service@8.48.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) - '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3) + '@typescript-eslint/types': 8.48.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -6710,20 +6624,20 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - '@typescript-eslint/scope-manager@8.46.3': + '@typescript-eslint/scope-manager@8.48.1': dependencies: - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/visitor-keys': 8.46.3 + '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/visitor-keys': 8.48.1 - '@typescript-eslint/tsconfig-utils@8.46.3(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.48.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.1(jiti@1.21.7) ts-api-utils: 2.1.0(typescript@5.9.3) @@ -6733,7 +6647,7 @@ snapshots: '@typescript-eslint/types@6.21.0': {} - '@typescript-eslint/types@8.46.3': {} + '@typescript-eslint/types@8.48.1': {} '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': dependencies: @@ -6750,28 +6664,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.3(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.48.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.46.3(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/visitor-keys': 8.46.3 + '@typescript-eslint/project-service': 8.48.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3) + '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/visitor-keys': 8.48.1 debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.3 + tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.46.3 - '@typescript-eslint/types': 8.46.3 - '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.48.1 + '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -6782,32 +6695,32 @@ snapshots: '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.46.3': + '@typescript-eslint/visitor-keys@8.48.1': dependencies: - '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/types': 8.48.1 eslint-visitor-keys: 4.2.1 - '@uiw/codemirror-extensions-basic-setup@4.25.3(@codemirror/autocomplete@6.19.1)(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.6)': + '@uiw/codemirror-extensions-basic-setup@4.25.3(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8)': dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.0 '@codemirror/language': 6.11.3 '@codemirror/lint': 6.9.2 '@codemirror/search': 6.5.11 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 + '@codemirror/view': 6.38.8 - '@uiw/react-codemirror@4.25.3(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.19.1)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.6)(codemirror@6.0.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@uiw/react-codemirror@4.25.3(@babel/runtime@7.28.4)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.38.8)(codemirror@6.0.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.28.4 '@codemirror/commands': 6.10.0 '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.3 - '@codemirror/view': 6.38.6 - '@uiw/codemirror-extensions-basic-setup': 4.25.3(@codemirror/autocomplete@6.19.1)(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.6) + '@codemirror/view': 6.38.8 + '@uiw/codemirror-extensions-basic-setup': 4.25.3(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.9.2)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.38.8) codemirror: 6.0.2 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) transitivePeerDependencies: - '@codemirror/autocomplete' - '@codemirror/language' @@ -6875,37 +6788,37 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vue/compiler-core@3.5.23': + '@vue/compiler-core@3.5.25': dependencies: '@babel/parser': 7.28.5 - '@vue/shared': 3.5.23 + '@vue/shared': 3.5.25 entities: 4.5.0 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.23': + '@vue/compiler-dom@3.5.25': dependencies: - '@vue/compiler-core': 3.5.23 - '@vue/shared': 3.5.23 + '@vue/compiler-core': 3.5.25 + '@vue/shared': 3.5.25 - '@vue/compiler-sfc@3.5.23': + '@vue/compiler-sfc@3.5.25': dependencies: '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.23 - '@vue/compiler-dom': 3.5.23 - '@vue/compiler-ssr': 3.5.23 - '@vue/shared': 3.5.23 + '@vue/compiler-core': 3.5.25 + '@vue/compiler-dom': 3.5.25 + '@vue/compiler-ssr': 3.5.25 + '@vue/shared': 3.5.25 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.23': + '@vue/compiler-ssr@3.5.25': dependencies: - '@vue/compiler-dom': 3.5.23 - '@vue/shared': 3.5.23 + '@vue/compiler-dom': 3.5.25 + '@vue/shared': 3.5.25 - '@vue/shared@3.5.23': {} + '@vue/shared@3.5.25': {} '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: @@ -6963,16 +6876,12 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} - ansi-styles@6.2.3: {} - any-promise@1.3.0: {} anymatch@3.1.3: @@ -7077,11 +6986,11 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.21(postcss@8.5.6): + autoprefixer@10.4.22(postcss@8.5.6): dependencies: - browserslist: 4.27.0 - caniuse-lite: 1.0.30001753 - fraction.js: 4.3.7 + browserslist: 4.28.1 + caniuse-lite: 1.0.30001759 + fraction.js: 5.3.4 normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.5.6 @@ -7156,13 +7065,13 @@ snapshots: balanced-match@1.0.2: {} - bare-events@2.8.1: {} + bare-events@2.8.2: {} base64-arraybuffer@1.0.2: {} base64-js@1.5.1: {} - baseline-browser-mapping@2.8.25: {} + baseline-browser-mapping@2.9.3: {} binary-extensions@2.3.0: {} @@ -7242,13 +7151,13 @@ snapshots: readable-stream: 2.3.8 safe-buffer: 5.2.1 - browserslist@4.27.0: + browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.8.25 - caniuse-lite: 1.0.30001753 - electron-to-chromium: 1.5.245 + baseline-browser-mapping: 2.9.3 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.266 node-releases: 2.0.27 - update-browserslist-db: 1.1.4(browserslist@4.27.0) + update-browserslist-db: 1.2.2(browserslist@4.28.1) bs-logger@0.2.6: dependencies: @@ -7299,13 +7208,13 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001753: {} + caniuse-lite@1.0.30001759: {} canvg@3.0.11: dependencies: '@babel/runtime': 7.28.4 '@types/raf': 3.4.3 - core-js: 3.46.0 + core-js: 3.47.0 raf: 3.4.1 regenerator-runtime: 0.13.11 rgbcolor: 1.0.1 @@ -7410,13 +7319,13 @@ snapshots: codemirror@6.0.2: dependencies: - '@codemirror/autocomplete': 6.19.1 + '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.0 '@codemirror/language': 6.11.3 '@codemirror/lint': 6.9.2 '@codemirror/search': 6.5.11 '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 + '@codemirror/view': 6.38.8 collect-v8-coverage@1.0.3: {} @@ -7444,11 +7353,9 @@ snapshots: confbox@0.1.8: {} - confbox@0.2.2: {} - convert-source-map@2.0.0: {} - core-js@3.46.0: + core-js@3.47.0: optional: true core-util-is@1.0.3: {} @@ -7472,7 +7379,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -7502,13 +7409,13 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.12 - create-jest@29.7.0(@types/node@20.19.24): + create-jest@29.7.0(@types/node@20.19.25): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.24) + jest-config: 29.7.0(@types/node@20.19.25) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -7554,7 +7461,7 @@ snapshots: dependencies: cssom: 0.3.8 - csstype@3.1.3: {} + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): dependencies: @@ -7818,7 +7725,7 @@ snapshots: dependencies: '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 - '@vue/compiler-sfc': 3.5.23 + '@vue/compiler-sfc': 3.5.25 callsite: 1.0.0 camelcase: 6.3.0 cosmiconfig: 7.1.0 @@ -7827,7 +7734,7 @@ snapshots: findup-sync: 5.0.0 ignore: 5.3.2 is-core-module: 2.16.1 - js-yaml: 3.14.1 + js-yaml: 3.14.2 json5: 2.2.3 lodash: 4.17.21 minimatch: 7.4.6 @@ -7905,9 +7812,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - - electron-to-chromium@1.5.245: {} + electron-to-chromium@1.5.266: {} elliptic@6.6.1: dependencies: @@ -8100,13 +8005,13 @@ snapshots: eslint-config-next@15.3.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 15.3.4 - '@rushstack/eslint-patch': 1.14.1 - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@rushstack/eslint-patch': 1.15.0 + '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@1.21.7)) @@ -8136,22 +8041,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8162,7 +8067,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -8174,7 +8079,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -8225,11 +8130,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)): dependencies: eslint: 9.39.1(jiti@1.21.7) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -8247,7 +8152,7 @@ snapshots: '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 + '@eslint/eslintrc': 3.3.3 '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -8309,7 +8214,7 @@ snapshots: events-universal@1.0.1: dependencies: - bare-events: 2.8.1 + bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller @@ -8346,8 +8251,6 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 - exsolve@1.0.7: {} - extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -8454,12 +8357,7 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.4: + form-data@4.0.5: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -8467,7 +8365,7 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - fraction.js@4.3.7: {} + fraction.js@5.3.4: {} fs.realpath@1.0.0: {} @@ -8533,15 +8431,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.5: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -8567,8 +8456,6 @@ snapshots: globals@14.0.0: {} - globals@15.15.0: {} - globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -8677,9 +8564,9 @@ snapshots: '@types/unist': 3.0.3 '@ungap/structured-clone': 1.3.0 hast-util-from-parse5: 8.0.3 - hast-util-to-parse5: 8.0.0 + hast-util-to-parse5: 8.0.1 html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 parse5: 7.3.0 unist-util-position: 5.0.0 unist-util-visit: 5.0.0 @@ -8707,18 +8594,18 @@ snapshots: mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - style-to-js: 1.1.19 + style-to-js: 1.1.21 unist-util-position: 5.0.0 vfile-message: 4.0.3 transitivePeerDependencies: - supports-color - hast-util-to-parse5@8.0.0: + hast-util-to-parse5@8.0.1: dependencies: '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 devlop: 1.1.0 - property-information: 6.5.0 + property-information: 7.1.0 space-separated-tokens: 2.0.2 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -8776,7 +8663,7 @@ snapshots: html2pdf.js@0.12.1: dependencies: html2canvas: 1.4.1 - jspdf: 3.0.3 + jspdf: 3.0.4 http-proxy-agent@5.0.0: dependencies: @@ -8828,7 +8715,7 @@ snapshots: ini@1.3.8: {} - inline-style-parser@0.2.6: {} + inline-style-parser@0.2.7: {} internal-slot@1.1.0: dependencies: @@ -8919,8 +8806,6 @@ snapshots: has-tostringtag: 1.0.2 safe-regex-test: 1.1.0 - is-git-ref-name-valid@1.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -8995,19 +8880,17 @@ snapshots: isexe@2.0.0: {} - isomorphic-git@1.34.2: + isomorphic-git@1.35.1: dependencies: async-lock: 1.4.1 clean-git-ref: 2.0.1 crc-32: 1.2.2 diff3: 0.0.3 ignore: 5.3.2 - is-git-ref-name-valid: 1.0.0 minimisted: 2.0.1 pako: 1.0.11 - path-browserify: 1.0.1 pify: 4.0.1 - readable-stream: 3.6.2 + readable-stream: 4.7.0 sha.js: 2.4.12 simple-get: 4.0.1 @@ -9065,12 +8948,6 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -9083,7 +8960,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.0 @@ -9103,16 +8980,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.24): + jest-cli@29.7.0(@types/node@20.19.25): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.24) + create-jest: 29.7.0(@types/node@20.19.25) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.24) + jest-config: 29.7.0(@types/node@20.19.25) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -9122,7 +8999,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.24): + jest-config@29.7.0(@types/node@20.19.25): dependencies: '@babel/core': 7.28.5 '@jest/test-sequencer': 29.7.0 @@ -9147,7 +9024,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.19.24 + '@types/node': 20.19.25 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -9177,7 +9054,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -9191,7 +9068,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -9201,7 +9078,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.24 + '@types/node': 20.19.25 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -9240,7 +9117,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -9275,7 +9152,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -9303,7 +9180,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -9349,7 +9226,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -9368,7 +9245,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.24 + '@types/node': 20.19.25 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -9377,17 +9254,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.19.24 + '@types/node': 20.19.25 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.6.4(@types/node@20.19.24): + jest@29.6.4(@types/node@20.19.25): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.24) + jest-cli: 29.7.0(@types/node@20.19.25) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -9400,12 +9277,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.1: + js-yaml@3.14.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -9420,12 +9297,12 @@ snapshots: decimal.js: 10.6.0 domexception: 4.0.0 escodegen: 2.1.0 - form-data: 4.0.4 + form-data: 4.0.5 html-encoding-sniffer: 3.0.0 http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 + nwsapi: 2.2.23 parse5: 7.3.0 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -9458,14 +9335,14 @@ snapshots: json5@2.2.3: {} - jspdf@3.0.3: + jspdf@3.0.4: dependencies: '@babel/runtime': 7.28.4 fast-png: 6.4.0 fflate: 0.8.2 optionalDependencies: canvg: 3.0.11 - core-js: 3.46.0 + core-js: 3.47.0 dompurify: 3.3.0 html2canvas: 1.4.1 @@ -9499,8 +9376,6 @@ snapshots: kleur@3.0.3: {} - kolorist@1.8.0: {} - langium@3.3.1: dependencies: chevrotain: 11.0.3 @@ -9585,12 +9460,6 @@ snapshots: lines-and-columns@1.2.4: {} - local-pkg@1.1.2: - dependencies: - mlly: 1.8.0 - pkg-types: 2.3.0 - quansync: 0.2.11 - locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9618,15 +9487,13 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@10.4.3: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lucide-react@0.546.0(react@19.2.0): + lucide-react@0.546.0(react@19.2.1): dependencies: - react: 19.2.0 + react: 19.2.1 magic-string@0.30.21: dependencies: @@ -9644,7 +9511,7 @@ snapshots: markdown-table@3.0.4: {} - marked@16.4.1: {} + marked@16.4.2: {} math-intrinsics@1.1.0: {} @@ -9796,7 +9663,7 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -9828,10 +9695,10 @@ snapshots: merge2@1.4.1: {} - mermaid@11.12.1: + mermaid@11.12.2: dependencies: '@braintree/sanitize-url': 7.1.1 - '@iconify/utils': 3.0.2 + '@iconify/utils': 3.1.0 '@mermaid-js/parser': 0.6.3 '@types/d3': 7.4.3 cytoscape: 3.33.1 @@ -9845,13 +9712,11 @@ snapshots: katex: 0.16.25 khroma: 2.1.0 lodash-es: 4.17.21 - marked: 16.4.1 + marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 ts-dedent: 2.2.0 uuid: 11.1.0 - transitivePeerDependencies: - - supports-color micromark-core-commonmark@2.0.3: dependencies: @@ -10100,8 +9965,6 @@ snapshots: dependencies: minimist: 1.2.8 - minipass@7.1.2: {} - mkdirp@1.0.4: {} mlly@1.8.0: @@ -10137,25 +10000,25 @@ snapshots: natural-compare@1.4.0: {} - next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.7(@babel/core@7.28.5)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: - '@next/env': 16.0.1 + '@next/env': 16.0.7 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001753 + caniuse-lite: 1.0.30001759 postcss: 8.4.31 - react: 19.2.0 - react-dom: 19.2.0(react@19.2.0) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.0.1 - '@next/swc-darwin-x64': 16.0.1 - '@next/swc-linux-arm64-gnu': 16.0.1 - '@next/swc-linux-arm64-musl': 16.0.1 - '@next/swc-linux-x64-gnu': 16.0.1 - '@next/swc-linux-x64-musl': 16.0.1 - '@next/swc-win32-arm64-msvc': 16.0.1 - '@next/swc-win32-x64-msvc': 16.0.1 - sharp: 0.34.4 + '@next/swc-darwin-arm64': 16.0.7 + '@next/swc-darwin-x64': 16.0.7 + '@next/swc-linux-arm64-gnu': 16.0.7 + '@next/swc-linux-arm64-musl': 16.0.7 + '@next/swc-linux-x64-gnu': 16.0.7 + '@next/swc-linux-x64-musl': 16.0.7 + '@next/swc-win32-arm64-msvc': 16.0.7 + '@next/swc-win32-x64-msvc': 16.0.7 + sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -10181,7 +10044,7 @@ snapshots: dependencies: path-key: 3.1.1 - nwsapi@2.2.22: {} + nwsapi@2.2.23: {} object-assign@4.1.1: {} @@ -10282,9 +10145,7 @@ snapshots: p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - - package-manager-detector@1.5.0: {} + package-manager-detector@1.6.0: {} pako@1.0.11: {} @@ -10337,11 +10198,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - path-type@4.0.0: {} pathe@2.0.3: {} @@ -10384,12 +10240,6 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - pkg-types@2.3.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.7 - pathe: 2.0.3 - please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 @@ -10415,13 +10265,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.1): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 - yaml: 2.8.1 + yaml: 2.8.2 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -10449,7 +10299,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.6.2: {} + prettier@3.7.4: {} pretty-format@29.7.0: dependencies: @@ -10472,8 +10322,6 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 - property-information@6.5.0: {} - property-information@7.1.0: {} psl@1.15.0: @@ -10501,8 +10349,6 @@ snapshots: - bufferutil - utf-8-validate - quansync@0.2.11: {} - querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -10525,37 +10371,42 @@ snapshots: dependencies: dnd-core: 16.0.1 - react-dnd@16.0.1(@types/node@20.19.24)(@types/react@19.2.2)(react@19.2.0): + react-dnd-touch-backend@16.0.1: + dependencies: + '@react-dnd/invariant': 4.0.2 + dnd-core: 16.0.1 + + react-dnd@16.0.1(@types/node@20.19.25)(@types/react@19.2.7)(react@19.2.1): dependencies: '@react-dnd/invariant': 4.0.2 '@react-dnd/shallowequal': 4.0.2 dnd-core: 16.0.1 fast-deep-equal: 3.1.3 hoist-non-react-statics: 3.3.2 - react: 19.2.0 + react: 19.2.1 optionalDependencies: - '@types/node': 20.19.24 - '@types/react': 19.2.2 + '@types/node': 20.19.25 + '@types/react': 19.2.7 - react-dom@19.2.0(react@19.2.0): + react-dom@19.2.1(react@19.2.1): dependencies: - react: 19.2.0 + react: 19.2.1 scheduler: 0.27.0 react-is@16.13.1: {} react-is@18.3.1: {} - react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0): + react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.1): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.2 + '@types/react': 19.2.7 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 - react: 19.2.0 + mdast-util-to-hast: 13.2.1 + react: 19.2.1 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -10564,7 +10415,7 @@ snapshots: transitivePeerDependencies: - supports-color - react@19.2.0: {} + react@19.2.1: {} read-cache@1.0.0: dependencies: @@ -10698,7 +10549,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 @@ -10846,34 +10697,36 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 - sharp@0.34.4: + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 semver: 7.7.3 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.4 - '@img/sharp-darwin-x64': 0.34.4 - '@img/sharp-libvips-darwin-arm64': 1.2.3 - '@img/sharp-libvips-darwin-x64': 1.2.3 - '@img/sharp-libvips-linux-arm': 1.2.3 - '@img/sharp-libvips-linux-arm64': 1.2.3 - '@img/sharp-libvips-linux-ppc64': 1.2.3 - '@img/sharp-libvips-linux-s390x': 1.2.3 - '@img/sharp-libvips-linux-x64': 1.2.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 - '@img/sharp-libvips-linuxmusl-x64': 1.2.3 - '@img/sharp-linux-arm': 0.34.4 - '@img/sharp-linux-arm64': 0.34.4 - '@img/sharp-linux-ppc64': 0.34.4 - '@img/sharp-linux-s390x': 0.34.4 - '@img/sharp-linux-x64': 0.34.4 - '@img/sharp-linuxmusl-arm64': 0.34.4 - '@img/sharp-linuxmusl-x64': 0.34.4 - '@img/sharp-wasm32': 0.34.4 - '@img/sharp-win32-arm64': 0.34.4 - '@img/sharp-win32-ia32': 0.34.4 - '@img/sharp-win32-x64': 0.34.4 + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 optional: true shebang-command@2.0.0: @@ -10912,8 +10765,6 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - simple-concat@1.0.1: {} simple-get@4.0.1: @@ -10999,12 +10850,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -11072,10 +10917,6 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -11086,31 +10927,31 @@ snapshots: style-mod@4.1.3: {} - style-to-js@1.1.19: + style-to-js@1.1.21: dependencies: - style-to-object: 1.0.12 + style-to-object: 1.0.14 - style-to-object@1.0.12: + style-to-object@1.0.14: dependencies: - inline-style-parser: 0.2.6 + inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.1): dependencies: client-only: 0.0.1 - react: 19.2.0 + react: 19.2.1 optionalDependencies: '@babel/core': 7.28.5 stylis@4.3.6: {} - sucrase@3.35.0: + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 10.4.5 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 + tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 supports-color@7.2.0: @@ -11128,7 +10969,7 @@ snapshots: symbol-tree@3.2.4: {} - tailwindcss@3.4.18(yaml@2.8.1): + tailwindcss@3.4.18(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -11147,16 +10988,16 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.1) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 - sucrase: 3.35.0 + sucrase: 3.35.1 transitivePeerDependencies: - tsx - yaml - tailwindcss@4.1.16: {} + tailwindcss@4.1.17: {} tapable@2.3.0: {} @@ -11243,11 +11084,11 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.1.0(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.6.4(@types/node@20.19.24))(typescript@5.9.3): + ts-jest@29.1.0(@babel/core@7.28.5)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest@29.6.4(@types/node@20.19.25))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.6.4(@types/node@20.19.24) + jest: 29.6.4(@types/node@20.19.25) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -11436,9 +11277,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.1.4(browserslist@4.27.0): + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: - browserslist: 4.27.0 + browserslist: 4.28.1 escalade: 3.2.0 picocolors: 1.1.1 @@ -11600,12 +11441,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - wrappy@1.0.2: {} write-file-atomic@4.0.2: @@ -11625,7 +11460,7 @@ snapshots: yaml@1.10.2: {} - yaml@2.8.1: {} + yaml@2.8.2: {} yargs-parser@20.2.9: {} @@ -11653,9 +11488,9 @@ snapshots: yocto-queue@0.1.0: {} - zustand@5.0.8(@types/react@19.2.2)(react@19.2.0): + zustand@5.0.9(@types/react@19.2.7)(react@19.2.1): optionalDependencies: - '@types/react': 19.2.2 - react: 19.2.0 + '@types/react': 19.2.7 + react: 19.2.1 zwitch@2.0.4: {} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index defadca6..2d1971e6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import './globals.css'; import ExtensionInitializer from '@/components/ExtensionInitializer'; @@ -10,6 +10,11 @@ import { I18nProvider } from '@/context/I18nContext'; import { TabProvider } from '@/context/TabContext'; import { ThemeProvider } from '@/context/ThemeContext'; +export const viewport: Viewport = { + width: 'device-width', + initialScale: 0.9, +}; + export const metadata: Metadata = { title: 'Pyxis - clientIDE Terminal', description: diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b864e0d..de2360fb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,19 @@ 'use client'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; +import { TouchBackend } from 'react-dnd-touch-backend'; import { useTheme } from '../context/ThemeContext'; import BottomPanel from '@/components/Bottom/BottomPanel'; import BottomStatusBar from '@/components/BottomStatusBar'; +import CustomDragLayer from '@/components/DnD/CustomDragLayer'; import LeftSidebar from '@/components/Left/LeftSidebar'; import MenuBar from '@/components/MenuBar'; import OperationWindow from '@/components/OperationWindow'; import PaneContainer from '@/components/PaneContainer'; +import PaneNavigator from '@/components/PaneNavigator'; import ProjectModal from '@/components/ProjectModal'; import RightSidebar from '@/components/Right/RightSidebar'; import TopBar from '@/components/TopBar'; @@ -28,9 +30,11 @@ import { useProjectWelcome } from '@/hooks/useProjectWelcome'; import { useTabContentRestore } from '@/hooks/useTabContentRestore'; import { sessionStorage } from '@/stores/sessionStorage'; import { useOptimizedUIStateSave } from '@/hooks/useOptimizedUIStateSave'; +import { useProjectStore } from '@/stores/projectStore'; import { useTabStore } from '@/stores/tabStore'; import { Project } from '@/types'; import type { MenuTab } from '@/types'; +import type { EditorPane } from '@/engine/tabs/types'; /** * Home: 新アーキテクチャのメインページ @@ -47,6 +51,7 @@ export default function Home() { const [isLeftSidebarVisible, setIsLeftSidebarVisible] = useState(true); const [isBottomPanelVisible, setIsBottomPanelVisible] = useState(true); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); + const [isPaneNavigatorOpen, setIsPaneNavigatorOpen] = useState(false); const [gitRefreshTrigger, setGitRefreshTrigger] = useState(0); const [gitChangesCount, setGitChangesCount] = useState(0); const [nodeRuntimeOperationInProgress] = useState(false); @@ -59,6 +64,12 @@ export default function Home() { isContentRestored, openTab, setPanes, + activePane, + setActivePane, + splitPane, + removePane, + moveTab, + activateTab, } = useTabStore(); const { isOpen: isOperationWindowVisible, @@ -66,9 +77,34 @@ export default function Home() { closeFileSelector, } = useFileSelector(); + // Helper function to flatten panes + const flattenPanes = useCallback((paneList: EditorPane[]): EditorPane[] => { + const result: EditorPane[] = []; + const traverse = (list: EditorPane[]) => { + for (const pane of list) { + if (!pane.children || pane.children.length === 0) { + result.push(pane); + } + if (pane.children) { + traverse(pane.children); + } + } + }; + traverse(paneList); + return result; + }, []); + // プロジェクト管理 const { currentProject, projectFiles, loadProject, createProject, refreshProjectFiles } = useProject(); + + // グローバルプロジェクトストアを同期 + // NOTE: useProject()は各コンポーネントで独立したステートを持つため、 + // ここでグローバルストアに同期することで、全コンポーネントが一貫したプロジェクト情報にアクセスできる + const setCurrentProjectToStore = useProjectStore(state => state.setCurrentProject); + useEffect(() => { + setCurrentProjectToStore(currentProject); + }, [currentProject, setCurrentProjectToStore]); // タブコンテンツの復元と自動更新 useTabContentRestore(projectFiles, isRestored); @@ -167,10 +203,10 @@ export default function Home() { if (isOperationWindowVisible) { closeFileSelector(); } else { - // QuickOpenの場合はpaneIdなし(アクティブなペインを使用) - const activePaneId = panes.find(p => p.activeTabId)?.id || panes[0]?.id; - if (activePaneId) { - openFileSelector(activePaneId); + // アクティブなペインを使用(tabStoreのactivePaneを優先) + const targetPaneId = activePane || panes.find(p => p.activeTabId)?.id || panes[0]?.id; + if (targetPaneId) { + openFileSelector(targetPaneId); } } }; @@ -194,7 +230,7 @@ export default function Home() { }; // ショートカットキーの登録 - useKeyBinding('quickOpen', toggleOperationWindow, [panes]); + useKeyBinding('quickOpen', toggleOperationWindow, [panes, activePane]); useKeyBinding('toggleLeftSidebar', () => setIsLeftSidebarVisible(prev => !prev), []); useKeyBinding('toggleRightSidebar', () => setIsRightSidebarVisible(prev => !prev), []); useKeyBinding('toggleBottomPanel', () => setIsBottomPanelVisible(prev => !prev), []); @@ -243,8 +279,89 @@ export default function Home() { [] ); + // Pane management shortcuts + useKeyBinding('openPaneNavigator', () => setIsPaneNavigatorOpen(true), []); + + useKeyBinding('splitPaneVertical', () => { + const flatPanes = flattenPanes(panes); + const currentPane = flatPanes.find(p => p.id === activePane) || flatPanes[0]; + if (currentPane) { + splitPane(currentPane.id, 'vertical'); + } + }, [panes, activePane, flattenPanes, splitPane]); + + useKeyBinding('splitPaneHorizontal', () => { + const flatPanes = flattenPanes(panes); + const currentPane = flatPanes.find(p => p.id === activePane) || flatPanes[0]; + if (currentPane) { + splitPane(currentPane.id, 'horizontal'); + } + }, [panes, activePane, flattenPanes, splitPane]); + + useKeyBinding('closePane', () => { + const flatPanes = flattenPanes(panes); + if (flatPanes.length <= 1) return; // Don't close the last pane + const currentPane = flatPanes.find(p => p.id === activePane) || flatPanes[0]; + if (currentPane) { + removePane(currentPane.id); + // Focus the first remaining pane + const remaining = flatPanes.filter(p => p.id !== currentPane.id); + if (remaining.length > 0) { + setActivePane(remaining[0].id); + if (remaining[0].activeTabId) { + activateTab(remaining[0].id, remaining[0].activeTabId); + } + } + } + }, [panes, activePane, flattenPanes, removePane, setActivePane, activateTab]); + + useKeyBinding('focusNextPane', () => { + const flatPanes = flattenPanes(panes); + if (flatPanes.length <= 1) return; + const currentIndex = flatPanes.findIndex(p => p.id === activePane); + const nextIndex = (currentIndex + 1) % flatPanes.length; + const nextPane = flatPanes[nextIndex]; + setActivePane(nextPane.id); + if (nextPane.activeTabId) { + activateTab(nextPane.id, nextPane.activeTabId); + } + }, [panes, activePane, flattenPanes, setActivePane, activateTab]); + + useKeyBinding('focusPrevPane', () => { + const flatPanes = flattenPanes(panes); + if (flatPanes.length <= 1) return; + const currentIndex = flatPanes.findIndex(p => p.id === activePane); + const prevIndex = (currentIndex - 1 + flatPanes.length) % flatPanes.length; + const prevPane = flatPanes[prevIndex]; + setActivePane(prevPane.id); + if (prevPane.activeTabId) { + activateTab(prevPane.id, prevPane.activeTabId); + } + }, [panes, activePane, flattenPanes, setActivePane, activateTab]); + + useKeyBinding('moveTabToNextPane', () => { + const flatPanes = flattenPanes(panes); + if (flatPanes.length <= 1) return; + const currentPane = flatPanes.find(p => p.id === activePane); + if (!currentPane || !currentPane.activeTabId) return; + + const currentIndex = flatPanes.findIndex(p => p.id === activePane); + const nextIndex = (currentIndex + 1) % flatPanes.length; + const nextPane = flatPanes[nextIndex]; + + moveTab(currentPane.id, nextPane.id, currentPane.activeTabId); + }, [panes, activePane, flattenPanes, moveTab]); + + // TouchBackendオプション: enableMouseEventsでマウスとタッチ両方をサポート + // delayTouchStart: 長押し(200ms)でドラッグ開始 + const dndOptions = useMemo(() => ({ + enableMouseEvents: true, + delayTouchStart: 200, + }), []); + return ( - + +
+ + setIsPaneNavigatorOpen(false)} + />
(null); const spaceButtonRef = useRef(null); + // Revert confirmation state + const [revertConfirmation, setRevertConfirmation] = useState<{ + open: boolean; + message: ChatSpaceMessage | null; + }>({ open: false, message: null }); + // Editing state for spaces const [editingSpaceId, setEditingSpaceId] = useState(null); const [editingSpaceName, setEditingSpaceName] = useState(''); @@ -75,6 +82,7 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId updateSelectedFiles: updateSpaceSelectedFiles, updateSpaceName, updateChatMessage, + revertToMessage, } = useChatSpace(currentProject?.id || null); // AI機能 @@ -85,6 +93,8 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId sendMessage, updateFileContexts, toggleFileSelection, + generatePromptText, + streamingContent, } = useAI({ onAddMessage: async (content, type, mode, fileContext, editResponse) => { return await addSpaceMessage(content, type, mode, fileContext, editResponse); @@ -95,6 +105,10 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId projectId: currentProject?.id, }); + // Prompt debug modal state + const [showPromptDebug, setShowPromptDebug] = useState(false); + const [promptDebugText, setPromptDebugText] = useState(''); + // レビュー機能 const { openAIReviewTab, closeAIReviewTab } = useAIReview(); @@ -236,7 +250,6 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId if (!projectId) { console.error('[AIPanel] No projectId available, cannot apply changes'); - // TODO: alertの代わりにトースト通知を使用する alert('プロジェクトが選択されていません'); return; } @@ -254,7 +267,7 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId console.warn('[AIPanel] clearAIReview failed (non-critical):', e); } - // Remove this file from the assistant editResponse in the current chat space + // Mark this file as applied in the assistant editResponse (keep original content for revert) try { if (currentSpace && updateChatMessage) { const editMsg = currentSpace.messages @@ -263,7 +276,9 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId .find(m => m.type === 'assistant' && m.mode === 'edit' && m.editResponse); if (editMsg && editMsg.editResponse) { - const newChangedFiles = editMsg.editResponse.changedFiles.filter(f => f.path !== filePath); + const newChangedFiles = editMsg.editResponse.changedFiles.map(f => + f.path === filePath ? { ...f, applied: true } : f + ); const newEditResponse = { ...editMsg.editResponse, changedFiles: newChangedFiles }; await updateChatMessage(currentSpace.id, editMsg.id, { @@ -276,10 +291,6 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId console.warn('[AIPanel] Failed to update chat message after apply:', e); } - // NOTE: Do NOT manually close the review tab here. The caller (AIReviewTab) - // already handles closing when appropriate. Closing here caused timing races - // with editor debounced saves and resulted in overwrites on active tabs. - // Rely on fileRepository.emitChange -> useActiveTabContentRestore to update tabs. } catch (error) { console.error('[AIPanel] Failed to apply changes:', error); alert(`変更の適用に失敗しました: ${(error as Error).message}`); @@ -441,6 +452,23 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId + + {/* Debug button to show internal prompt */} + {/* OperationWindow-driven spaces list (opened when showSpaceList) */} @@ -471,82 +499,17 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId messages={messages} isProcessing={isProcessing} emptyMessage={mode === 'ask' ? t('AI.ask') : t('AI.edit')} + streamingContent={streamingContent} onRevert={async (message: ChatSpaceMessage) => { - const projectId = currentProject?.id; - try { - if (!projectId) return; - if (message.type !== 'assistant' || message.mode !== 'edit' || !message.editResponse) return; - - const { getAIReviewEntry, updateAIReviewEntry } = await import('@/engine/storage/aiStorageAdapter'); - - const files = message.editResponse.changedFiles || []; - for (const f of files) { - try { - const entry = await getAIReviewEntry(projectId, f.path); - if (entry && entry.originalSnapshot) { - // fileRepositoryを直接使用してファイルを保存 - await fileRepository.saveFileByPath(projectId, f.path, entry.originalSnapshot); - - // mark entry reverted and add history - const hist = Array.isArray(entry.history) ? entry.history : []; - const historyEntry = { id: `revert-${Date.now()}`, timestamp: new Date(), content: entry.originalSnapshot, note: `reverted via chat ${message.id}` }; - try { - await updateAIReviewEntry(projectId, f.path, { - status: 'reverted', - history: [historyEntry, ...hist], - }); - } catch (e) { - console.warn('[AIPanel] updateAIReviewEntry failed', e); - } - } - } catch (e) { - console.warn('[AIPanel] revert file failed for', f.path, e); - } - } - - // Append a chat message recording the revert as a branch from the original message - try { - // Update the original assistant edit message so the UI no longer - // shows the reverted files in its changedFiles list. - try { - if (currentSpace && updateChatMessage) { - const origEdit = message.editResponse; - const newChangedFiles = (origEdit.changedFiles || []).filter( - (cf: any) => !files.some((f: any) => f.path === cf.path) - ); - - const newEditResponse = { ...origEdit, changedFiles: newChangedFiles }; - - await updateChatMessage(currentSpace.id, message.id, { - editResponse: newEditResponse, - content: message.content, - }); - } - } catch (e) { - console.warn('[AIPanel] failed to update original assistant message after revert', e); - } - - await addSpaceMessage( - `Reverted changes from message ${message.id} for ${files.map((x: any) => x.path).join(', ')}`, - 'assistant', - 'edit', - [], - undefined, - { parentMessageId: message.id, action: 'revert' } as any - ); - } catch (e) { - console.warn('[AIPanel] failed to append revert message to chat', e); - } - } catch (e) { - console.error('[AIPanel] handleRevertMessage failed', e); - } + // Show confirmation dialog instead of executing immediately + setRevertConfirmation({ open: true, message }); }} /> {/* 変更ファイル一覧(Editモードで変更がある場合のみ表示) ここではパネルを最小化できるようにし、最小化中は ChangedFilesPanel 本体を描画しないことで 「採用」などのアクションボタン類を表示しないようにする */} - {mode === 'edit' && latestEditResponse && latestEditResponse.changedFiles.length > 0 && ( + {mode === 'edit' && latestEditResponse && latestEditResponse.changedFiles.filter(f => !f.applied).length > 0 && (
変更ファイル
-
{latestEditResponse.changedFiles.length} 個
+
{latestEditResponse.changedFiles.filter(f => !f.applied).length} 個
+
+
+
+                {promptDebugText}
+              
+
+
+ + +
+
+
+ )} + + {/* リバート確認ダイアログ */} + setRevertConfirmation({ open: false, message: null })} + onConfirm={async () => { + const message = revertConfirmation.message; + setRevertConfirmation({ open: false, message: null }); + + if (!message) return; + + const projectId = currentProject?.id; + try { + if (!projectId) return; + if (message.type !== 'assistant' || message.mode !== 'edit' || !message.editResponse) return; + + const { clearAIReviewEntry } = await import('@/engine/storage/aiStorageAdapter'); + + // 1. このメッセージ以降の全メッセージを削除(このメッセージ含む) + const deletedMessages = await revertToMessage(message.id); + + // 2. 削除されたメッセージの中から、editResponseを持つものを全て処理 + // editResponse内のoriginalContentを使ってファイルを復元 + // 逆順で処理することで、最新の変更から順に元に戻す + const reversedMessages = [...deletedMessages].reverse(); + + for (const deletedMsg of reversedMessages) { + if (deletedMsg.type === 'assistant' && deletedMsg.mode === 'edit' && deletedMsg.editResponse) { + const files = deletedMsg.editResponse.changedFiles || []; + // Only revert files that were applied (default to false if undefined) + const appliedFiles = files.filter(f => f.applied === true); + + for (const f of appliedFiles) { + try { + if (f.isNewFile) { + // This was a new file created by AI - delete it on revert + const fileToDelete = await fileRepository.getFileByPath(projectId, f.path); + if (fileToDelete) { + await fileRepository.deleteFile(fileToDelete.id); + console.log('[AIPanel] Deleted new file on revert:', f.path); + } + } else { + // Existing file - restore originalContent + await fileRepository.saveFileByPath(projectId, f.path, f.originalContent); + console.log('[AIPanel] Reverted file:', f.path); + } + + // Clear AI review entry + try { + await clearAIReviewEntry(projectId, f.path); + } catch (e) { + console.warn('[AIPanel] clearAIReviewEntry failed', e); + } + } catch (e) { + console.warn('[AIPanel] revert file failed for', f.path, e); + } + } + } + } + + console.log('[AIPanel] Reverted to before message:', message.id, 'deleted messages:', deletedMessages.length); + } catch (e) { + console.error('[AIPanel] handleRevertMessage failed', e); + } + }} + /> ); } diff --git a/src/components/AI/AIReview/AIReviewTab.tsx b/src/components/AI/AIReview/AIReviewTab.tsx index fb492448..74a05f8e 100644 --- a/src/components/AI/AIReview/AIReviewTab.tsx +++ b/src/components/AI/AIReview/AIReviewTab.tsx @@ -10,6 +10,7 @@ import type * as monacoEditor from 'monaco-editor'; import React, { useState, useRef, useEffect } from 'react'; import { getLanguage } from '@/components/Tab/text-editor/editors/editor-utils'; +import { defineAndSetMonacoThemes } from '@/components/Tab/text-editor/editors/monaco-themes'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import { calculateDiff } from '@/engine/ai/diffProcessor'; @@ -30,7 +31,7 @@ export default function AIReviewTab({ onUpdateSuggestedContent, onCloseTab, }: AIReviewTabProps) { - const { colors } = useTheme(); + const { colors, themeName } = useTheme(); const { t } = useTranslation(); console.log('[AIReviewTab] Rendering with tab:', tab); @@ -130,6 +131,13 @@ export default function AIReviewTab({ ) => { diffEditorRef.current = editor; + // テーマ定義と適用 + try { + defineAndSetMonacoThemes(monaco, colors, themeName); + } catch (e) { + console.warn('[AIReviewTab] Failed to define/set themes:', e); + } + // モデルを取得して保存 const diffModel = editor.getModel(); if (diffModel) { diff --git a/src/components/AI/ChangedFilesList.tsx b/src/components/AI/ChangedFilesList.tsx deleted file mode 100644 index 62265d9b..00000000 --- a/src/components/AI/ChangedFilesList.tsx +++ /dev/null @@ -1,353 +0,0 @@ -// 変更されたファイル一覧表示コンポーネント - -'use client'; - -import React from 'react'; - -import { useTranslation } from '@/context/I18nContext'; -import { useTheme } from '@/context/ThemeContext'; -import type { AIEditResponse } from '@/types'; - -interface ChangedFilesListProps { - changedFiles: AIEditResponse['changedFiles']; - onOpenReview: (filePath: string, originalContent: string, suggestedContent: string) => void; - onApplyChanges: (filePath: string, content: string) => void; - onDiscardChanges: (filePath: string) => void; -} - -export default function ChangedFilesList({ - changedFiles, - onOpenReview, - onApplyChanges, - onDiscardChanges, -}: ChangedFilesListProps) { - const compact = true; - const { colors } = useTheme(); - const { t } = useTranslation(); - - if (changedFiles.length === 0) { - return ( -
- {t('ai.changedFilesList.noChangedFiles')} -
- ); - } - - if (compact) { - return ( -
-
- - - - {t('changedFilesList.changedFiles')} ({changedFiles.length}) -
- - {changedFiles.map((file, index) => ( -
- {/* ファイル名と操作ボタン */} -
-
- - - - {file.path.split('/').pop()} -
-
- - - -
-
- - {/* 変更理由(コンパクト) */} - {file.explanation && ( -
- 💡 {file.explanation} -
- )} - - {/* 統計情報 */} -
- - {file.originalContent.split('\n').length} - {t('diff.lines')} - - - - - - {file.suggestedContent.split('\n').length} - {t('diff.lines')} - - - ( - {file.suggestedContent.split('\n').length - - file.originalContent.split('\n').length > - 0 - ? '+' - : ''} - {file.suggestedContent.split('\n').length - file.originalContent.split('\n').length} - ) - -
- - {/* プレビュー(最初の1行のみ) */} -
-
- {file.suggestedContent.split('\n')[0] || ' '} -
- {file.suggestedContent.split('\n').length > 1 && ( -
- ... +{file.suggestedContent.split('\n').length - 1} {t('diff.lines')} -
- )} -
-
- ))} -
- ); - } - - return ( -
-
- {t('changedFilesList.changedFiles')} ({changedFiles.length}) -
- - {changedFiles.map((file, index) => ( -
- {/* ファイル名 */} -
-
- {file.path} -
-
- - - -
-
- - {/* 変更理由 */} - {file.explanation && ( -
- {t('changedFilesList.reason')}: {file.explanation} -
- )} - - {/* コード変更のプレビュー(最初の3行のみ) */} -
-
- {t('changedFilesList.preview')}: -
-
- {file.suggestedContent - .split('\n') - .slice(0, 3) - .map((line, i) => ( -
- {line || ' '} -
- ))} - {file.suggestedContent.split('\n').length > 3 && ( -
- ... {t('changedFilesList.others')} {file.suggestedContent.split('\n').length - 3}{' '} - {t('diff.lines')} -
- )} -
-
- - {/* 統計情報 */} -
- - {t('diff.original')}: {file.originalContent.split('\n').length} - {t('diff.lines')} - - - {t('diff.suggested')}: {file.suggestedContent.split('\n').length} - {t('diff.lines')} - - - {t('diff.diff')}:{' '} - {file.suggestedContent.split('\n').length - file.originalContent.split('\n').length > - 0 - ? '+' - : ''} - {file.suggestedContent.split('\n').length - file.originalContent.split('\n').length} - {t('diff.lines')} - -
-
- ))} - - {/* 一括操作 */} - {changedFiles.length > 1 && ( -
- - -
- )} -
- ); -} diff --git a/src/components/AI/chat/ChatContainer.tsx b/src/components/AI/chat/ChatContainer.tsx index ed55c51e..862d4a9f 100644 --- a/src/components/AI/chat/ChatContainer.tsx +++ b/src/components/AI/chat/ChatContainer.tsx @@ -2,11 +2,14 @@ 'use client'; -import { Loader2, MessageSquare } from 'lucide-react'; +import { Loader2, MessageSquare, Bot } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import ChatMessage from './ChatMessage'; +import InlineHighlightedCode from '@/components/Tab/InlineHighlightedCode'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import type { ChatSpaceMessage } from '@/types'; @@ -15,58 +18,47 @@ interface ChatContainerProps { messages: ChatSpaceMessage[]; isProcessing: boolean; emptyMessage?: string; + streamingContent?: string; onRevert?: (message: ChatSpaceMessage) => Promise; - onOpenReview?: (filePath: string, originalContent: string, suggestedContent: string) => Promise; - onApplyChanges?: (filePath: string, newContent: string) => Promise; - onDiscardChanges?: (filePath: string) => Promise; } export default function ChatContainer({ messages, isProcessing, emptyMessage = 'AIとチャットを開始してください', + streamingContent = '', onRevert, }: ChatContainerProps) { - // Always compact by design - const compact = true; const { colors } = useTheme(); const { t } = useTranslation(); const scrollRef = useRef(null); - // 新しいメッセージが追加されたら自動スクロール + // Auto scroll to bottom when new messages arrive or streaming content updates useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [messages.length, isProcessing]); - - // Debug: log messages on each render to inspect which messages contain editResponse - // Debug: log message count only to reduce noise (avoid logging full array each render) - useEffect(() => { - try { - console.log('[ChatContainer] messages render, count:', messages.length); - } catch (e) { - console.warn('[ChatContainer] debug log failed', e); - } - }, [messages.length, isProcessing]); + }, [messages.length, isProcessing, streamingContent]); return (
{messages.length === 0 ? (
- -
{emptyMessage}
-
{t('ai.chatContainer.suggest')}
+
+ +
+
{emptyMessage}
+
{t('ai.chatContainer.suggest')}
) : ( <> @@ -74,27 +66,118 @@ export default function ChatContainer({ { - if (typeof onRevert === 'function') await onRevert(m); - }} + onRevert={onRevert} /> ))} - {/* 処理中インジケータ */} - {isProcessing && ( -
- - {t('ai.chatContainer.generating')} + {/* Streaming message display */} + {isProcessing && streamingContent && ( +
+ {/* Avatar */} +
+ +
+ + {/* Streaming content */} +
+
+
+ + ); + } + + return ( + + {children} + + ); + }, + p: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + }} + > + {streamingContent} +
    +
    + {/* Streaming indicator */} +
    + + {t('ai.chatContainer.generating')} +
    +
    +
    +
    + )} + + {/* Processing indicator (shown when no streaming content yet) */} + {isProcessing && !streamingContent && ( +
    +
    + +
    +
    + + {t('ai.chatContainer.generating')} +
    )} diff --git a/src/components/AI/chat/ChatInput.tsx b/src/components/AI/chat/ChatInput.tsx index ad617893..27084251 100644 --- a/src/components/AI/chat/ChatInput.tsx +++ b/src/components/AI/chat/ChatInput.tsx @@ -115,60 +115,58 @@ export default function ChatInput({ background: colors.cardBg, }} > -
    +
    {/* 選択ファイル表示 */} {(selectedFiles.length > 0 || activeTabPath) && ( -
    +
    - - {t('ai.selectedLabel')} +
    - {/* アクティブタブをインラインで表示(選択ファイルの先頭) - ただし既に選択済みなら候補表示は不要なので非表示にする */} + {/* アクティブタブをインラインで表示 */} {!isActiveTabSelected && activeTabPath && (
    icon - + {activeTabPath.split('/').pop()}
    )} @@ -182,10 +180,10 @@ export default function ChatInput({ style={{ display: 'inline-flex', alignItems: 'center', - gap: 6, - padding: '2px 6px', - borderRadius: 6, - fontSize: 11, + gap: 4, + padding: '1px 4px', + borderRadius: 4, + fontSize: 10, fontFamily: 'monospace', background: colors.mutedBg, border: `1px solid ${colors.border}`, @@ -195,7 +193,7 @@ export default function ChatInput({ }} > icon - + {fileName}
    ); @@ -231,38 +230,38 @@ export default function ChatInput({ onKeyDown={handleKeyDown} placeholder={placeholder} disabled={isProcessing || disabled} - className="w-full px-3 py-2 pr-20 rounded-lg border resize-none focus:outline-none focus:ring-2 transition-all" + className="w-full px-2.5 py-1.5 pr-16 rounded-md border resize-none focus:outline-none focus:ring-1 transition-all text-xs" style={{ background: colors.editorBg, color: colors.editorFg, borderColor: colors.border, - minHeight: '48px', - maxHeight: '200px', + minHeight: '36px', + maxHeight: '150px', }} rows={1} /> {/* 送信ボタン */} -
    +
    {onOpenFileSelector && ( )}
    @@ -287,7 +286,7 @@ export default function ChatInput({ {/* ヘルプテキスト */}
    {t('ai.hints.enterSend')} diff --git a/src/components/AI/chat/ChatMessage.tsx b/src/components/AI/chat/ChatMessage.tsx index 4870c12d..d2a25def 100644 --- a/src/components/AI/chat/ChatMessage.tsx +++ b/src/components/AI/chat/ChatMessage.tsx @@ -2,13 +2,13 @@ 'use client'; -import { FileCode, Clock, Copy, Check } from 'lucide-react'; -import React, { useState } from 'react'; +import { FileCode, Clock, RotateCcw, Bot, User } from 'lucide-react'; +import React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; + import InlineHighlightedCode from '@/components/Tab/InlineHighlightedCode'; import LocalImage from '@/components/Tab/LocalImage'; - import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import type { ChatSpaceMessage } from '@/types'; @@ -18,197 +18,210 @@ interface ChatMessageProps { onRevert?: (message: ChatSpaceMessage) => Promise; } -// InlineHighlightedCode is used for syntax highlighting - export default function ChatMessage({ message, onRevert }: ChatMessageProps) { const { colors } = useTheme(); const { t } = useTranslation(); const isUser = message.type === 'user'; + const isEdit = message.mode === 'edit'; + const hasEditResponse = message.type === 'assistant' && isEdit && message.editResponse; + + // Count applied/pending files (extract to avoid duplicate filtering) + const changedFiles = message.editResponse?.changedFiles ?? []; + const appliedFiles = changedFiles.filter(f => f.applied); + const pendingFiles = changedFiles.filter(f => !f.applied); + const appliedCount = hasEditResponse ? appliedFiles.length : 0; + const pendingCount = hasEditResponse ? pendingFiles.length : 0; return ( -
    +
    + {/* Avatar */}
    - {/* メッセージ内容 - Markdown + シンタックスハイライト */} -
    - + ) : ( + + )} +
    + + {/* Message content */} +
    +
    + {/* Mode badge */} + {isEdit && ( + + Edit + + )} + + {/* Message content - Markdown */} +
    + + ); + } - if (!inline && language) { return ( - + + {children} + ); - } - - // インラインコード - return ( -

    {children}

    , + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + li: ({ children }) =>
  • {children}
  • , + blockquote: ({ children }) => ( +
    {children} - - ); - }, - - // 段落 - p: ({ children }) =>

    {children}

    , - - // 見出し - h1: ({ children }) => ( -

    {children}

    - ), - h2: ({ children }) => ( -

    {children}

    - ), - h3: ({ children }) => ( -

    {children}

    - ), - - // リスト - ul: ({ children }) => ( -
      {children}
    - ), - ol: ({ children }) => ( -
      {children}
    - ), - li: ({ children }) =>
  • {children}
  • , - - // 引用 - blockquote: ({ children }) => ( -
    - {children} -
    - ), - - // テーブル - table: ({ children }) => ( -
    - + + ), + table: ({ children }) => ( +
    +
    + {children} +
    +
    + ), + th: ({ children }) => ( + {children} - -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - - // リンク - a: ({ children, href }) => ( - - {children} - - ), - // 画像: LocalImage を使ってローカルパスを解決 - img: ({ node, src, alt, ...props }: any) => ( - - ), - }} - > - {message.content} - -
    - - {/* ヘッダツール: コピーなどと並べる形で Revert ボタンを追加 */} - {message.type === 'assistant' && message.mode === 'edit' && message.editResponse && ( -
    - + {message.content} +
    - )} - {/* ファイルコンテキスト表示 */} - {message.fileContext && message.fileContext.length > 0 && ( -
    -
    - - {t('ai.chatMessage.reference')} - {message.fileContext.map((filePath, index) => ( - 0 && ( +
    +
    + + {message.fileContext.map((filePath, index) => ( + + {filePath.split('/').pop()} + + ))} +
    +
    + )} + + {/* Edit response summary */} + {hasEditResponse && ( +
    +
    + {appliedCount > 0 && ( + + {appliedCount} {t('ai.applied') || '適用済み'} + + )} + {pendingCount > 0 && ( + + {pendingCount} {t('ai.pending') || '保留中'} + + )} +
    + + {/* Revert button */} + {appliedCount > 0 && onRevert && ( + + )}
    -
    - )} + )} +
    - {/* タイムスタンプ */} + {/* Timestamp */}
    - + {message.timestamp.toLocaleTimeString('ja-JP', { hour: '2-digit', diff --git a/src/components/Bottom/ProblemsPanel.tsx b/src/components/Bottom/ProblemsPanel.tsx index 479a17b0..8e9a0278 100644 --- a/src/components/Bottom/ProblemsPanel.tsx +++ b/src/components/Bottom/ProblemsPanel.tsx @@ -1,6 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'; +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { loader } from '@monaco-editor/react'; import type * as monaco from 'monaco-editor'; import { useTheme } from '@/context/ThemeContext'; import { useTabStore } from '@/stores/tabStore'; @@ -10,206 +12,357 @@ interface ProblemsPanelProps { isActive?: boolean; } +// Marker with file info for display +interface MarkerWithFile { + marker: any; + filePath: string; + fileName: string; +} + +// File extensions to exclude from problems display +const EXCLUDED_EXTENSIONS = ['.txt', '.md', '.markdown']; + +function shouldExcludeFile(fileName: string): boolean { + const lower = fileName.toLowerCase(); + return EXCLUDED_EXTENSIONS.some(ext => lower.endsWith(ext)); +} + +// Check if marker owner matches the file type +// This filters out TypeScript diagnostics for non-TS/JS files +function isMarkerOwnerValidForFile(fileName: string, owner: string): boolean { + const lower = fileName.toLowerCase(); + const ownerLower = (owner || '').toLowerCase(); + + // TypeScript/JavaScript markers should only apply to TS/JS/JSX/TSX files + if (ownerLower === 'typescript' || ownerLower === 'javascript') { + return ( + lower.endsWith('.ts') || + lower.endsWith('.tsx') || + lower.endsWith('.js') || + lower.endsWith('.jsx') || + lower.endsWith('.mts') || + lower.endsWith('.cts') || + lower.endsWith('.mjs') || + lower.endsWith('.cjs') + ); + } + + // CSS markers should only apply to CSS/SCSS/LESS files + if (ownerLower === 'css' || ownerLower === 'scss' || ownerLower === 'less') { + return ( + lower.endsWith('.css') || + lower.endsWith('.scss') || + lower.endsWith('.less') || + lower.endsWith('.sass') + ); + } + + // JSON markers should only apply to JSON files + if (ownerLower === 'json') { + return lower.endsWith('.json') || lower.endsWith('.jsonc'); + } + + // HTML markers should only apply to HTML files + if (ownerLower === 'html') { + return ( + lower.endsWith('.html') || + lower.endsWith('.htm') || + lower.endsWith('.xhtml') + ); + } + + // Allow other markers (unknown owners) + return true; +} + export default function ProblemsPanel({ height, isActive }: ProblemsPanelProps) { const { colors } = useTheme(); const globalActiveTab = useTabStore(state => state.globalActiveTab); const panes = useTabStore(state => state.panes); const updateTab = useTabStore(state => state.updateTab); + const activateTab = useTabStore(state => state.activateTab); - const [markers, setMarkers] = useState([]); + const [allMarkers, setAllMarkers] = useState([]); const [showImportErrors, setShowImportErrors] = useState(false); + const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); + const [refreshCounter, setRefreshCounter] = useState(0); - // find paneId for current globalActiveTab - const paneIdForActiveTab = useMemo(() => { - if (!globalActiveTab) return null; - const findPane = (panesList: any[]): string | null => { - for (const p of panesList) { - if (p.tabs && p.tabs.find((t: any) => t.id === globalActiveTab)) return p.id; - if (p.children) { - const found = findPane(p.children); - if (found) return found; + // Helper to find paneId for a tabId + const findPaneIdForTab = useMemo(() => { + return (tabId: string): string | null => { + const findPane = (panesList: any[]): string | null => { + for (const p of panesList) { + if (p.tabs && p.tabs.find((t: any) => t.id === tabId)) return p.id; + if (p.children) { + const found = findPane(p.children); + if (found) return found; + } } - } - return null; + return null; + }; + return findPane(panes); }; - return findPane(panes); - }, [globalActiveTab, panes]); + }, [panes]); - useEffect(() => { - if (!globalActiveTab) { - setMarkers([]); - return; - } + // Manual refresh + const handleRefresh = useCallback(() => { + setRefreshCounter(c => c + 1); + }, []); + useEffect(() => { let disposable: { dispose?: () => void } | null = null; + let isCancelled = false; - // run in async scope so we can dynamic-import monaco on client only - (async () => { - try { - const monAny = (globalThis as any).monaco; - const monModule = monAny || (await import('monaco-editor')); - const mon = monModule as typeof import('monaco-editor'); - - // Construct the same inmemory URI used by useMonacoModels - const normalized = globalActiveTab.startsWith('/') ? globalActiveTab : `/${globalActiveTab}`; - const expectedUri = mon.Uri.parse(`inmemory://model${normalized}`); - - // Try exact match first, then a few fallbacks that tolerate Windows backslashes - let model: monaco.editor.ITextModel | null = mon.editor.getModel(expectedUri) || null; - if (!model) { - const expectedStr = expectedUri.toString(); - const expectedNorm = expectedStr.replace(/\\/g, '/'); - const expectedPath = expectedUri.path || normalized; - const expectedPathNorm = expectedPath.replace(/\\/g, '/'); - - const found = mon.editor.getModels().find((m: monaco.editor.ITextModel) => { + // Use @monaco-editor/react's loader to get Monaco instance + loader.init().then((mon) => { + if (isCancelled) return; + + const collectAllMarkers = () => { + if (isCancelled) return; + + try { + // Get ALL markers from Monaco + const allMonacoMarkers = mon.editor.getModelMarkers({}); + const markersWithFiles: MarkerWithFile[] = []; + + for (const marker of allMonacoMarkers) { try { - const s = m.uri.toString(); - const sNorm = s.replace(/\\/g, '/'); - const p = m.uri.path || ''; - const pNorm = p.replace(/\\/g, '/'); - return ( - s === expectedStr || - sNorm === expectedNorm || - p === expectedPath || - pNorm.endsWith(expectedPathNorm) || - s.endsWith(expectedPath) || - sNorm.endsWith(expectedPathNorm) - ); + // Extract file path from the marker's resource URI + let filePath = marker.resource?.path || ''; + if (filePath.startsWith('/')) { + filePath = filePath.substring(1); + } + // Remove any timestamp suffixes added for uniqueness + filePath = filePath.replace(/__\d+$/, ''); + + const fileName = filePath.split('/').pop() || filePath; + + // Skip excluded file types + if (shouldExcludeFile(fileName)) { + continue; + } + + // Skip markers where the owner doesn't match the file type + // (e.g., TypeScript errors for CSS files) + if (!isMarkerOwnerValidForFile(fileName, marker.owner)) { + continue; + } + + markersWithFiles.push({ + marker, + filePath, + fileName, + }); } catch (e) { - return false; + // Skip markers that fail } - }); - model = found || null; - } - - const collect = () => { - if (!model) { - setMarkers([]); - return; } - // Request markers for this specific model/resource - try { - const our = mon.editor.getModelMarkers({ resource: model.uri }); - setMarkers(our); - } catch (e) { - // fallback: full list filtered - const all = mon.editor.getModelMarkers({}); - const our = all.filter((mk: any) => mk.resource && mk.resource.toString() === model!.uri.toString()); - setMarkers(our); + if (!isCancelled) { + setAllMarkers(markersWithFiles); } - }; + } catch (e) { + console.warn('[ProblemsPanel] failed to collect markers', e); + } + }; - collect(); + // Initial collection + collectAllMarkers(); - // no debug logging in production panel - - disposable = mon.editor.onDidChangeMarkers((uris: readonly monaco.Uri[]) => { - if (!model) return; - if (uris.some(u => u.toString() === model!.uri.toString())) { - collect(); - } - }); - } catch (e) { - console.warn('[ProblemsPanel] failed to read markers', e); - setMarkers([]); - } - })(); + // Listen to marker changes + disposable = mon.editor.onDidChangeMarkers(() => { + collectAllMarkers(); + }); + }).catch((e) => { + console.warn('[ProblemsPanel] failed to initialize Monaco', e); + }); return () => { + isCancelled = true; try { - disposable && disposable.dispose && disposable.dispose(); + if (disposable && disposable.dispose) { + disposable.dispose(); + } } catch (e) {} }; - }, [globalActiveTab]); - - const handleGoto = (marker: any) => { - if (!globalActiveTab || !paneIdForActiveTab) return; - updateTab(paneIdForActiveTab, globalActiveTab, { - jumpToLine: marker.startLineNumber, - jumpToColumn: marker.startColumn, - } as any); + }, [refreshCounter]); + + const handleGoto = (markerWithFile: MarkerWithFile) => { + const { marker, filePath } = markerWithFile; + + // Find the tab and pane for this file + const tabId = filePath.startsWith('/') ? filePath : `/${filePath}`; + const paneId = findPaneIdForTab(tabId) || findPaneIdForTab(filePath); + + if (paneId) { + // Activate the tab first + activateTab(paneId, tabId.startsWith('/') ? tabId : filePath); + + // Then update with jump info + updateTab(paneId, tabId.startsWith('/') ? tabId : filePath, { + jumpToLine: marker.startLineNumber, + jumpToColumn: marker.startColumn, + } as any); + } + }; + + const toggleFileCollapse = (filePath: string) => { + setCollapsedFiles(prev => { + const newSet = new Set(prev); + if (newSet.has(filePath)) { + newSet.delete(filePath); + } else { + newSet.add(filePath); + } + return newSet; + }); }; - const displayedMarkers = markers.filter(m => { + const displayedMarkers = allMarkers.filter(m => { if (showImportErrors) return true; - // Hide multi-file import resolution errors like: "Cannot find module './math' or its corresponding type declarations." - const msg = (m.message || '').toString(); + // Hide multi-file import resolution errors + const msg = (m.marker.message || '').toString(); if (/Cannot find module\b/i.test(msg)) return false; if (/corresponding type declarations/i.test(msg)) return false; return true; }); + // Group markers by file + const markersByFile = useMemo(() => { + const grouped: Map = new Map(); + for (const m of displayedMarkers) { + const key = m.filePath; + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)!.push(m); + } + return grouped; + }, [displayedMarkers]); + + const totalProblems = displayedMarkers.length; + const errorCount = displayedMarkers.filter(m => m.marker.severity === 8).length; + const warningCount = displayedMarkers.filter(m => m.marker.severity === 4).length; + return (
    -
    -
    -
    Problems
    -
    - 注意: この機能はベータ版です。検出されたエラーは誤検出の可能性があります。 -
    +
    +
    + Problems ({totalProblems}) + {errorCount > 0 && E:{errorCount}} + {warningCount > 0 && W:{warningCount}}
    -
    +
    +
    - {globalActiveTab ? ( - displayedMarkers.length > 0 ? ( -
    - {displayedMarkers.map((m, idx) => ( -
    handleGoto(m)} - style={{ - borderLeft: `3px solid ${m.severity === 8 ? '#D16969' : '#D7BA7D'}`, - padding: '6px 8px', - marginBottom: 6, - cursor: 'pointer', - background: colors.mutedBg, - }} - > -
    - {m.message.split('\n')[0]} -
    -
    - Line {m.startLineNumber}, Col {m.startColumn} — {m.source || m.owner || ''} + {totalProblems > 0 ? ( +
    + {Array.from(markersByFile.entries()).map(([filePath, fileMarkers]) => { + const isCollapsed = collapsedFiles.has(filePath); + const fileErrorCount = fileMarkers.filter(m => m.marker.severity === 8).length; + const fileWarnCount = fileMarkers.filter(m => m.marker.severity === 4).length; + + return ( +
    +
    toggleFileCollapse(filePath)} + style={{ + fontSize: 11, + fontWeight: 500, + padding: '3px 4px', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: 4, + background: colors.mutedBg, + borderRadius: 2, + }} + > + {isCollapsed ? ( + + ) : ( + + )} + {fileMarkers[0]?.fileName || filePath} + + {fileErrorCount > 0 && {fileErrorCount}} + {fileWarnCount > 0 && {fileWarnCount}} +
    + {!isCollapsed && ( +
    + {fileMarkers.map((m, idx) => ( +
    handleGoto(m)} + style={{ + borderLeft: `2px solid ${m.marker.severity === 8 ? '#D16969' : '#D7BA7D'}`, + padding: '2px 6px', + marginTop: 2, + cursor: 'pointer', + fontSize: 10, + lineHeight: 1.3, + }} + > + + {m.marker.startLineNumber}:{m.marker.startColumn} + + {m.marker.message.split('\n')[0].substring(0, 80)}{m.marker.message.length > 80 ? '...' : ''} +
    + ))} +
    + )}
    - ))} - {displayedMarkers.length !== markers.length && ( -
    - 一部のエラーを非表示にしています。表示するには上のボタンを切り替えてください。 -
    - )} -
    - ) : ( -
    No problems found in current file.
    - ) + ); + })} + {displayedMarkers.length !== allMarkers.length && ( +
    + 一部非表示中 +
    + )} +
    ) : ( -
    No active tab selected.
    +
    No problems
    )}
    ); diff --git a/src/components/Bottom/Terminal.tsx b/src/components/Bottom/Terminal.tsx index 7487991e..02ed9ec5 100644 --- a/src/components/Bottom/Terminal.tsx +++ b/src/components/Bottom/Terminal.tsx @@ -12,6 +12,7 @@ import type { UnixCommands } from '@/engine/cmd/global/unix'; import { handleGitCommand } from '@/engine/cmd/handlers/gitHandler'; import { handleNPMCommand } from '@/engine/cmd/handlers/npmHandler'; import { handlePyxisCommand } from '@/engine/cmd/handlers/pyxisHandler'; +import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; import { handleVimCommand } from '@/engine/cmd/vim'; import { fileRepository } from '@/engine/core/fileRepository'; import { gitFileSystem } from '@/engine/core/gitFileSystem'; @@ -265,11 +266,15 @@ function ClientTerminal({ // サイズを調整 setTimeout(() => { fitAddon.fit(); + // Update shell terminal size after fit + terminalCommandRegistry.updateShellSize(currentProjectId, term.cols, term.rows); setTimeout(() => { term.scrollToBottom(); setTimeout(() => { fitAddon.fit(); term.scrollToBottom(); + // Update shell terminal size again after second fit + terminalCommandRegistry.updateShellSize(currentProjectId, term.cols, term.rows); }, 100); }, 50); }, 100); @@ -363,12 +368,27 @@ function ClientTerminal({ } catch {} }; + // Write lock to prevent concurrent writes causing newlines + let isTermWriting = false; + const writeQueue: string[] = []; + + const flushWriteQueue = () => { + if (isTermWriting || writeQueue.length === 0) return; + isTermWriting = true; + const output = writeQueue.shift()!; + term.write(output, () => { + isTermWriting = false; + flushWriteQueue(); // Process next in queue + }); + }; + // 長い出力を段階的に処理する関数 const writeOutput = async (output: string) => { // \nを\r\nに変換(xtermは\r\nが必要) const normalized = output.replace(/\r?\n/g, '\r\n'); cmdOutputs += output; - term.write(normalized); + writeQueue.push(normalized); + flushWriteQueue(); }; const processCommand = async (command: string) => { @@ -397,8 +417,13 @@ function ClientTerminal({ console.log('[Terminal] captureWriteOutput received:', JSON.stringify(output)); } catch (e) {} + // Don't add newlines to in-place updates (starts with \r for carriage return) + // or cursor control sequences (starts with \x1b[) + const isInPlaceUpdate = output.startsWith('\r') || output.startsWith('\x1b[?'); + // 末尾に改行がない場合は追加(すべてのコマンド出力を統一的に処理) - const normalizedOutput = output.endsWith('\n') ? output : output + '\n'; + // But skip for in-place updates which need to stay on the same line + const normalizedOutput = isInPlaceUpdate || output.endsWith('\n') ? output : output + '\n'; capturedOutput += normalizedOutput; if (!redirect) { @@ -913,6 +938,14 @@ function ClientTerminal({ if (fitAddonRef.current && xtermRef.current) { setTimeout(() => { fitAddonRef.current?.fit(); + // Update shell terminal size after resize + if (currentProjectId && xtermRef.current) { + terminalCommandRegistry.updateShellSize( + currentProjectId, + xtermRef.current?.cols ?? 80, + xtermRef.current?.rows ?? 24 + ); + } setTimeout(() => { xtermRef.current?.scrollToBottom(); }, 100); diff --git a/src/components/DnD/CustomDragLayer.tsx b/src/components/DnD/CustomDragLayer.tsx new file mode 100644 index 00000000..e5323ce5 --- /dev/null +++ b/src/components/DnD/CustomDragLayer.tsx @@ -0,0 +1,95 @@ +'use client'; +import { memo, useMemo } from 'react'; +import { useDragLayer } from 'react-dnd'; +import { getIconForFile, getIconForFolder } from 'vscode-icons-js'; + +import { DND_TAB, DND_FILE_TREE_ITEM } from '@/constants/dndTypes'; +import { useTheme } from '@/context/ThemeContext'; + +/** + * 共通カスタムドラッグレイヤー + * FileTreeとTabBarの両方で使用するドラッグプレビューを表示 + */ +const CustomDragLayer = memo(function CustomDragLayer() { + const { colors } = useTheme(); + const { isDragging, item, itemType, currentOffset } = useDragLayer((monitor) => ({ + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + })); + + // アイテム情報をメモ化 + const { iconSrc, name, isFolder } = useMemo(() => { + if (!item) { + return { iconSrc: '', name: '', isFolder: false }; + } + + // FILE_TREE_ITEMの場合 + if (itemType === DND_FILE_TREE_ITEM && item.item) { + const fileItem = item.item; + const isFolder = fileItem.type === 'folder'; + const iconPath = isFolder + ? getIconForFolder(fileItem.name) || getIconForFolder('') + : getIconForFile(fileItem.name) || getIconForFile(''); + const iconSrc = iconPath && iconPath.endsWith('.svg') + ? `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath.split('/').pop()}` + : `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${isFolder ? 'folder.svg' : 'file.svg'}`; + return { iconSrc, name: fileItem.name, isFolder }; + } + + // TABの場合 + if (itemType === DND_TAB && item.tabName) { + const iconPath = getIconForFile(item.tabName) || getIconForFile(''); + const iconSrc = iconPath && iconPath.endsWith('.svg') + ? `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath.split('/').pop()}` + : `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/file.svg`; + return { iconSrc, name: item.tabName, isFolder: false }; + } + + return { iconSrc: '', name: '', isFolder: false }; + }, [item, itemType]); + + if (!isDragging || !item || !currentOffset || !name) { + return null; + } + + return ( +
    +
    + {isFolder + {name} +
    +
    + ); +}); + +export default CustomDragLayer; diff --git a/src/components/ExtensionInitializer.tsx b/src/components/ExtensionInitializer.tsx index 0e929dfa..b87d1b01 100644 --- a/src/components/ExtensionInitializer.tsx +++ b/src/components/ExtensionInitializer.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { initializeExtensions } from '@/engine/extensions/autoInstaller'; +import { initializeBuiltinRuntimes } from '@/engine/runtime/builtinRuntimes'; export default function ExtensionInitializer() { useEffect(() => { @@ -10,6 +11,10 @@ export default function ExtensionInitializer() { (async () => { try { + // ビルトインランタイムを初期化 + initializeBuiltinRuntimes(); + + // 拡張機能を初期化 await initializeExtensions(); if (mounted) { // noop: initialization complete diff --git a/src/components/Left/FileTree.tsx b/src/components/Left/FileTree.tsx index 89a55ec1..69fe11d6 100644 --- a/src/components/Left/FileTree.tsx +++ b/src/components/Left/FileTree.tsx @@ -1,7 +1,10 @@ -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { useState, useEffect, useRef } from 'react'; +import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react'; +import { useState, useEffect, useRef, useMemo, memo } from 'react'; +import { useDrag, useDrop, useDragLayer } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; import { getIconForFile, getIconForFolder, getIconForOpenFolder } from 'vscode-icons-js'; +import { DND_FILE_TREE_ITEM, FileTreeDragItem } from '@/constants/dndTypes'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; @@ -13,6 +16,12 @@ import { importSingleFile } from '@/engine/import/importSingleFile'; import { useTabStore } from '@/stores/tabStore'; import { FileItem } from '@/types'; +// ドラッグアイテムの型定義(FileTreeDragItemと互換性を持たせる) +interface DragItem { + type: string; + item: FileItem; +} + interface FileTreeProps { items: FileItem[]; level?: number; @@ -20,6 +29,406 @@ interface FileTreeProps { currentProjectId?: string; onRefresh?: () => void; // [NEW ARCHITECTURE] ファイルツリー再読み込み用 isFileSelectModal?: boolean; + // 内部移動用のコールバック(親から渡される) + onInternalFileDrop?: (draggedItem: FileItem, targetFolderPath: string) => void; +} + +// カスタムドラッグレイヤー - ドラッグ中にファイル/フォルダ名を表示する長方形 +// パフォーマンス最適化のためmemoを使用 +const CustomDragLayer = memo(function CustomDragLayer() { + const { colors } = useTheme(); + const { isDragging, item, currentOffset } = useDragLayer((monitor) => ({ + item: monitor.getItem() as DragItem | null, + currentOffset: monitor.getSourceClientOffset(), + isDragging: monitor.isDragging(), + })); + + // アイテム情報をメモ化して再計算を防ぐ + const { iconSrc, name, isFolder } = useMemo(() => { + if (!item?.item) { + return { iconSrc: '', name: '', isFolder: false }; + } + const fileItem = item.item; + const isFolder = fileItem.type === 'folder'; + const iconPath = isFolder + ? getIconForFolder(fileItem.name) || getIconForFolder('') + : getIconForFile(fileItem.name) || getIconForFile(''); + const iconSrc = iconPath && iconPath.endsWith('.svg') + ? `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath.split('/').pop()}` + : `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${isFolder ? 'folder.svg' : 'file.svg'}`; + return { iconSrc, name: fileItem.name, isFolder }; + }, [item?.item?.name, item?.item?.type]); + + if (!isDragging || !item || !currentOffset) { + return null; + } + + return ( +
    +
    + {isFolder + {name} +
    +
    + ); +}); + +// 個別のファイルツリーアイテムコンポーネント(react-dnd対応) +interface FileTreeItemProps { + item: FileItem; + level: number; + isExpanded: boolean; + isIgnored: boolean; + hoveredItemId: string | null; + colors: any; + currentProjectName: string; + currentProjectId?: string; + onRefresh?: () => void; + onItemClick: (item: FileItem) => void; + onContextMenu: (e: React.MouseEvent, item: FileItem) => void; + onTouchStart: (e: React.TouchEvent, item: FileItem) => void; + onTouchEnd: () => void; + onTouchMove: () => void; + setHoveredItemId: (id: string | null) => void; + handleNativeFileDrop: (e: React.DragEvent, targetPath?: string) => void; + handleDragOver: (e: React.DragEvent) => void; + onInternalFileDrop?: (draggedItem: FileItem, targetFolderPath: string) => void; +} + +function FileTreeItem({ + item, + level, + isExpanded, + isIgnored, + hoveredItemId, + colors, + currentProjectName, + currentProjectId, + onRefresh, + onItemClick, + onContextMenu, + onTouchStart, + onTouchEnd, + onTouchMove, + setHoveredItemId, + handleNativeFileDrop, + handleDragOver, + onInternalFileDrop, +}: FileTreeItemProps) { + const [dropIndicator, setDropIndicator] = useState(false); + const [isTouchDevice, setIsTouchDevice] = useState(false); + + // Check if it's a touch device + useEffect(() => { + const checkTouchDevice = () => { + setIsTouchDevice( + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + ('msMaxTouchPoints' in navigator && (navigator as any).msMaxTouchPoints > 0) + ); + }; + checkTouchDevice(); + window.addEventListener('resize', checkTouchDevice); + return () => window.removeEventListener('resize', checkTouchDevice); + }, []); + + // 開発環境かどうかを判断 + const isDev = process.env.NEXT_PUBLIC_IS_DEV_SERVER === 'true'; + + // カスタムドラッグプレビュー用のref + const dragPreviewRef = useRef(null); + + // ドラッグソース - with proper item structure + const [{ isDragging }, drag, preview] = useDrag( + () => ({ + type: DND_FILE_TREE_ITEM, + item: () => { + if (isDev) { + console.log('[FileTreeItem] DRAG START', { item: item.name, path: item.path }); + } + return { type: DND_FILE_TREE_ITEM, item }; + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + end: (draggedItem, monitor) => { + if (isDev) { + console.log('[FileTreeItem] DRAG END', { + didDrop: monitor.didDrop(), + dropResult: monitor.getDropResult() + }); + } + }, + }), + [item, isDev] + ); + + // カスタムドラッグプレビューを設定(デフォルトの空の画像を使用して、カスタムレイヤーで表示) + useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, [preview]); + + // ドロップターゲット(フォルダのみ) + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_FILE_TREE_ITEM, + canDrop: (dragItem: DragItem, monitor) => { + // フォルダでない場合はドロップ不可 + if (item.type !== 'folder') return false; + // 自分自身へのドロップは不可 + if (dragItem.item.id === item.id) return false; + // ドラッグアイテム(フォルダ)を自分の子孫にドロップしようとしている場合は不可 + if (item.path.startsWith(dragItem.item.path + '/')) return false; + // ドラッグアイテムの親フォルダにドロップしようとしている場合は不可 + const draggedParent = dragItem.item.path.substring(0, dragItem.item.path.lastIndexOf('/')) || '/'; + if (draggedParent === item.path) return false; + return true; + }, + hover: (dragItem: DragItem, monitor) => { + // Hover feedback is handled by dropIndicator state + }, + drop: (dragItem: DragItem, monitor) => { + if (isDev) { + console.log('[FileTreeItem] DROP EVENT', { + target: item.path, + dragged: dragItem.item.path, + didDrop: monitor.didDrop(), + isOver: monitor.isOver({ shallow: true }) + }); + } + + // 子要素が既にドロップを処理した場合はスキップ + if (monitor.didDrop()) { + return; + } + + if (onInternalFileDrop && item.type === 'folder') { + if (isDev) { + console.log('[FileTreeItem] Calling onInternalFileDrop'); + } + onInternalFileDrop(dragItem.item, item.path); + return { handled: true }; + } + return undefined; + }, + collect: (monitor) => ({ + isOver: monitor.isOver({ shallow: true }), + canDrop: monitor.canDrop(), + }), + }), + [item, onInternalFileDrop, isDev] + ); + + // Combine drag and drop refs using callback ref pattern + const attachRef = (el: HTMLDivElement | null) => { + // Always attach drop to the row + drop(el); + // On desktop, entire row is draggable + if (!isTouchDevice) { + drag(el); + } + }; + + // Ref for grab handle on touch devices + const grabHandleRef = (el: HTMLDivElement | null) => { + if (isTouchDevice && el) { + drag(el); + } + }; + + // ドロップインジケーターの更新 + useEffect(() => { + setDropIndicator(isOver && canDrop); + }, [isOver, canDrop]); + + return ( +
    +
    onItemClick(item)} + onContextMenu={e => onContextMenu(e, item)} + onMouseEnter={() => setHoveredItemId(item.id)} + onMouseLeave={() => setHoveredItemId(null)} + onTouchStart={e => { + // On touch devices, only start context menu long press if not on grab handle + const target = e.target as HTMLElement; + if (!target.closest('[data-grab-handle]')) { + onTouchStart(e, item); + } + setHoveredItemId(item.id); + }} + onTouchEnd={() => { + onTouchEnd(); + setHoveredItemId(null); + }} + onTouchMove={() => { + onTouchMove(); + setHoveredItemId(null); + }} + onTouchCancel={() => { + onTouchEnd(); + setHoveredItemId(null); + }} + > + {item.type === 'folder' ? ( + <> + {isExpanded ? ( + + ) : ( + + )} + { + const iconPath = isExpanded + ? getIconForOpenFolder(item.name) || + getIconForFolder(item.name) || + getIconForFolder('') + : getIconForFolder(item.name) || getIconForFolder(''); + if (iconPath && iconPath.endsWith('.svg')) { + return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath + .split('/') + .pop()}`; + } + return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/folder.svg`; + })()} + alt="folder" + style={{ + width: 16, + height: 16, + verticalAlign: 'middle', + opacity: isIgnored ? 0.55 : 1, + }} + /> + + ) : ( + <> +
    + { + const iconPath = getIconForFile(item.name) || getIconForFile(''); + if (iconPath && iconPath.endsWith('.svg')) { + return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath + .split('/') + .pop()}`; + } + return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/file.svg`; + })()} + alt="file" + style={{ + width: 16, + height: 16, + verticalAlign: 'middle', + opacity: isIgnored ? 0.55 : 1, + }} + /> + + )} + + {item.name} + + + {/* Grab handle for touch devices - only visible on touch devices */} + {isTouchDevice && ( +
    { + // Prevent context menu trigger when starting drag from handle + e.stopPropagation(); + }} + > + +
    + )} +
    + {item.type === 'folder' && item.children && isExpanded && ( + + )} +
    + ); } export default function FileTree({ @@ -29,6 +438,7 @@ export default function FileTree({ currentProjectId, onRefresh, isFileSelectModal, + onInternalFileDrop, }: FileTreeProps) { const { colors } = useTheme(); const { t } = useTranslation(); @@ -45,8 +455,21 @@ export default function FileTree({ const contextMenuRef = useRef(null); const [gitignoreRules, setGitignoreRules] = useState(null); - // ドラッグ&ドロップ用(フォルダ対応) + // ドラッグ&ドロップ用(フォルダ対応) - ネイティブファイルドロップのみ処理 + // react-dnd のドロップはこのハンドラでは処理しない const handleDrop = async (e: React.DragEvent, targetPath?: string) => { + // Check if this is a react-dnd drop (has no files) - let react-dnd handle it + const hasFiles = e.dataTransfer.files && e.dataTransfer.files.length > 0; + const hasItems = e.dataTransfer.items && e.dataTransfer.items.length > 0; + const hasNativeFiles = hasItems && Array.from(e.dataTransfer.items).some( + item => item.kind === 'file' + ); + + // If no native files, this is likely a react-dnd internal drop - don't interfere + if (!hasFiles && !hasNativeFiles) { + return; + } + e.preventDefault(); e.stopPropagation(); const items = e.dataTransfer.items; @@ -138,9 +561,15 @@ export default function FileTree({ } }; + // handleDragOver - ネイティブファイルドロップ用 + // react-dnd 内部のドラッグには干渉しない const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); + // Only prevent default for native file drops + const hasNativeFiles = e.dataTransfer.types.includes('Files'); + if (hasNativeFiles) { + e.preventDefault(); + e.stopPropagation(); + } }; // expandedFoldersをlocalStorageに保存(初回復元後のみ) @@ -301,6 +730,67 @@ export default function FileTree({ } }; + // react-dnd: ファイル/フォルダをドロップターゲットに移動する + // propsから渡されている場合はそれを使用、そうでなければ自前のハンドラーを使用 + const internalDropHandler = onInternalFileDrop ?? (async (draggedItem: FileItem, targetFolderPath: string) => { + console.log('[FileTree] ============================================'); + console.log('[FileTree] internalDropHandler called'); + console.log('[FileTree] draggedItem:', JSON.stringify(draggedItem, null, 2)); + console.log('[FileTree] targetFolderPath:', targetFolderPath); + console.log('[FileTree] currentProjectId:', currentProjectId); + console.log('[FileTree] currentProjectName:', currentProjectName); + console.log('[FileTree] ============================================'); + + if (!currentProjectId) { + console.error('[FileTree] ERROR: No currentProjectId, cannot move file'); + return; + } + + if (!currentProjectName) { + console.error('[FileTree] ERROR: No currentProjectName, cannot move file'); + return; + } + + // 自分自身への移動は無視 + if (draggedItem.path === targetFolderPath) { + console.log('[FileTree] Same path, ignoring move'); + return; + } + + // ドラッグしたアイテムを自分の子フォルダに移動しようとしている場合は無視 + if (targetFolderPath.startsWith(draggedItem.path + '/')) { + console.log('[FileTree] Cannot move to child folder'); + return; + } + + try { + console.log('[FileTree] Getting unix commands...'); + const unix = terminalCommandRegistry.getUnixCommands( + currentProjectName, + currentProjectId + ); + console.log('[FileTree] Got unix commands:', !!unix); + + const oldPath = `/projects/${currentProjectName}${draggedItem.path}`; + const newPath = `/projects/${currentProjectName}${targetFolderPath}/`; + + console.log('[FileTree] Moving file/folder:'); + console.log('[FileTree] oldPath:', oldPath); + console.log('[FileTree] newPath:', newPath); + + // mvコマンドを使用(ファイルもフォルダも正しく移動できる) + const result = await unix.mv(oldPath, newPath); + console.log('[FileTree] Move result:', result); + + if (onRefresh) { + console.log('[FileTree] Refreshing file tree'); + setTimeout(onRefresh, 100); + } + } catch (error: any) { + console.error('[FileTree] Failed to move file:', error); + } + }); + return (
    handleDrop(e) : undefined} onDragOver={level === 0 ? handleDragOver : undefined} > + {/* カスタムドラッグレイヤーはpage.tsxで共通表示 */} + {items.map(item => { const isExpanded = expandedFolders.has(item.id); const isIgnored = @@ -324,131 +816,27 @@ export default function FileTree({ ? isPathIgnored(gitignoreRules, item.path.replace(/^\/+/, ''), item.type === 'folder') : false; return ( -
    handleDrop(e, item.path) : undefined} - onDragOver={item.type === 'folder' ? handleDragOver : undefined} - > -
    handleItemClick(item)} - onContextMenu={e => handleContextMenu(e, item)} - onMouseEnter={() => setHoveredItemId(item.id)} - onMouseLeave={() => setHoveredItemId(null)} - onTouchStart={e => { - handleTouchStart(e, item); - setHoveredItemId(item.id); - }} - onTouchEnd={() => { - handleTouchEnd(); - setHoveredItemId(null); - }} - onTouchMove={() => { - handleTouchMove(); - setHoveredItemId(null); - }} - onTouchCancel={() => { - handleTouchEnd(); - setHoveredItemId(null); - }} - > - {item.type === 'folder' ? ( - <> - {isExpanded ? ( - - ) : ( - - )} - { - const iconPath = isExpanded - ? getIconForOpenFolder(item.name) || - getIconForFolder(item.name) || - getIconForFolder('') - : getIconForFolder(item.name) || getIconForFolder(''); - if (iconPath && iconPath.endsWith('.svg')) { - return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath - .split('/') - .pop()}`; - } - return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/folder.svg`; - })()} - alt="folder" - style={{ - width: 16, - height: 16, - verticalAlign: 'middle', - opacity: isIgnored ? 0.55 : 1, - }} - /> - - ) : ( - <> -
    - { - const iconPath = getIconForFile(item.name) || getIconForFile(''); - if (iconPath && iconPath.endsWith('.svg')) { - return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/${iconPath - .split('/') - .pop()}`; - } - return `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/vscode-icons/file.svg`; - })()} - alt="file" - style={{ - width: 16, - height: 16, - verticalAlign: 'middle', - opacity: isIgnored ? 0.55 : 1, - }} - /> - - )} - - {item.name} - -
    - {item.type === 'folder' && item.children && isExpanded && ( - - )} -
    + item={item} + level={level} + isExpanded={isExpanded} + isIgnored={isIgnored} + hoveredItemId={hoveredItemId} + colors={colors} + currentProjectName={currentProjectName} + currentProjectId={currentProjectId} + onRefresh={onRefresh} + onItemClick={handleItemClick} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchMove} + setHoveredItemId={setHoveredItemId} + handleNativeFileDrop={handleDrop} + handleDragOver={handleDragOver} + onInternalFileDrop={internalDropHandler} + /> ); })} diff --git a/src/components/Left/RunPanel.tsx b/src/components/Left/RunPanel.tsx index 16d4affa..94b9cdbc 100644 --- a/src/components/Left/RunPanel.tsx +++ b/src/components/Left/RunPanel.tsx @@ -1,14 +1,14 @@ import clsx from 'clsx'; import { Play, Square, Code, Trash2 } from 'lucide-react'; import { useState, useRef, useEffect } from 'react'; -import { parseGitignore, isPathIgnored } from '@/engine/core/gitignore'; -import OperationWindow from '@/components/OperationWindow'; +import OperationWindow from '@/components/OperationWindow'; import { LOCALSTORAGE_KEY } from '@/context/config'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; -import { executeNodeFile } from '@/engine/runtime/nodeRuntime'; +import { parseGitignore, isPathIgnored } from '@/engine/core/gitignore'; import { initPyodide, runPythonWithSync, setCurrentProject } from '@/engine/runtime/pyodideRuntime'; +import { runtimeRegistry } from '@/engine/runtime/RuntimeRegistry'; interface RunPanelProps { currentProject: { id: string; name: string } | null; @@ -207,18 +207,24 @@ export default function RunPanel({ currentProject, files }: RunPanelProps) { const pythonResult = await pyodide.runPythonAsync(cleanCode); addOutput(String(pythonResult), 'log'); } else { - // Node.js実行 - 一時ファイルとして実行 - // 一時ファイルをIndexedDBに作成 - const { fileRepository } = await import('@/engine/core/fileRepository'); - await fileRepository.createFile(currentProject.id, '/temp-code.js', inputCode, 'file'); - - await executeNodeFile({ + // Node.js実行 - RuntimeRegistryを使用 + const runtime = runtimeRegistry.getRuntime('nodejs'); + if (!runtime) { + addOutput('Node.js runtime not available', 'error'); + return; + } + + const result = await runtime.executeCode?.(inputCode, { projectId: currentProject.id, projectName: currentProject.name, filePath: '/temp-code.js', debugConsole: createDebugConsole(), onInput: createOnInput(), }); + + if (result?.stderr) { + addOutput(result.stderr, 'error'); + } } } catch (error) { addOutput(`Error: ${(error as Error).message}`, 'error'); @@ -232,42 +238,35 @@ export default function RunPanel({ currentProject, files }: RunPanelProps) { const executeFile = async () => { if (!selectedFile || !currentProject) return; setIsRunning(true); - const fileObj = executableFiles.find(f => f.path === selectedFile); - const lang = fileObj?.lang || (selectedFile.endsWith('.py') ? 'python' : 'node'); - addOutput(lang === 'python' ? `> python ${selectedFile}` : `> node ${selectedFile}`, 'input'); + const filePath = `/${selectedFile}`; + + // RuntimeRegistryからランタイムを取得 + const runtime = runtimeRegistry.getRuntimeForFile(filePath); + + if (!runtime) { + addOutput(`No runtime found for ${selectedFile}`, 'error'); + setIsRunning(false); + return; + } + + addOutput(`> ${runtime.name} ${selectedFile}`, 'input'); localStorage.setItem(LOCALSTORAGE_KEY.LAST_EXECUTE_FILE, selectedFile); + try { - if (lang === 'node') { - // Node.js実行 - await executeNodeFile({ - projectId: currentProject.id, - projectName: currentProject.name, - filePath: `/${selectedFile}`, - debugConsole: createDebugConsole(), - onInput: createOnInput(), - }); - } else { - // Python実行 - if (!isPyodideReady) { - addOutput(t('run.runtimeNotReady'), 'error'); - return; - } - if (!fileObj || !fileObj.content) { - addOutput(t('run.fileContentError'), 'error'); - return; - } - // runPythonWithSyncで自動同期 - const pythonResult = await runPythonWithSync(fileObj.content, currentProject.id); - if (pythonResult.stderr) { - addOutput(pythonResult.stderr, 'error'); - } else if (pythonResult.stdout) { - addOutput(pythonResult.stdout, 'log'); - } else if (pythonResult.result) { - addOutput(String(pythonResult.result), 'log'); - } else { - addOutput(t('run.noOutput'), 'log'); - } + const result = await runtime.execute({ + projectId: currentProject.id, + projectName: currentProject.name, + filePath, + debugConsole: createDebugConsole(), + onInput: createOnInput(), + }); + + if (result.stderr) { + addOutput(result.stderr, 'error'); + } else if (result.stdout) { + addOutput(result.stdout, 'log'); } + // Don't show "no output" message - if there's no output, show nothing } catch (error) { addOutput(`Error: ${(error as Error).message}`, 'error'); } finally { diff --git a/src/components/OperationWindow.tsx b/src/components/OperationWindow.tsx index 255c3f86..bcf6ac8d 100644 --- a/src/components/OperationWindow.tsx +++ b/src/components/OperationWindow.tsx @@ -7,7 +7,6 @@ import { getIconForFile } from 'vscode-icons-js'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; import { parseGitignore, isPathIgnored } from '@/engine/core/gitignore'; -import { useProject } from '@/engine/core/project'; import { formatKeyComboForDisplay } from '@/hooks/useKeyBindings'; import { useSettings } from '@/hooks/useSettings'; import { useTabStore } from '@/stores/tabStore'; @@ -147,7 +146,6 @@ export default function OperationWindow({ const inputRef = useRef(null); const listRef = useRef(null); const [portalEl] = useState(() => (typeof document !== 'undefined' ? document.createElement('div') : null)); - const { currentProject } = useProject(); const { isExcluded } = useSettings(); // 固定アイテム高さを定義(スクロール計算と見た目の基準にする) const ITEM_HEIGHT = 20; // slightly more compact diff --git a/src/components/PaneContainer.tsx b/src/components/PaneContainer.tsx index e619b7d0..7c9cc4c8 100644 --- a/src/components/PaneContainer.tsx +++ b/src/components/PaneContainer.tsx @@ -1,16 +1,17 @@ // src/components/PaneContainer.tsx 'use client'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo } from 'react'; import { useDrop } from 'react-dnd'; import PaneResizer from '@/components/PaneResizer'; import TabBar from '@/components/Tab/TabBar'; import { Breadcrumb } from '@/components/Tab/Breadcrumb'; +import { DND_TAB, DND_FILE_TREE_ITEM, isTabDragItem, isFileTreeDragItem } from '@/constants/dndTypes'; import { useTheme } from '@/context/ThemeContext'; import { tabRegistry } from '@/engine/tabs/TabRegistry'; import { useTabStore } from '@/stores/tabStore'; -import type { EditorPane } from '@/types'; +import type { EditorPane, FileItem } from '@/types'; interface PaneContainerProps { pane: EditorPane; @@ -32,6 +33,19 @@ export const useGitContext = () => { return context; }; +// ペインをフラット化してリーフペインの数をカウント +function flattenPanes(paneList: EditorPane[]): EditorPane[] { + const result: EditorPane[] = []; + const traverse = (items: EditorPane[]) => { + for (const p of items) { + if (!p.children || p.children.length === 0) result.push(p); + if (p.children) traverse(p.children); + } + }; + traverse(paneList); + return result; +} + /** * PaneContainer: 自律的かつ機能完全なペインコンポーネント * - TabContextを通じた自律的なタブ操作 @@ -40,88 +54,123 @@ export const useGitContext = () => { */ export default function PaneContainer({ pane, setGitRefreshTrigger }: PaneContainerProps) { const { colors } = useTheme(); - const { globalActiveTab, setPanes, panes: allPanes, moveTab, splitPaneAndMoveTab } = useTabStore(); - const [dropZone, setDropZone] = React.useState<'top' | 'bottom' | 'left' | 'right' | 'center' | null>(null); + const { globalActiveTab, activePane, setPanes, panes: allPanes, moveTab, splitPaneAndMoveTab, openTab, splitPaneAndOpenFile } = useTabStore(); + + // リーフペインの数を計算(枠線表示の判定に使用)- パフォーマンスのためメモ化 + const leafPaneCount = useMemo(() => flattenPanes(allPanes).length, [allPanes]); + const [dropZone, setDropZone] = React.useState<'top' | 'bottom' | 'left' | 'right' | 'center' | 'tabbar' | null>(null); + const elementRef = React.useRef(null); + const dropZoneRef = React.useRef(null); + + // dropZone stateが変更されたらrefも更新(drop時に最新の値を参照するため) + React.useEffect(() => { + dropZoneRef.current = dropZone; + }, [dropZone]); + + // ファイルを開くヘルパー関数 + const openFileInPane = React.useCallback((fileItem: FileItem, targetPaneId?: string) => { + if (fileItem.type !== 'file') return; + const defaultEditor = + typeof window !== 'undefined' ? localStorage.getItem('pyxis-defaultEditor') : 'monaco'; + const kind = fileItem.isBufferArray ? 'binary' : 'editor'; + openTab({ ...fileItem, isCodeMirror: defaultEditor === 'codemirror' }, { kind, paneId: targetPaneId || pane.id }); + }, [openTab, pane.id]); - // このペイン自体をドロップターゲットとして扱う + // このペイン自体をドロップターゲットとして扱う(TABとFILE_TREE_ITEM両方受け付け) const [{ isOver }, drop] = useDrop( () => ({ - accept: 'TAB', + accept: [DND_TAB, DND_FILE_TREE_ITEM], drop: (item: any, monitor) => { - if (!item || !item.tabId) return; - - // ドロップ時のゾーンに基づいて処理 - // monitor.getClientOffset() はドロップ時の座標 - // しかし、dropZone state は hover で更新されているはずなのでそれを使うのが簡単だが、 - // drop イベントの瞬間に state が最新かどうかの懸念があるため、再計算が安全。 - // ここでは dropZone state を信頼する(hover で更新されている前提) + const currentDropZone = dropZoneRef.current; + console.log('[PaneContainer] drop called', { item, currentDropZone }); - // ただし、React DnD の drop は非同期ではないので、ref の current 値などを使うのがベストだが、 - // state でも通常は問題ない。 - // 安全のため、ここで再計算を行う。 - - // Note: monitor.getClientOffset() returns { x, y } relative to viewport - // We need bounding rect of the element. - // Since we don't have easy access to the element rect inside drop() without a ref, - // we will rely on the `hover` method to have set the state, OR we can use the state if we trust it. - // Let's try to use the state first. If it's null, we default to moveTab (center). + // FILE_TREE_ITEMの場合 + if (isFileTreeDragItem(item)) { + const fileItem = item.item as FileItem; + console.log('[PaneContainer] File dropped from tree:', { fileItem, currentDropZone }); + + // ファイルのみ処理(フォルダは無視) + if (fileItem.type === 'file') { + // TabBar上またはcenterの場合は単純にファイルを開く + if (!currentDropZone || currentDropZone === 'center' || currentDropZone === 'tabbar') { + openFileInPane(fileItem); + } else { + // 端にドロップした場合はペイン分割して開く + const direction = (currentDropZone === 'top' || currentDropZone === 'bottom') ? 'horizontal' : 'vertical'; + const position = (currentDropZone === 'top' || currentDropZone === 'left') ? 'before' : 'after'; + + // splitPaneAndOpenFileがあればそれを使用、なければ手動で処理 + if (splitPaneAndOpenFile) { + splitPaneAndOpenFile(pane.id, direction, fileItem, position); + } else { + // フォールバック:単純にファイルを開く + openFileInPane(fileItem); + } + } + } + setDropZone(null); + return; + } - if (!dropZone || dropZone === 'center') { + // TABの場合は既存のタブ移動ロジック + if (isTabDragItem(item)) { + if (!currentDropZone || currentDropZone === 'center' || currentDropZone === 'tabbar') { if (item.fromPaneId === pane.id) return; // 同じペインなら無視 moveTab(item.fromPaneId, pane.id, item.tabId); - } else { + } else { // Split logic - // Top/Bottom -> Stacked -> horizontal layout - // Left/Right -> Side-by-side -> vertical layout - const direction = (dropZone === 'top' || dropZone === 'bottom') ? 'horizontal' : 'vertical'; - const side = (dropZone === 'top' || dropZone === 'left') ? 'before' : 'after'; + const direction = (currentDropZone === 'top' || currentDropZone === 'bottom') ? 'horizontal' : 'vertical'; + const side = (currentDropZone === 'top' || currentDropZone === 'left') ? 'before' : 'after'; splitPaneAndMoveTab(pane.id, direction, item.tabId, side); + } } setDropZone(null); }, hover: (item, monitor) => { if (!monitor.isOver({ shallow: true })) { - setDropZone(null); - return; + setDropZone(null); + return; } const clientOffset = monitor.getClientOffset(); - if (!clientOffset) return; + if (!clientOffset || !elementRef.current) return; - // 要素の矩形を取得する必要がある - // dropRef で取得した node を使う - // しかし dropRef は関数なので、useRef で node を保持する必要がある - if (elementRef.current) { - const rect = elementRef.current.getBoundingClientRect(); - const x = clientOffset.x - rect.left; - const y = clientOffset.y - rect.top; - const w = rect.width; - const h = rect.height; + const rect = elementRef.current.getBoundingClientRect(); + const x = clientOffset.x - rect.left; + const y = clientOffset.y - rect.top; + const w = rect.width; + const h = rect.height; - // ゾーン判定 (20% threshold for edges) - const thresholdX = w * 0.25; - const thresholdY = h * 0.25; + // TabBarの高さ(約40px) + const tabBarHeight = 40; + + // TabBar上にいる場合 + if (y < tabBarHeight) { + setDropZone('tabbar'); + return; + } - let zone: 'top' | 'bottom' | 'left' | 'right' | 'center' = 'center'; + // ゾーン判定 (25% threshold for edges) + const thresholdX = w * 0.25; + const thresholdY = h * 0.25; - if (y < thresholdY) zone = 'top'; - else if (y > h - thresholdY) zone = 'bottom'; - else if (x < thresholdX) zone = 'left'; - else if (x > w - thresholdX) zone = 'right'; + let zone: 'top' | 'bottom' | 'left' | 'right' | 'center' = 'center'; - setDropZone(zone); - } + if (y < thresholdY + tabBarHeight) zone = 'top'; + else if (y > h - thresholdY) zone = 'bottom'; + else if (x < thresholdX) zone = 'left'; + else if (x > w - thresholdX) zone = 'right'; + + setDropZone(zone); }, collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }), }), }), - [pane.id, dropZone] // dropZone を依存配列に入れることで drop 内で最新の state を参照できる可能性が高まる + [pane.id, moveTab, splitPaneAndMoveTab, openFileInPane, splitPaneAndOpenFile] ); - const elementRef = React.useRef(null); - // 子ペインがある場合は分割レイアウトをレンダリング if (pane.children && pane.children.length > 0) { return ( @@ -178,11 +227,9 @@ export default function PaneContainer({ pane, setGitRefreshTrigger }: PaneContai const updatePaneRecursive = (panes: EditorPane[]): EditorPane[] => { return panes.map(p => { if (p.id === pane.id) { - // 該当するペインの子を更新 return { ...p, children: updatedChildren }; } if (p.children) { - // 再帰的に探索 return { ...p, children: updatePaneRecursive(p.children) }; } return p; @@ -202,18 +249,19 @@ export default function PaneContainer({ pane, setGitRefreshTrigger }: PaneContai // リーフペイン(実際のエディタ)をレンダリング const activeTab = pane.tabs.find(tab => tab.id === pane.activeTabId); - const isGloballyActive = globalActiveTab === pane.activeTabId; + const isActivePane = activePane === pane.id; + // isActive: グローバルアクティブタブが現在表示しているタブと一致する場合 + // activeTab?.id を使用することで、pane.activeTabId との比較ではなく実際に表示しているタブのIDを使用 + const isGloballyActive = globalActiveTab === activeTab?.id; // TabRegistryからコンポーネントを取得 const TabComponent = activeTab ? tabRegistry.get(activeTab.kind)?.component : null; - // React の `ref` に渡すときの型不整合を避けるため、コールバック ref を用いる const dropRef = (node: HTMLDivElement | null) => { elementRef.current = node; try { if (typeof drop === 'function') { - // react-dnd の drop へ渡す際に any を許容 (drop as any)(node); } } catch (err) { @@ -221,6 +269,65 @@ export default function PaneContainer({ pane, setGitRefreshTrigger }: PaneContai } }; + // ドロップゾーンオーバーレイのスタイルを計算 + const getDropOverlayStyle = (): React.CSSProperties | null => { + if (!isOver || !dropZone) return null; + + const baseStyle: React.CSSProperties = { + position: 'absolute', + zIndex: 50, + pointerEvents: 'none', + }; + + // TabBar上の場合:青いハイライト(ペイン分割なし、ファイルを開くだけ) + if (dropZone === 'tabbar') { + return { + ...baseStyle, + top: 0, + left: 0, + right: 0, + height: '40px', + backgroundColor: 'rgba(59, 130, 246, 0.15)', + border: '2px solid #3b82f6', + }; + } + + // Center:青いハイライト(ペイン移動/ファイルを開く) + if (dropZone === 'center') { + return { + ...baseStyle, + inset: 0, + backgroundColor: 'rgba(59, 130, 246, 0.1)', + border: '2px solid #3b82f6', + }; + } + + // 端にドロップ:白いオーバーレイ(ペイン分割) + const splitStyle: React.CSSProperties = { + ...baseStyle, + backgroundColor: 'rgba(255, 255, 255, 0.25)', + border: '2px dashed rgba(59, 130, 246, 0.5)', + }; + + switch (dropZone) { + case 'top': + return { ...splitStyle, top: 0, left: 0, right: 0, height: '50%' }; + case 'bottom': + return { ...splitStyle, bottom: 0, left: 0, right: 0, height: '50%' }; + case 'left': + return { ...splitStyle, top: 0, left: 0, bottom: 0, width: '50%' }; + case 'right': + return { ...splitStyle, top: 0, right: 0, bottom: 0, width: '50%' }; + default: + return null; + } + }; + + const overlayStyle = getDropOverlayStyle(); + + // ペインが1つの場合は枠線を非表示、複数の場合はソフトな緑の強調 + const showActiveBorder = leafPaneCount > 1 && isActivePane; + return (
    {/* ドロップゾーンのオーバーレイ */} - {isOver && dropZone && ( -
    - )} + {overlayStyle &&
    } {/* タブバー */} @@ -267,6 +354,7 @@ export default function PaneContainer({ pane, setGitRefreshTrigger }: PaneContai
    {activeTab && TabComponent ? ( diff --git a/src/components/PaneNavigator.tsx b/src/components/PaneNavigator.tsx new file mode 100644 index 00000000..4cd734ec --- /dev/null +++ b/src/components/PaneNavigator.tsx @@ -0,0 +1,321 @@ +'use client'; + +import React, { useCallback, useEffect, useState, useMemo, memo } from 'react'; +import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Columns2, Rows2, Trash2, CornerDownLeft } from 'lucide-react'; + +import { useTheme } from '@/context/ThemeContext'; +import { EditorPane } from '@/engine/tabs/types'; +import { useTabStore } from '@/stores/tabStore'; + +interface PaneNavigatorProps { + isOpen: boolean; + onClose: () => void; +} + +interface PaneItemProps { + pane: EditorPane; + isSelected: boolean; + isActive: boolean; + onSelect: (paneId: string) => void; + onActivate: (paneId: string) => void; + colors: any; + index: number; +} + +// Larger pane item with big number +const PaneItem = memo(function PaneItem({ pane, isSelected, isActive, onSelect, onActivate, colors, index }: PaneItemProps) { + const num = index + 1; + return ( +
    { e.stopPropagation(); onSelect(pane.id); }} + onDoubleClick={(e) => { e.stopPropagation(); onActivate(pane.id); }} + > + + {num} + +
    + ); +}); + +interface RecursivePaneViewProps { + pane: EditorPane; + selectedPaneId: string | null; + activePane: string | null; + onSelect: (paneId: string) => void; + onActivate: (paneId: string) => void; + colors: any; + leafIndexRef: { current: number }; +} + +// Recursive pane view - mirrors actual layout +const RecursivePaneView = memo(function RecursivePaneView({ pane, selectedPaneId, activePane, onSelect, onActivate, colors, leafIndexRef }: RecursivePaneViewProps) { + if (pane.children && pane.children.length > 0) { + const isVertical = pane.layout === 'vertical'; + return ( +
    + {pane.children.map((child) => ( +
    + +
    + ))} +
    + ); + } + const currentIndex = leafIndexRef.current++; + return ; +}); + +// Calculate layout dimensions based on pane structure +function calculateLayoutDimensions(panes: EditorPane[]): { width: number; height: number } { + const baseSize = 60; // Base size for each pane item + const gap = 4; + + // Calculate dimensions for a single pane tree + function calcSize(pane: EditorPane): { w: number; h: number } { + if (!pane.children || pane.children.length === 0) { + return { w: baseSize, h: baseSize }; + } + + const childSizes = pane.children.map(c => calcSize(c)); + const isVertical = pane.layout === 'vertical'; + + if (isVertical) { + // Horizontal arrangement (side by side) + const totalW = childSizes.reduce((sum, s) => sum + s.w, 0) + (childSizes.length - 1) * gap; + const maxH = Math.max(...childSizes.map(s => s.h)); + return { w: totalW, h: maxH }; + } else { + // Vertical arrangement (stacked) + const maxW = Math.max(...childSizes.map(s => s.w)); + const totalH = childSizes.reduce((sum, s) => sum + s.h, 0) + (childSizes.length - 1) * gap; + return { w: maxW, h: totalH }; + } + } + + // Root level panes are always arranged horizontally + let totalWidth = 0; + let maxHeight = 0; + + for (const pane of panes) { + const size = calcSize(pane); + totalWidth += size.w; + maxHeight = Math.max(maxHeight, size.h); + } + + totalWidth += (panes.length - 1) * gap; + + return { width: totalWidth, height: maxHeight }; +} + +/** + * PaneNavigator: コンパクトなペイン操作モーダル + * - 数字キー1-9で直接選択・アクティブ化 + * - 再帰的レイアウト表示 + */ +export default function PaneNavigator({ isOpen, onClose }: PaneNavigatorProps) { + const { colors } = useTheme(); + const { panes, activePane, setActivePane, splitPane, removePane } = useTabStore(); + const [selectedPaneId, setSelectedPaneId] = useState(null); + + // Flatten panes for navigation + const flattenedPanes = useMemo(() => { + const result: EditorPane[] = []; + const traverse = (list: EditorPane[]) => { + for (const p of list) { + if (!p.children || p.children.length === 0) result.push(p); + if (p.children) traverse(p.children); + } + }; + traverse(panes); + return result; + }, [panes]); + + // Initialize selection + useEffect(() => { + if (isOpen) { + const active = flattenedPanes.find(p => p.id === activePane); + setSelectedPaneId(active?.id || flattenedPanes[0]?.id || null); + } else { + setSelectedPaneId(null); + } + }, [isOpen, activePane, flattenedPanes]); + + const handleSelect = useCallback((id: string) => setSelectedPaneId(id), []); + + const handleActivate = useCallback((id: string) => { + setActivePane(id); + const pane = flattenedPanes.find(p => p.id === id); + if (pane?.activeTabId) { + useTabStore.getState().activateTab(id, pane.activeTabId); + } + onClose(); + }, [setActivePane, flattenedPanes, onClose]); + + const handleSplit = useCallback((dir: 'vertical' | 'horizontal') => { + if (!selectedPaneId) return; + splitPane(selectedPaneId, dir); + requestAnimationFrame(() => { + const newFlat: EditorPane[] = []; + const traverse = (list: EditorPane[]) => { + for (const p of list) { + if (!p.children || p.children.length === 0) newFlat.push(p); + if (p.children) traverse(p.children); + } + }; + traverse(useTabStore.getState().panes); + const newPane = newFlat.find(p => !flattenedPanes.some(fp => fp.id === p.id)); + if (newPane) setSelectedPaneId(newPane.id); + }); + }, [selectedPaneId, splitPane, flattenedPanes]); + + const handleDelete = useCallback(() => { + if (!selectedPaneId || flattenedPanes.length <= 1) return; + const idx = flattenedPanes.findIndex(p => p.id === selectedPaneId); + const nextId = flattenedPanes[idx > 0 ? idx - 1 : 1]?.id || null; + removePane(selectedPaneId); + requestAnimationFrame(() => setSelectedPaneId(nextId)); + }, [selectedPaneId, flattenedPanes, removePane]); + + // Keyboard handler with number keys + useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + const key = e.key; + + // Number keys 1-9 for direct selection + if (key >= '1' && key <= '9') { + e.preventDefault(); + e.stopPropagation(); + const idx = parseInt(key) - 1; + if (idx < flattenedPanes.length) { + handleActivate(flattenedPanes[idx].id); + } + return; + } + + if (['Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'h', 'j', 'k', 'l', 'v', 's', 'd'].includes(key)) { + e.preventDefault(); + e.stopPropagation(); + } + const len = flattenedPanes.length; + const idx = flattenedPanes.findIndex(p => p.id === selectedPaneId); + switch (key) { + case 'Escape': onClose(); break; + case 'Enter': if (selectedPaneId) handleActivate(selectedPaneId); break; + case 'ArrowLeft': case 'h': case 'ArrowUp': case 'k': + if (idx > 0) setSelectedPaneId(flattenedPanes[idx - 1].id); + break; + case 'ArrowRight': case 'l': case 'ArrowDown': case 'j': + if (idx < len - 1) setSelectedPaneId(flattenedPanes[idx + 1].id); + break; + case 'v': handleSplit('vertical'); break; + case 's': handleSplit('horizontal'); break; + case 'd': handleDelete(); break; + } + }; + window.addEventListener('keydown', handler, { capture: true }); + return () => window.removeEventListener('keydown', handler, { capture: true }); + }, [isOpen, selectedPaneId, flattenedPanes, onClose, handleActivate, handleSplit, handleDelete]); + + if (!isOpen) return null; + + const leafIndexRef = { current: 0 }; + const { width, height } = calculateLayoutDimensions(panes); + + return ( +
    + {/* Pane Layout */} +
    e.stopPropagation()} + > + {panes.map((pane) => ( +
    + +
    + ))} +
    + {/* Hint - positioned below with gap */} +
    e.stopPropagation()} + > +
    + {/* Number keys */} + 1-9 + + {/* Navigation */} +
    + + + + +
    + + {/* Split */} +
    + + v + + s +
    + + {/* Delete */} +
    + + d +
    + + {/* Enter */} +
    + +
    +
    +
    +
    + ); +} diff --git a/src/components/PaneResizer.tsx b/src/components/PaneResizer.tsx index b0b332b1..716ca252 100644 --- a/src/components/PaneResizer.tsx +++ b/src/components/PaneResizer.tsx @@ -4,6 +4,7 @@ import React, { useState, useRef, useCallback } from 'react'; import { useTheme } from '@/context/ThemeContext'; +import { usePaneResize } from '@/hooks/usePaneResize'; interface PaneResizerProps { direction: 'horizontal' | 'vertical'; @@ -13,6 +14,10 @@ interface PaneResizerProps { minSize?: number; } +/** + * ペイン間リサイザーコンポーネント + * usePaneResizeフックを使用してマウス/タッチイベントを処理 + */ export default function PaneResizer({ direction, onResize, @@ -24,99 +29,24 @@ export default function PaneResizer({ const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); - const startResize = useCallback( - (clientX: number, clientY: number) => { - // 親コンテナを見つける - let parentContainer = containerRef.current?.parentElement; - while (parentContainer && !parentContainer.classList.contains('flex')) { - parentContainer = parentContainer.parentElement; - } - - if (!parentContainer) return; - - const containerRect = parentContainer.getBoundingClientRect(); - const containerStart = direction === 'vertical' ? containerRect.left : containerRect.top; - const containerSize = direction === 'vertical' ? containerRect.width : containerRect.height; - - // 初期の分割点の位置(ピクセル) - const initialSplitPos = (leftSize / 100) * containerSize; - - const handleMove = (moveX: number, moveY: number) => { - const currentPos = direction === 'vertical' ? moveX : moveY; - const relativePos = currentPos - containerStart; - - // 新しい分割点の位置を計算 - const newSplitPos = Math.max( - (minSize * containerSize) / 100, - Math.min(relativePos, containerSize - (minSize * containerSize) / 100) - ); - - // パーセントに変換 - const newLeftPercent = (newSplitPos / containerSize) * 100; - const newRightPercent = 100 - newLeftPercent; - - // 最小サイズチェック - if (newLeftPercent >= minSize && newRightPercent >= minSize) { - onResize(newLeftPercent, newRightPercent); - } - }; - - const handleStop = () => { - setIsDragging(false); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('touchmove', handleTouchMove); - document.removeEventListener('touchend', handleTouchEnd); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - - const handleMouseMove = (e: MouseEvent) => { - e.preventDefault(); - handleMove(e.clientX, e.clientY); - }; - - const handleTouchMove = (e: TouchEvent) => { - e.preventDefault(); - const touch = e.touches[0]; - handleMove(touch.clientX, touch.clientY); - }; - - const handleMouseUp = () => { - handleStop(); - }; - - const handleTouchEnd = () => { - handleStop(); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('touchmove', handleTouchMove, { passive: false }); - document.addEventListener('touchend', handleTouchEnd); - document.body.style.cursor = direction === 'vertical' ? 'col-resize' : 'row-resize'; - document.body.style.userSelect = 'none'; - }, - [direction, onResize, leftSize, minSize] - ); + const { startResize } = usePaneResize({ + direction, + leftSize, + minSize, + onResize, + containerRef, + }); const handleMouseDown = useCallback( (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - startResize(e.clientX, e.clientY); + startResize(e, setIsDragging); }, [startResize] ); const handleTouchStart = useCallback( (e: React.TouchEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - const touch = e.touches[0]; - startResize(touch.clientX, touch.clientY); + startResize(e, setIsDragging); }, [startResize] ); diff --git a/src/components/Tab/CodeEditor.tsx b/src/components/Tab/CodeEditor.tsx index ea76971b..57bd6a21 100644 --- a/src/components/Tab/CodeEditor.tsx +++ b/src/components/Tab/CodeEditor.tsx @@ -43,6 +43,8 @@ interface CodeEditorProps { isCodeMirror?: boolean; // 即時ローカル編集反映ハンドラ: 全ペーンの同ファイルタブに対して isDirty を立てる onImmediateContentChange?: (tabId: string, content: string) => void; + // タブがアクティブかどうか(フォーカス制御用) + isActive?: boolean; } export default function CodeEditor({ @@ -53,6 +55,7 @@ export default function CodeEditor({ onImmediateContentChange, currentProject, wordWrapConfig, + isActive = false, }: CodeEditorProps) { // プロジェクトIDは優先的に props の currentProject?.id を使い、なければ activeTab の projectId を参照 const projectId = @@ -271,6 +274,7 @@ export default function CodeEditor({ tabSize={settings?.editor.tabSize ?? 2} insertSpaces={settings?.editor.insertSpaces ?? true} fontSize={settings?.editor.fontSize ?? 14} + isActive={isActive} /> void; // 編集内容の保存用(デバウンス後) // 即時反映用ハンドラ: 編集が発生したら即座に呼ばれる(isDirty フラグ立てに使用) onImmediateContentChange?: (content: string) => void; + // 折り返し設定(CodeEditorと同じくユーザー設定から取得) + wordWrapConfig?: 'on' | 'off'; } const DiffTab: React.FC = ({ @@ -31,7 +35,9 @@ const DiffTab: React.FC = ({ editable = false, onContentChange, onImmediateContentChange, + wordWrapConfig = 'off', }) => { + const { colors, themeName } = useTheme(); // 各diff領域へのref const diffRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -154,6 +160,13 @@ const DiffTab: React.FC = ({ ) => { editorsRef.current.set(idx, editor); + // テーマ定義と適用 + try { + defineAndSetMonacoThemes(monaco, colors, themeName); + } catch (e) { + console.warn('[DiffTab] Failed to define/set themes:', e); + } + // モデルを取得して保存 const diffModel = editor.getModel(); if (diffModel) { @@ -269,17 +282,30 @@ const DiffTab: React.FC = ({
    )}
    1 ? 'auto' : 'hidden', + display: 'flex', + flexDirection: 'column', + }} > {diffs.map((diff, idx) => { const showLatter = diff.latterFullPath !== diff.formerFullPath; + // 単一ファイルの場合は全高さを使用、複数ファイルの場合は固定高さ + const isSingleFile = diffs.length === 1; return (
    { diffRefs.current[idx] = el ?? null; }} - style={{ marginBottom: 24, borderBottom: '1px solid #333', scrollMarginTop: 24 }} + style={{ + ...(isSingleFile + ? { flex: 1, minHeight: 0, height: '100%', display: 'flex', flexDirection: 'column' } + : { marginBottom: 24, scrollMarginTop: 24 }), + borderBottom: isSingleFile ? 'none' : '1px solid #333', + }} >
    = ({ fontSize: 13, display: 'flex', justifyContent: 'space-between', + flexShrink: 0, }} >
    @@ -304,7 +331,7 @@ const DiffTab: React.FC = ({
    -
    +
    {(() => { const formerBinary = isBinaryContent(diff.formerContent); const latterBinary = isBinaryContent(diff.latterContent); @@ -350,7 +377,7 @@ const DiffTab: React.FC = ({ minimap: { enabled: false }, scrollBeyondLastLine: false, fontSize: 14, - wordWrap: 'on', + wordWrap: wordWrapConfig, lineNumbers: 'on', automaticLayout: true, }} diff --git a/src/components/Tab/InlineHighlightedCode.tsx b/src/components/Tab/InlineHighlightedCode.tsx index 24bfd2d2..84a8ac45 100644 --- a/src/components/Tab/InlineHighlightedCode.tsx +++ b/src/components/Tab/InlineHighlightedCode.tsx @@ -65,12 +65,12 @@ const createPatterns = (lang: string): PatternDef[] => { { type: 'text', regex: /^./ }, ]; - // JavaScript/TypeScript patterns + // JavaScript/TypeScript patterns - note: template strings are handled specially in tokenizeJsTs const jstsPatterns: PatternDef[] = [ { type: 'docComment', regex: /^\/\*\*[\s\S]*?\*\// }, { type: 'comment', regex: /^\/\*[\s\S]*?\*\// }, { type: 'comment', regex: /^\/\/[^\n]*/ }, - { type: 'templateString', regex: /^`(?:[^`\\]|\\[\s\S])*`/ }, + // Template strings handled by tokenizeJsTs for proper ${} support with nested templates { type: 'string', regex: /^"(?:[^"\\]|\\[\s\S])*?"/ }, { type: 'string', regex: /^'(?:[^'\\]|\\[\s\S])*?'/ }, { type: 'regex', regex: /^\/(?!\/)(?:[^/\\[\n]|\\[\s\S]|\[[^\]\\]*(?:\\[\s\S][^\]\\]*)*\])+\/[gimsy]*/ }, @@ -303,13 +303,13 @@ const createPatterns = (lang: string): PatternDef[] => { { type: 'identifier', regex: /^[a-zA-Z_][a-zA-Z0-9_]*/ }, ]; - // Shell/Bash patterns + // Shell/Bash patterns - note: double-quoted strings are handled specially in tokenizeShell const shellPatterns: PatternDef[] = [ { type: 'comment', regex: /^#[^\n]*/ }, { type: 'templateString', regex: /^\$"(?:[^"\\]|\\[\s\S])*?"/ }, - { type: 'string', regex: /^"(?:[^"\\$]|\\[\s\S])*?"/ }, + // Double-quoted strings handled by tokenizeShell for proper $() and ${} support { type: 'string', regex: /^'[^']*'/ }, - { type: 'method', regex: /^\$\([^)]*\)/ }, + // $() command substitution handled by tokenizeShell { type: 'method', regex: /^`[^`]*`/ }, { type: 'variable', regex: /^\$\{[^}]*\}/ }, { type: 'variable', regex: /^\$[a-zA-Z_][a-zA-Z0-9_]*/ }, @@ -546,8 +546,343 @@ const createPatterns = (lang: string): PatternDef[] => { return [...langPatterns, ...commonPatterns]; }; +// Helper function to parse a double-quoted string in shell +// Handles nested $(), ${}, and escaped characters correctly +const parseShellDoubleQuotedString = (code: string, startIndex: number): string => { + let j = startIndex + 1; // skip opening " + let result = '"'; + + while (j < code.length) { + const char = code[j]; + + if (char === '"') { + return result + '"'; + } + + if (char === '\\' && j + 1 < code.length) { + result += code.slice(j, j + 2); + j += 2; + continue; + } + + if (char === '$' && j + 1 < code.length && code[j + 1] === '(') { + const sub = parseShellCommandSubstitution(code, j); + result += sub; + j += sub.length; + continue; + } + + if (char === '`') { + // Find matching backtick, handling escape sequences + let endIndex = j + 1; + while (endIndex < code.length) { + if (code[endIndex] === '`') { + break; + } + if (code[endIndex] === '\\' && endIndex + 1 < code.length) { + endIndex += 2; + continue; + } + endIndex++; + } + if (endIndex < code.length) { + result += code.slice(j, endIndex + 1); + j = endIndex + 1; + continue; + } + // Unclosed backtick - just include the rest and move to end + result += code.slice(j); + break; + } + + result += char; + j++; + } + + return result; +}; + +// Helper function to parse command substitution $() in shell +// Handles nested parentheses and quoted strings correctly +const parseShellCommandSubstitution = (code: string, startIndex: number): string => { + let depth = 0; + let j = startIndex; + + while (j < code.length) { + const char = code[j]; + + if (char === '$' && j + 1 < code.length && code[j + 1] === '(') { + depth++; + j += 2; + continue; + } + + if (char === '(') { + depth++; + j++; + continue; + } + + if (char === ')') { + depth--; + if (depth === 0) { + return code.slice(startIndex, j + 1); + } + j++; + continue; + } + + if (char === '"') { + const str = parseShellDoubleQuotedString(code, j); + j += str.length; + continue; + } + + // In bash, single quotes don't support escape sequences - they are literal + // The string ends at the next single quote (no escaping possible) + if (char === "'") { + const end = code.indexOf("'", j + 1); + if (end !== -1) { + j = end + 1; + continue; + } + } + + if (char === '\\' && j + 1 < code.length) { + j += 2; + continue; + } + + j++; + } + + return code.slice(startIndex, j); +}; + +// Specialized tokenizer for shell/bash that handles quotes correctly +const tokenizeShell = (code: string, patterns: PatternDef[]): Token[] => { + const tokens: Token[] = []; + let i = 0; + + while (i < code.length) { + const char = code[i]; + const rest = code.slice(i); + + // Double quoted string - use special parser + if (char === '"') { + const str = parseShellDoubleQuotedString(code, i); + tokens.push({ type: 'string', value: str }); + i += str.length; + continue; + } + + // $"..." localized string + if (rest.startsWith('$"')) { + const str = parseShellDoubleQuotedString(code, i + 1); + tokens.push({ type: 'templateString', value: '$' + str }); + i += 1 + str.length; + continue; + } + + // Command substitution $() - use special parser + if (rest.startsWith('$(')) { + const sub = parseShellCommandSubstitution(code, i); + tokens.push({ type: 'method', value: sub }); + i += sub.length; + continue; + } + + // Use pattern-based matching for other tokens + let matched = false; + for (const pattern of patterns) { + const match = rest.match(pattern.regex); + if (match) { + tokens.push({ type: pattern.type, value: match[0] }); + i += match[0].length; + matched = true; + break; + } + } + + if (!matched) { + tokens.push({ type: 'text', value: char }); + i++; + } + } + + return tokens; +}; + +// Helper function to parse JS/TS template literal with ${} interpolation +// Handles nested template literals correctly +const parseJsTemplateString = (code: string, startIndex: number): string => { + let j = startIndex + 1; // skip opening ` + let result = '`'; + + while (j < code.length) { + const char = code[j]; + + if (char === '`') { + return result + '`'; + } + + if (char === '\\' && j + 1 < code.length) { + result += code.slice(j, j + 2); + j += 2; + continue; + } + + // Handle ${...} interpolation with nested braces and template literals + if (char === '$' && j + 1 < code.length && code[j + 1] === '{') { + const expr = parseJsTemplateExpression(code, j); + result += expr; + j += expr.length; + continue; + } + + result += char; + j++; + } + + return result; +}; + +// Helper function to parse ${...} expression in JS template literals +// Handles nested braces, strings, and template literals correctly +const parseJsTemplateExpression = (code: string, startIndex: number): string => { + let depth = 0; + let j = startIndex; + + while (j < code.length) { + const char = code[j]; + + if (char === '$' && j + 1 < code.length && code[j + 1] === '{') { + depth++; + j += 2; + continue; + } + + if (char === '{') { + depth++; + j++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + return code.slice(startIndex, j + 1); + } + j++; + continue; + } + + // Handle nested template literals + if (char === '`') { + const str = parseJsTemplateString(code, j); + j += str.length; + continue; + } + + // Handle strings + if (char === '"') { + let endIndex = j + 1; + while (endIndex < code.length) { + if (code[endIndex] === '"') { + break; + } + if (code[endIndex] === '\\' && endIndex + 1 < code.length) { + endIndex += 2; + continue; + } + endIndex++; + } + // Ensure j doesn't go out of bounds + j = Math.min(endIndex + 1, code.length); + continue; + } + + if (char === "'") { + let endIndex = j + 1; + while (endIndex < code.length) { + if (code[endIndex] === "'") { + break; + } + if (code[endIndex] === '\\' && endIndex + 1 < code.length) { + endIndex += 2; + continue; + } + endIndex++; + } + // Ensure j doesn't go out of bounds + j = Math.min(endIndex + 1, code.length); + continue; + } + + if (char === '\\' && j + 1 < code.length) { + j += 2; + continue; + } + + j++; + } + + return code.slice(startIndex, j); +}; + +// Specialized tokenizer for JavaScript/TypeScript that handles template literals correctly +const tokenizeJsTs = (code: string, patterns: PatternDef[]): Token[] => { + const tokens: Token[] = []; + let i = 0; + + while (i < code.length) { + const char = code[i]; + const rest = code.slice(i); + + // Template literal - use special parser + if (char === '`') { + const str = parseJsTemplateString(code, i); + tokens.push({ type: 'templateString', value: str }); + i += str.length; + continue; + } + + // Use pattern-based matching for other tokens + let matched = false; + for (const pattern of patterns) { + const match = rest.match(pattern.regex); + if (match) { + tokens.push({ type: pattern.type, value: match[0] }); + i += match[0].length; + matched = true; + break; + } + } + + if (!matched) { + tokens.push({ type: 'text', value: char }); + i++; + } + } + + return tokens; +}; + // Tokenizer function -const tokenize = (code: string, patterns: PatternDef[]): Token[] => { +const tokenize = (code: string, patterns: PatternDef[], lang?: string): Token[] => { + // Use specialized tokenizer based on language + const normalizedLang = (lang || '').toLowerCase(); + + // Shell/Bash languages + if (['bash', 'sh', 'shell', 'zsh', 'fish'].includes(normalizedLang)) { + return tokenizeShell(code, patterns); + } + + // JavaScript/TypeScript languages + if (['javascript', 'js', 'typescript', 'ts', 'tsx', 'jsx', 'mjs', 'cjs', 'mts', 'cts'].includes(normalizedLang)) { + return tokenizeJsTs(code, patterns); + } + const tokens: Token[] = []; let remaining = code; @@ -674,7 +1009,7 @@ export default function InlineHighlightedCode({ // Highlight function using the new tokenizer const highlight = (code: string): string => { - const tokens = tokenize(code, patterns); + const tokens = tokenize(code, patterns, language); const htmlParts = tokens.map(token => { const escaped = token.value diff --git a/src/components/Tab/MarkdownPreview/CodeBlock.tsx b/src/components/Tab/MarkdownPreview/CodeBlock.tsx new file mode 100644 index 00000000..d328e50d --- /dev/null +++ b/src/components/Tab/MarkdownPreview/CodeBlock.tsx @@ -0,0 +1,38 @@ +import { memo, type ReactNode } from 'react'; + +import { type ThemeColors } from '@/context/ThemeContext'; +import { FileItem } from '@/types'; + +import InlineHighlightedCode from '../InlineHighlightedCode'; + +import Mermaid from './Mermaid'; + +interface MemoizedCodeComponentProps { + className?: string; + children: ReactNode; + colors: ThemeColors; + currentProjectName?: string; + projectFiles?: FileItem[]; +} + +const MemoizedCodeComponent = memo( + ({ className, children, colors }) => { + const match = /language-(\w+)/.exec(className || ''); + const codeString = String(children).replace(/\n$/, '').trim(); + + if (match && match[1] === 'mermaid') { + return ; + } + + if (className && match) { + return ; + } + + // インラインコード: InlineHighlightedCode を使う + return ; + } +); + +MemoizedCodeComponent.displayName = 'MemoizedCodeComponent'; + +export default MemoizedCodeComponent; diff --git a/src/components/Tab/MarkdownPreview/LocalImage.tsx b/src/components/Tab/MarkdownPreview/LocalImage.tsx new file mode 100644 index 00000000..9ad0c4a6 --- /dev/null +++ b/src/components/Tab/MarkdownPreview/LocalImage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState, memo } from 'react'; + +import { useTranslation } from '@/context/I18nContext'; +import type { PreviewTab } from '@/engine/tabs/types'; + +import { loadImageAsDataURL } from '../markdownUtils'; + +interface LocalImageProps { + src: string; + alt: string; + activeTab: PreviewTab; + projectName?: string; + projectId?: string; + baseFilePath?: string; + [key: string]: unknown; +} + +const LocalImage = memo( + ({ src, alt, activeTab, projectName, projectId, ...props }) => { + const [dataUrl, setDataUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + const loadImage = async (): Promise => { + if (!src || !projectName) { + setError(true); + setLoading(false); + return; + } + + // 外部URLの場合はそのまま使用 + if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) { + setDataUrl(src); + setLoading(false); + return; + } + + // ローカル画像の場合はプロジェクトファイルまたはファイルシステムから読み込み + try { + const loadedDataUrl = await loadImageAsDataURL( + src, + projectName, + projectId, + // pass the path of the markdown file so relative paths can be resolved + activeTab.path + ); + if (loadedDataUrl) { + setDataUrl(loadedDataUrl); + console.log('Loaded local image:', src); + setError(false); + } else { + setError(true); + } + } catch (err) { + console.warn('Failed to load local image:', src, err); + setError(true); + } finally { + setLoading(false); + } + }; + + loadImage(); + }, [src, projectName, activeTab.path, projectId]); + + if (loading) { + return ( + + {t ? t('markdownPreview.loadingImage') : '画像を読み込み中...'} + + ); + } + + if (error || !dataUrl) { + return ( + + {t ? t('markdownPreview.imageNotFound', { params: { src } }) : `画像が見つかりません: ${src}`} + + ); + } + + return {alt}; + } +); + +LocalImage.displayName = 'LocalImage'; + +export default LocalImage; diff --git a/src/components/Tab/MarkdownPreview/Mermaid.tsx b/src/components/Tab/MarkdownPreview/Mermaid.tsx new file mode 100644 index 00000000..f9a23f1c --- /dev/null +++ b/src/components/Tab/MarkdownPreview/Mermaid.tsx @@ -0,0 +1,605 @@ +import { ZoomIn, ZoomOut, RefreshCw, Download } from 'lucide-react'; +import mermaid from 'mermaid'; +import { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react'; + +import { useTranslation } from '@/context/I18nContext'; +import { useTheme, type ThemeColors } from '@/context/ThemeContext'; + +import { parseMermaidContent } from '../markdownUtils'; + +import { useIntersectionObserver } from './useIntersectionObserver'; + +interface MermaidProps { + chart: string; + colors: ThemeColors; +} + +interface ZoomState { + scale: number; + translate: { x: number; y: number }; +} + +// グローバルカウンタ: ID衝突を確実に防ぐ +let globalMermaidCounter = 0; + +// グローバルズーム状態ストア: diagramハッシュをキーにしてズーム状態を保持 +const mermaidZoomStore = new Map(); + +// diagram内容からハッシュキーを生成(安定したキー) +const generateDiagramKey = (diagram: string): string => { + try { + const hash = diagram.split('').reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; + }, 0); + return `diagram-${Math.abs(hash)}`; + } catch { + return `diagram-fallback-${Date.now()}`; + } +}; + +// ズーム状態を取得 +const getStoredZoomState = (diagramKey: string): ZoomState | undefined => { + return mermaidZoomStore.get(diagramKey); +}; + +// ズーム状態を保存 +const setStoredZoomState = (diagramKey: string, state: ZoomState): void => { + mermaidZoomStore.set(diagramKey, state); +}; + +// 安全なID生成(非ASCII文字でのエラー回避) +const generateSafeId = (chart: string): string => { + try { + const hash = chart.split('').reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; + }, 0); + return `mermaid-${Math.abs(hash)}-${++globalMermaidCounter}`; + } catch { + return `mermaid-fallback-${++globalMermaidCounter}`; + } +}; + +const Mermaid = memo(({ chart, colors }) => { + const { t } = useTranslation(); + const { themeName } = useTheme(); + const ref = useRef(null); + + // Lazy loading with IntersectionObserver + const { ref: containerRef, hasIntersected } = useIntersectionObserver({ + rootMargin: '200px 0px', // Start loading 200px before coming into view + triggerOnce: true, + }); + + // 設定パースをメモ化(パフォーマンス改善) + const { config, diagram } = useMemo(() => parseMermaidContent(chart), [chart]); + + // diagramキーを生成(ズーム状態の保持に使用) + const diagramKey = useMemo(() => generateDiagramKey(diagram), [diagram]); + + // ID生成をメモ化(chart変更時のみ再生成) + const idRef = useMemo(() => generateSafeId(chart), [chart]); + + // 保存されたズーム状態を取得、なければデフォルト値 + const initialZoomState = useMemo((): ZoomState => { + const stored = getStoredZoomState(diagramKey); + return stored || { scale: 1, translate: { x: 0, y: 0 } }; + }, [diagramKey]); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [svgContent, setSvgContent] = useState(null); + const [zoomState, setZoomState] = useState(initialZoomState); + + const scaleRef = useRef(initialZoomState.scale); + const translateRef = useRef<{ x: number; y: number }>({ ...initialZoomState.translate }); + const isPanningRef = useRef(false); + const lastPointerRef = useRef<{ x: number; y: number } | null>(null); + + // diagramKeyが変わったときに保存されたズーム状態を復元 + useEffect(() => { + const stored = getStoredZoomState(diagramKey); + if (stored) { + setZoomState(stored); + scaleRef.current = stored.scale; + translateRef.current = { ...stored.translate }; + } + }, [diagramKey]); + + // ズーム状態が変わったときに保存 + useEffect(() => { + setStoredZoomState(diagramKey, zoomState); + }, [diagramKey, zoomState]); + + useEffect(() => { + // Don't render until element comes into view + if (!hasIntersected) return; + + let lastTouchDist = 0; + let isPinching = false; + let pinchStartScale = 1; + let pinchStart = { x: 0, y: 0 }; + let isMounted = true; + + const renderMermaid = async (): Promise => { + if (!ref.current || !isMounted) return; + + setIsLoading(true); + setError(null); + scaleRef.current = zoomState.scale; + translateRef.current = { ...zoomState.translate }; + + ref.current.innerHTML = ` +
    + + + + + + ${t ? t('markdownPreview.generatingMermaid') : 'Mermaid図表を生成中...'} +
    + `; + + try { + const isDark = !(themeName && themeName.includes('light')); + const mermaidConfig: Record = { + startOnLoad: false, + theme: isDark ? 'dark' : 'default', + securityLevel: 'loose', + themeVariables: { + fontSize: '8px', + }, + suppressErrorRendering: true, + maxTextSize: 100000, + maxEdges: 2000, + flowchart: { + useMaxWidth: false, + htmlLabels: true, + curve: 'basis', + rankSpacing: 80, + nodeSpacing: 50, + }, + layout: 'dagre', + }; + + if (config.config) { + if (config.config.theme) mermaidConfig.theme = config.config.theme; + if (config.config.themeVariables) { + mermaidConfig.themeVariables = { + ...(mermaidConfig.themeVariables as Record), + ...config.config.themeVariables, + }; + } + if (config.config.flowchart) { + mermaidConfig.flowchart = { + ...(mermaidConfig.flowchart as Record), + ...config.config.flowchart, + }; + } + if (config.config.defaultRenderer === 'elk') { + (mermaidConfig.flowchart as Record).defaultRenderer = 'elk'; + } + if (config.config.layout) { + mermaidConfig.layout = config.config.layout; + if (config.config.layout === 'elk') { + (mermaidConfig.flowchart as Record).defaultRenderer = 'elk'; + mermaidConfig.elk = { + algorithm: 'layered', + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': 50, + 'elk.layered.spacing.nodeNodeBetweenLayers': 80, + ...(config.config.elk || {}), + }; + } + } + if (config.config.look) { + mermaidConfig.look = config.config.look; + } + } + + console.log('[Mermaid] Initializing with config:', mermaidConfig); + console.log('[Mermaid] Rendering diagram (length:', diagram.length, ')'); + + mermaid.initialize(mermaidConfig as Parameters[0]); + + // タイムアウト処理追加(10秒) + const timeoutMs = 10000; + const renderPromise = mermaid.render(idRef, diagram); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Rendering timeout')), timeoutMs) + ); + + const { svg } = (await Promise.race([renderPromise, timeoutPromise])) as { svg: string }; + + if (!isMounted || !ref.current) return; + + ref.current.innerHTML = svg; + setSvgContent(svg); + + const svgElem = ref.current.querySelector('svg'); + if (svgElem) { + svgElem.style.maxWidth = '100%'; + svgElem.style.height = 'auto'; + svgElem.style.maxHeight = '90vh'; + svgElem.style.overflow = 'visible'; + svgElem.style.background = colors.mermaidBg || '#eaffea'; + svgElem.style.touchAction = 'none'; + svgElem.style.transformOrigin = '0 0'; + + // requestAnimationFrameで描画完了を保証 + requestAnimationFrame(() => { + if (svgElem && isMounted) { + svgElem.style.transform = `translate(${zoomState.translate.x}px, ${zoomState.translate.y}px) scale(${zoomState.scale})`; + } + }); + + const container = ref.current as HTMLDivElement; + + // Apply transform directly to SVG without triggering React re-render + const applyTransformVisual = (): void => { + const s = scaleRef.current; + const { x, y } = translateRef.current; + svgElem.style.transform = `translate(${x}px, ${y}px) scale(${s})`; + }; + + // Sync state with refs (called only when interaction ends) + const syncStateWithRefs = (): void => { + setZoomState({ scale: scaleRef.current, translate: { ...translateRef.current } }); + }; + + const onWheel = (e: WheelEvent): void => { + e.preventDefault(); + const rect = container.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const delta = e.deltaY < 0 ? 1.12 : 0.9; + const prevScale = scaleRef.current; + const newScale = Math.max(0.2, Math.min(8, prevScale * delta)); + const tx = translateRef.current.x; + const ty = translateRef.current.y; + translateRef.current.x = mx - (mx - tx) * (newScale / prevScale); + translateRef.current.y = my - (my - ty) * (newScale / prevScale); + scaleRef.current = newScale; + applyTransformVisual(); + syncStateWithRefs(); + }; + + const getTouchDist = (touches: TouchList): number => { + if (touches.length < 2) return 0; + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }; + + const onTouchStart = (e: TouchEvent): void => { + if (e.touches.length === 2) { + isPinching = true; + lastTouchDist = getTouchDist(e.touches); + pinchStartScale = scaleRef.current; + const rect = container.getBoundingClientRect(); + pinchStart = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top, + }; + } + }; + + const onTouchMove = (e: TouchEvent): void => { + if (isPinching && e.touches.length === 2) { + e.preventDefault(); + const newDist = getTouchDist(e.touches); + if (lastTouchDist > 0) { + const scaleDelta = newDist / lastTouchDist; + const newScale = Math.max(0.2, Math.min(8, pinchStartScale * scaleDelta)); + const tx = translateRef.current.x; + const ty = translateRef.current.y; + translateRef.current.x = pinchStart.x - (pinchStart.x - tx) * (newScale / scaleRef.current); + translateRef.current.y = pinchStart.y - (pinchStart.y - ty) * (newScale / scaleRef.current); + scaleRef.current = newScale; + applyTransformVisual(); + } + } + }; + + const onTouchEnd = (e: TouchEvent): void => { + if (e.touches.length < 2) { + isPinching = false; + lastTouchDist = 0; + syncStateWithRefs(); + } + }; + + const onPointerDown = (e: PointerEvent): void => { + (e.target as Element).setPointerCapture?.(e.pointerId); + isPanningRef.current = true; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + container.style.cursor = 'grabbing'; + }; + + const onPointerMove = (e: PointerEvent): void => { + if (!isPanningRef.current || !lastPointerRef.current) return; + const dx = e.clientX - lastPointerRef.current.x; + const dy = e.clientY - lastPointerRef.current.y; + lastPointerRef.current = { x: e.clientX, y: e.clientY }; + translateRef.current.x += dx; + translateRef.current.y += dy; + applyTransformVisual(); + }; + + const onPointerUp = (e: PointerEvent): void => { + try { + (e.target as Element).releasePointerCapture?.(e.pointerId); + } catch { + // ignore + } + isPanningRef.current = false; + lastPointerRef.current = null; + container.style.cursor = 'default'; + syncStateWithRefs(); + }; + + const onDblClick = (): void => { + scaleRef.current = 1; + translateRef.current = { x: 0, y: 0 }; + applyTransformVisual(); + syncStateWithRefs(); + }; + + container.addEventListener('wheel', onWheel, { passive: false }); + container.addEventListener('pointerdown', onPointerDown); + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + container.addEventListener('dblclick', onDblClick); + container.addEventListener('touchstart', onTouchStart, { passive: false }); + container.addEventListener('touchmove', onTouchMove, { passive: false }); + container.addEventListener('touchend', onTouchEnd, { passive: false }); + + const cleanup = (): void => { + try { + container.removeEventListener('wheel', onWheel); + container.removeEventListener('pointerdown', onPointerDown); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + container.removeEventListener('dblclick', onDblClick); + container.removeEventListener('touchstart', onTouchStart); + container.removeEventListener('touchmove', onTouchMove); + container.removeEventListener('touchend', onTouchEnd); + } catch { + // ignore + } + }; + (container as HTMLDivElement & { __mermaidCleanup?: () => void }).__mermaidCleanup = cleanup; + } + setIsLoading(false); + } catch (e: unknown) { + if (!isMounted || !ref.current) return; + + // 詳細なエラーメッセージ + let errorMessage = 'Mermaidのレンダリングに失敗しました。'; + const err = e as Error & { str?: string }; + if (err.message?.includes('timeout') || err.message?.includes('Rendering timeout')) { + errorMessage += ' 図が複雑すぎてタイムアウトしました。ノード数を減らすか、シンプルな構造にしてください。'; + } else if (err.message?.includes('Parse error')) { + errorMessage += ` 構文エラー: ${err.message}`; + } else if (err.message?.includes('Lexical error')) { + errorMessage += ' 不正な文字が含まれています。'; + } else if (err.str) { + errorMessage += ` ${err.str}`; + } else { + errorMessage += ` ${err.message || e}`; + } + + ref.current.innerHTML = `
    ${errorMessage}
    `; + setError(errorMessage); + setIsLoading(false); + setSvgContent(null); + console.error('[Mermaid] Rendering error:', e); + } + }; + + renderMermaid(); + + return () => { + isMounted = false; + try { + if (ref.current) { + const container = ref.current as HTMLDivElement & { __mermaidCleanup?: () => void }; + if (container.__mermaidCleanup) { + container.__mermaidCleanup(); + } + } + } catch { + // ignore + } + }; + }, [chart, colors.mermaidBg, themeName, config, diagram, idRef, hasIntersected]); + + const handleDownloadSvg = useCallback(() => { + if (!svgContent) return; + const blob = new Blob([svgContent], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'mermaid-diagram.svg'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + }, [svgContent]); + + const handleZoomIn = useCallback(() => { + const container = ref.current; + if (!container) return; + const svgElem = container.querySelector('svg') as SVGElement | null; + if (!svgElem) return; + const prev = scaleRef.current; + const next = Math.min(8, prev * 1.2); + const rect = container.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + translateRef.current.x = cx - (cx - translateRef.current.x) * (next / prev); + translateRef.current.y = cy - (cy - translateRef.current.y) * (next / prev); + scaleRef.current = next; + svgElem.style.transform = `translate(${translateRef.current.x}px, ${translateRef.current.y}px) scale(${scaleRef.current})`; + setZoomState({ scale: scaleRef.current, translate: { ...translateRef.current } }); + }, []); + + const handleZoomOut = useCallback(() => { + const container = ref.current; + if (!container) return; + const svgElem = container.querySelector('svg') as SVGElement | null; + if (!svgElem) return; + const prev = scaleRef.current; + const next = Math.max(0.2, prev / 1.2); + const rect = container.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + translateRef.current.x = cx - (cx - translateRef.current.x) * (next / prev); + translateRef.current.y = cy - (cy - translateRef.current.y) * (next / prev); + scaleRef.current = next; + svgElem.style.transform = `translate(${translateRef.current.x}px, ${translateRef.current.y}px) scale(${scaleRef.current})`; + setZoomState({ scale: scaleRef.current, translate: { ...translateRef.current } }); + }, []); + + const handleResetView = useCallback(() => { + const container = ref.current; + if (!container) return; + const svgElem = container.querySelector('svg') as SVGElement | null; + if (!svgElem) return; + scaleRef.current = 1; + translateRef.current = { x: 0, y: 0 }; + svgElem.style.transform = `translate(0px, 0px) scale(1)`; + setZoomState({ scale: 1, translate: { x: 0, y: 0 } }); + }, []); + + // Placeholder shown before the element comes into view + if (!hasIntersected) { + return ( +
    +
    + {t ? t('markdownPreview.mermaidPlaceholder') : 'スクロールするとMermaid図が表示されます'} +
    +
    + ); + } + + return ( +
    + {svgContent && !isLoading && !error && ( +
    +
    + + + + +
    +
    + )} +
    +
    +
    +
    + ); +}); + +Mermaid.displayName = 'Mermaid'; + +export default Mermaid; diff --git a/src/components/Tab/MarkdownPreview/index.ts b/src/components/Tab/MarkdownPreview/index.ts new file mode 100644 index 00000000..54e3c5b6 --- /dev/null +++ b/src/components/Tab/MarkdownPreview/index.ts @@ -0,0 +1,4 @@ +export { default as Mermaid } from './Mermaid'; +export { default as LocalImage } from './LocalImage'; +export { default as CodeBlock } from './CodeBlock'; +export { useIntersectionObserver } from './useIntersectionObserver'; diff --git a/src/components/Tab/MarkdownPreview/useIntersectionObserver.ts b/src/components/Tab/MarkdownPreview/useIntersectionObserver.ts new file mode 100644 index 00000000..b0945d12 --- /dev/null +++ b/src/components/Tab/MarkdownPreview/useIntersectionObserver.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +interface UseIntersectionObserverOptions { + threshold?: number | number[]; + rootMargin?: string; + triggerOnce?: boolean; +} + +/** + * Custom hook to observe element visibility using IntersectionObserver + * Used for lazy loading mermaid diagrams and images + */ +export const useIntersectionObserver = ( + options: UseIntersectionObserverOptions = {} +): { + ref: React.RefObject; + isIntersecting: boolean; + hasIntersected: boolean; +} => { + const { threshold = 0, rootMargin = '200px 0px', triggerOnce = true } = options; + const ref = useRef(null); + const [isIntersecting, setIsIntersecting] = useState(false); + const [hasIntersected, setHasIntersected] = useState(false); + const observerRef = useRef(null); + + const disconnect = useCallback(() => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined' || !window.IntersectionObserver) { + // SSR or old browser - always render + setIsIntersecting(true); + setHasIntersected(true); + return; + } + + const element = ref.current; + if (!element) return; + + // If already intersected and triggerOnce, don't observe again + if (hasIntersected && triggerOnce) return; + + disconnect(); + + observerRef.current = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry) { + setIsIntersecting(entry.isIntersecting); + if (entry.isIntersecting) { + setHasIntersected(true); + if (triggerOnce) { + disconnect(); + } + } + } + }, + { threshold, rootMargin } + ); + + observerRef.current.observe(element); + + return () => { + disconnect(); + }; + }, [threshold, rootMargin, triggerOnce, hasIntersected, disconnect]); + + return { ref, isIntersecting, hasIntersected }; +}; diff --git a/src/components/Tab/MarkdownPreviewTab.tsx b/src/components/Tab/MarkdownPreviewTab.tsx index 0aec52f7..7a4c9972 100644 --- a/src/components/Tab/MarkdownPreviewTab.tsx +++ b/src/components/Tab/MarkdownPreviewTab.tsx @@ -1,595 +1,29 @@ -import { ZoomIn, ZoomOut, RefreshCw, Download } from 'lucide-react'; -import { exportPdfFromHtml } from '@/engine/export/exportPdf'; -import mermaid from 'mermaid'; -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import ReactMarkdown from 'react-markdown'; +import { useEffect, useRef, useState, useCallback, useMemo, memo, type FC } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; -import InlineHighlightedCode from './InlineHighlightedCode'; +import type { PluggableList } from 'unified'; import 'katex/dist/katex.min.css'; -import { loadImageAsDataURL, parseMermaidContent } from './markdownUtils'; + import { useTranslation } from '@/context/I18nContext'; import { useTheme, ThemeContext } from '@/context/ThemeContext'; -import type { PreviewTab } from '@/engine/tabs/types'; +import { exportPdfFromHtml } from '@/engine/export/exportPdf'; +import type { EditorTab, PreviewTab } from '@/engine/tabs/types'; import { useSettings } from '@/hooks/useSettings'; -import { FileItem, Project } from '@/types'; +import { useTabStore } from '@/stores/tabStore'; +import { Project } from '@/types'; + +import InlineHighlightedCode from './InlineHighlightedCode'; +import { CodeBlock, LocalImage, Mermaid } from './MarkdownPreview'; interface MarkdownPreviewTabProps { activeTab: PreviewTab; currentProject?: Project; } -// グローバルカウンタ: ID衝突を確実に防ぐ -let globalMermaidCounter = 0; - -// 安全なID生成(非ASCII文字でのエラー回避) -const generateSafeId = (chart: string): string => { - try { - const hash = chart.split('').reduce((acc, char) => { - return ((acc << 5) - acc + char.charCodeAt(0)) | 0; - }, 0); - return `mermaid-${Math.abs(hash)}-${++globalMermaidCounter}`; - } catch { - return `mermaid-fallback-${++globalMermaidCounter}`; - } -}; - -const Mermaid = React.memo<{ chart: string; colors: any }>(({ chart, colors }) => { - const { t } = useTranslation(); - const { themeName } = useTheme(); - const ref = useRef(null); - - // ID生成をメモ化(chart変更時のみ再生成) - const idRef = useMemo(() => generateSafeId(chart), [chart]); - - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [svgContent, setSvgContent] = useState(null); - const [zoomState, setZoomState] = useState<{ - scale: number; - translate: { x: number; y: number }; - }>({ scale: 1, translate: { x: 0, y: 0 } }); - - const scaleRef = useRef(zoomState.scale); - const translateRef = useRef<{ x: number; y: number }>({ ...zoomState.translate }); - const isPanningRef = useRef(false); - const lastPointerRef = useRef<{ x: number; y: number } | null>(null); - - // 設定パースをメモ化(パフォーマンス改善) - const { config, diagram } = useMemo(() => parseMermaidContent(chart), [chart]); - - useEffect(() => { - let lastTouchDist = 0; - let isPinching = false; - let pinchStartScale = 1; - let pinchStart = { x: 0, y: 0 }; - let isMounted = true; - - const renderMermaid = async () => { - if (!ref.current || !isMounted) return; - - setIsLoading(true); - setError(null); - scaleRef.current = zoomState.scale; - translateRef.current = { ...zoomState.translate }; - - ref.current.innerHTML = ` -
    - - - - - - ${t ? t('markdownPreview.generatingMermaid') : 'Mermaid図表を生成中...'} -
    - `; - - try { - const isDark = !(themeName && themeName.includes('light')); - const mermaidConfig: any = { - startOnLoad: false, - theme: isDark ? 'dark' : 'default', - securityLevel: 'loose', - themeVariables: { - fontSize: '8px', - }, - suppressErrorRendering: true, - maxTextSize: 100000, // 複雑な図のサイズ制限緩和 - maxEdges: 2000, - flowchart: { - useMaxWidth: false, - htmlLabels: true, - curve: 'basis', - rankSpacing: 80, - nodeSpacing: 50, - }, - layout: 'dagre', - }; - - if (config.config) { - if (config.config.theme) mermaidConfig.theme = config.config.theme; - if (config.config.themeVariables) { - mermaidConfig.themeVariables = { - ...mermaidConfig.themeVariables, - ...config.config.themeVariables, - }; - } - if (config.config.flowchart) { - mermaidConfig.flowchart = { - ...mermaidConfig.flowchart, - ...config.config.flowchart, - }; - } - if (config.config.defaultRenderer === 'elk') { - mermaidConfig.flowchart.defaultRenderer = 'elk'; - } - if (config.config.layout) { - mermaidConfig.layout = config.config.layout; - if (config.config.layout === 'elk') { - mermaidConfig.flowchart.defaultRenderer = 'elk'; - mermaidConfig.elk = { - 'algorithm': 'layered', - 'elk.direction': 'DOWN', - 'elk.spacing.nodeNode': 50, - 'elk.layered.spacing.nodeNodeBetweenLayers': 80, - ...(config.config.elk || {}), - }; - } - } - if (config.config.look) { - mermaidConfig.look = config.config.look; - } - } - - console.log('[Mermaid] Initializing with config:', mermaidConfig); - console.log('[Mermaid] Rendering diagram (length:', diagram.length, ')'); - - mermaid.initialize(mermaidConfig); - - // タイムアウト処理追加(10秒) - const timeoutMs = 10000; - const renderPromise = mermaid.render(idRef, diagram); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Rendering timeout')), timeoutMs) - ); - - const { svg } = (await Promise.race([renderPromise, timeoutPromise])) as any; - - if (!isMounted || !ref.current) return; - - ref.current.innerHTML = svg; - setSvgContent(svg); - - const svgElem = ref.current.querySelector('svg'); - if (svgElem) { - svgElem.style.maxWidth = '100%'; - svgElem.style.height = 'auto'; - svgElem.style.maxHeight = '90vh'; - svgElem.style.overflow = 'visible'; - svgElem.style.background = colors.mermaidBg || '#eaffea'; - svgElem.style.touchAction = 'none'; - svgElem.style.transformOrigin = '0 0'; - - // requestAnimationFrameで描画完了を保証 - requestAnimationFrame(() => { - if (svgElem && isMounted) { - svgElem.style.transform = `translate(${zoomState.translate.x}px, ${zoomState.translate.y}px) scale(${zoomState.scale})`; - } - }); - - const container = ref.current as HTMLDivElement; - const applyTransform = () => { - const s = scaleRef.current; - const { x, y } = translateRef.current; - svgElem.style.transform = `translate(${x}px, ${y}px) scale(${s})`; - setZoomState({ scale: s, translate: { x, y } }); - }; - - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - const rect = container.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const delta = e.deltaY < 0 ? 1.12 : 0.9; - const prevScale = scaleRef.current; - const newScale = Math.max(0.2, Math.min(8, prevScale * delta)); - const tx = translateRef.current.x; - const ty = translateRef.current.y; - translateRef.current.x = mx - (mx - tx) * (newScale / prevScale); - translateRef.current.y = my - (my - ty) * (newScale / prevScale); - scaleRef.current = newScale; - applyTransform(); - }; - - const getTouchDist = (touches: TouchList) => { - if (touches.length < 2) return 0; - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - return Math.sqrt(dx * dx + dy * dy); - }; - - const onTouchStart = (e: TouchEvent) => { - if (e.touches.length === 2) { - isPinching = true; - lastTouchDist = getTouchDist(e.touches); - pinchStartScale = scaleRef.current; - const rect = container.getBoundingClientRect(); - pinchStart = { - x: (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left, - y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top, - }; - } - }; - - const onTouchMove = (e: TouchEvent) => { - if (isPinching && e.touches.length === 2) { - e.preventDefault(); - const newDist = getTouchDist(e.touches); - if (lastTouchDist > 0) { - const scaleDelta = newDist / lastTouchDist; - const newScale = Math.max(0.2, Math.min(8, pinchStartScale * scaleDelta)); - const tx = translateRef.current.x; - const ty = translateRef.current.y; - translateRef.current.x = - pinchStart.x - (pinchStart.x - tx) * (newScale / scaleRef.current); - translateRef.current.y = - pinchStart.y - (pinchStart.y - ty) * (newScale / scaleRef.current); - scaleRef.current = newScale; - applyTransform(); - } - } - }; - - const onTouchEnd = (e: TouchEvent) => { - if (e.touches.length < 2) { - isPinching = false; - lastTouchDist = 0; - } - }; - - const onPointerDown = (e: PointerEvent) => { - (e.target as Element).setPointerCapture?.(e.pointerId); - isPanningRef.current = true; - lastPointerRef.current = { x: e.clientX, y: e.clientY }; - container.style.cursor = 'grabbing'; - }; - - const onPointerMove = (e: PointerEvent) => { - if (!isPanningRef.current || !lastPointerRef.current) return; - const dx = e.clientX - lastPointerRef.current.x; - const dy = e.clientY - lastPointerRef.current.y; - lastPointerRef.current = { x: e.clientX, y: e.clientY }; - translateRef.current.x += dx; - translateRef.current.y += dy; - applyTransform(); - }; - - const onPointerUp = (e: PointerEvent) => { - try { - (e.target as Element).releasePointerCapture?.(e.pointerId); - } catch (e) {} - isPanningRef.current = false; - lastPointerRef.current = null; - container.style.cursor = 'default'; - }; - - const onDblClick = (e: MouseEvent) => { - scaleRef.current = 1; - translateRef.current = { x: 0, y: 0 }; - applyTransform(); - }; - - container.addEventListener('wheel', onWheel, { passive: false }); - container.addEventListener('pointerdown', onPointerDown as any); - window.addEventListener('pointermove', onPointerMove as any); - window.addEventListener('pointerup', onPointerUp as any); - container.addEventListener('dblclick', onDblClick as any); - container.addEventListener('touchstart', onTouchStart, { passive: false }); - container.addEventListener('touchmove', onTouchMove, { passive: false }); - container.addEventListener('touchend', onTouchEnd, { passive: false }); - - const cleanup = () => { - try { - container.removeEventListener('wheel', onWheel as any); - container.removeEventListener('pointerdown', onPointerDown as any); - window.removeEventListener('pointermove', onPointerMove as any); - window.removeEventListener('pointerup', onPointerUp as any); - container.removeEventListener('dblclick', onDblClick as any); - container.removeEventListener('touchstart', onTouchStart as any); - container.removeEventListener('touchmove', onTouchMove as any); - container.removeEventListener('touchend', onTouchEnd as any); - } catch (err) {} - }; - (container as any).__mermaidCleanup = cleanup; - } - setIsLoading(false); - } catch (e: any) { - if (!isMounted || !ref.current) return; - - // 詳細なエラーメッセージ - let errorMessage = 'Mermaidのレンダリングに失敗しました。'; - if (e.message?.includes('timeout') || e.message?.includes('Rendering timeout')) { - errorMessage += ' 図が複雑すぎてタイムアウトしました。ノード数を減らすか、シンプルな構造にしてください。'; - } else if (e.message?.includes('Parse error')) { - errorMessage += ` 構文エラー: ${e.message}`; - } else if (e.message?.includes('Lexical error')) { - errorMessage += ' 不正な文字が含まれています。'; - } else if (e.str) { - errorMessage += ` ${e.str}`; - } else { - errorMessage += ` ${e.message || e}`; - } - - ref.current.innerHTML = `
    ${errorMessage}
    `; - setError(errorMessage); - setIsLoading(false); - setSvgContent(null); - console.error('[Mermaid] Rendering error:', e); - } - }; - - renderMermaid(); - - return () => { - isMounted = false; - try { - if (ref.current && (ref.current as any).__mermaidCleanup) { - (ref.current as any).__mermaidCleanup(); - } - } catch (err) {} - }; - }, [chart, colors.mermaidBg, themeName, config, diagram, idRef]); - - const handleDownloadSvg = useCallback(() => { - if (!svgContent) return; - const blob = new Blob([svgContent], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'mermaid-diagram.svg'; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 100); - }, [svgContent]); - - const handleZoomIn = useCallback(() => { - const container = ref.current; - if (!container) return; - const svgElem = container.querySelector('svg') as SVGElement | null; - if (!svgElem) return; - const prev = scaleRef.current; - const next = Math.min(8, prev * 1.2); - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - translateRef.current.x = cx - (cx - translateRef.current.x) * (next / prev); - translateRef.current.y = cy - (cy - translateRef.current.y) * (next / prev); - scaleRef.current = next; - svgElem.style.transform = `translate(${translateRef.current.x}px, ${translateRef.current.y}px) scale(${scaleRef.current})`; - setZoomState({ scale: scaleRef.current, translate: { ...translateRef.current } }); - }, []); - - const handleZoomOut = useCallback(() => { - const container = ref.current; - if (!container) return; - const svgElem = container.querySelector('svg') as SVGElement | null; - if (!svgElem) return; - const prev = scaleRef.current; - const next = Math.max(0.2, prev / 1.2); - const rect = container.getBoundingClientRect(); - const cx = rect.width / 2; - const cy = rect.height / 2; - translateRef.current.x = cx - (cx - translateRef.current.x) * (next / prev); - translateRef.current.y = cy - (cy - translateRef.current.y) * (next / prev); - scaleRef.current = next; - svgElem.style.transform = `translate(${translateRef.current.x}px, ${translateRef.current.y}px) scale(${scaleRef.current})`; - setZoomState({ scale: scaleRef.current, translate: { ...translateRef.current } }); - }, []); - - const handleResetView = useCallback(() => { - const container = ref.current; - if (!container) return; - const svgElem = container.querySelector('svg') as SVGElement | null; - if (!svgElem) return; - scaleRef.current = 1; - translateRef.current = { x: 0, y: 0 }; - svgElem.style.transform = `translate(0px, 0px) scale(1)`; - setZoomState({ scale: 1, translate: { x: 0, y: 0 } }); - }, []); - - return ( -
    - {svgContent && !isLoading && !error && ( -
    -
    - - - - -
    -
    - )} -
    -
    -
    -
    - ); -}); - -Mermaid.displayName = 'Mermaid'; - -// メモ化されたローカル画像コンポーネント -const LocalImage = React.memo<{ - src: string; - alt: string; - activeTab: PreviewTab; - projectName?: string | undefined; - projectId?: string | undefined; - [key: string]: any; -}>(({ src, alt, activeTab, projectName, projectId, ...props }) => { - const [dataUrl, setDataUrl] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - // i18n - const { t } = useTranslation(); - - useEffect(() => { - const loadImage = async () => { - if (!src || !projectName) { - setError(true); - setLoading(false); - return; - } - - // 外部URLの場合はそのまま使用 - if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) { - setDataUrl(src); - setLoading(false); - return; - } - - // ローカル画像の場合はプロジェクトファイルまたはファイルシステムから読み込み - try { - const loadedDataUrl = await loadImageAsDataURL( - src, - projectName, - projectId, - // pass the path of the markdown file so relative paths can be resolved - activeTab.path - ); - if (loadedDataUrl) { - setDataUrl(loadedDataUrl); - console.log('Loaded local image:', src); - setError(false); - } else { - setError(true); - } - } catch (err) { - console.warn('Failed to load local image:', src, err); - setError(true); - } finally { - setLoading(false); - } - }; - - loadImage(); - }, [src, projectName, activeTab.path]); - - if (loading) { - return ( - - {t ? t('markdownPreview.loadingImage') : '画像を読み込み中...'} - - ); - } - - if (error || !dataUrl) { - return ( - - {t - ? t('markdownPreview.imageNotFound', { params: { src } }) - : `画像が見つかりません: ${src}`} - - ); - } - - return ( - {alt} - ); -}); - -LocalImage.displayName = 'LocalImage'; - -// メモ化されたコードコンポーネント -const MemoizedCodeComponent = React.memo<{ - className?: string; - children: React.ReactNode; - colors: any; - currentProjectName?: string | undefined; - projectFiles?: FileItem[]; -}>(({ className, children, colors, currentProjectName, projectFiles, ...props }) => { - const match = /language-(\w+)/.exec(className || ''); - const codeString = String(children).replace(/\n$/, '').trim(); - - if (match && match[1] === 'mermaid') { - return ( - - ); - } - - if (className && match) { - return ( - - ); - } - - // インラインコード: InlineHighlightedCode を使う - const inlineCode = codeString; - return ( - - ); -}); - -MemoizedCodeComponent.displayName = 'MemoizedCodeComponent'; - -const MarkdownPreviewTab: React.FC = ({ activeTab, currentProject }) => { +const MarkdownPreviewTab: FC = ({ activeTab, currentProject }) => { const { colors, themeName } = useTheme(); const { settings } = useSettings(currentProject?.id); const { t } = useTranslation(); @@ -599,14 +33,26 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr const prevContentRef = useRef(null); // determine markdown plugins based on settings - const [extraRemarkPlugins, setExtraRemarkPlugins] = useState([ - /* maybe remark-breaks */ - ]); + const [extraRemarkPlugins, setExtraRemarkPlugins] = useState([]); + + // Subscribe to editor tab content changes for real-time preview + // Find the corresponding editor tab and get its content + const editorTabContent = useTabStore(state => { + // Find editor tab with the same path + const result = state.findTabByPath(activeTab.path, 'editor'); + if (result?.tab && result.tab.kind === 'editor') { + return (result.tab as EditorTab).content; + } + return null; + }); + + // Use editor tab content if available (for real-time updates), otherwise use preview tab content + const contentSource = editorTabContent ?? activeTab.content ?? ''; useEffect(() => { let mounted = true; - const setup = async () => { - const plugins: any[] = []; + const setup = async (): Promise => { + const plugins: PluggableList = []; try { const mode = settings?.markdown?.singleLineBreaks || 'default'; if (mode === 'breaks') { @@ -620,8 +66,6 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr ); } } - // No AST-level conversion here. Bracket-style math is handled by - // preprocessing the raw markdown string (see `processedContent` below) } catch (e) { console.warn('[MarkdownPreviewTab] failed to configure markdown plugins', e); } @@ -635,19 +79,14 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr // ReactMarkdownのコンポーネントをメモ化 // 通常表示用 - const markdownComponents = useMemo( + const markdownComponents = useMemo>( () => ({ - code: ({ node, className, children, ...props }: any) => ( - + code: ({ className, children, ...props }) => ( + {children} - + ), - img: ({ node, src, alt, ...props }: any) => { + img: ({ src, alt, ...props }) => { const srcString = typeof src === 'string' ? src : ''; return ( = ({ activeTab, curr ); }, }), - [colors, currentProject?.name] + [colors, currentProject?.name, currentProject?.id, activeTab] ); // PDFエクスポート用: plain=trueを渡す - const markdownComponentsPlain = useMemo( + const markdownComponentsPlain = useMemo>( () => ({ - code: ({ node, className, children, ...props }: any) => { + code: ({ className, children, ...props }) => { const match = /language-(\w+)/.exec(className || ''); const codeString = String(children).replace(/\n$/, '').trim(); if (match && match[1] === 'mermaid') { - return ( - - ); + return ; } - return ( - - ); + return ; }, - img: ({ node, src, alt, ...props }: any) => { + img: ({ src, alt, ...props }) => { const srcString = typeof src === 'string' ? src : ''; return ( = ({ activeTab, curr ); }, }), - [colors, currentProject?.name] + [colors, currentProject?.name, currentProject?.id, activeTab] ); - // メイン部分もメモ化 // Preprocess the raw markdown to convert bracket-style math delimiters // into dollar-style, while skipping code fences and inline code. + // For 'bracket' mode: escape dollar signs so they don't get processed as math const processedContent = useMemo(() => { - const src = activeTab.content || ''; + // Use editor tab content for real-time updates, otherwise fall back to preview tab content + const src = contentSource; const delimiter = settings?.markdown?.math?.delimiter || 'dollar'; if (delimiter === 'dollar') return src; - const convertInNonCode = (text: string) => { - // Split by code fences (```...```) and keep them intact + // Helper: process text while preserving code blocks + const processNonCode = (text: string, processFn: (segment: string) => string): string => { + // Split by code fences and keep them intact return text .split(/(```[\s\S]*?```)/g) .map(part => { if (/^```/.test(part)) return part; // code fence, leave - // Within non-fence parts, also preserve inline code `...` + // Within non-fence parts, also preserve inline code return part .split(/(`[^`]*`)/g) .map(seg => { if (/^`/.test(seg)) return seg; // inline code - // replace \(...\) -> $...$ and \[...\] -> $$...$$ - return seg - .replace(/\\\(([\s\S]+?)\\\)/g, (_m, g) => `$${g}$`) - .replace(/\\\[([\s\S]+?)\\\]/g, (_m, g) => `$$${g}$$`); + return processFn(seg); }) .join(''); }) .join(''); }; - if (delimiter === 'bracket' || delimiter === 'both') { - return convertInNonCode(src); + if (delimiter === 'bracket') { + // 'bracket' mode: + // 1. First, escape existing dollar signs to prevent remark-math from processing them + // 2. Then, convert bracket delimiters to dollar style + // Use unique placeholders that won't appear in normal markdown text + const DOUBLE_DOLLAR_PLACEHOLDER = '__PYXIS_ESCAPED_DOUBLE_DOLLAR__'; + const SINGLE_DOLLAR_PLACEHOLDER = '__PYXIS_ESCAPED_SINGLE_DOLLAR__'; + + let result = processNonCode(src, (seg) => { + // Escape $$ first (display math), then $ (inline math) + return seg + .replace(/\$\$/g, DOUBLE_DOLLAR_PLACEHOLDER) + .replace(/\$/g, SINGLE_DOLLAR_PLACEHOLDER); + }); + // Convert bracket delimiters to dollar style + result = processNonCode(result, (seg) => { + return seg + .replace(/\\\(([\s\S]+?)\\\)/g, (_m, g: string) => '$' + g + '$') + .replace(/\\\[([\s\S]+?)\\\]/g, (_m, g: string) => '$$' + g + '$$'); + }); + // Restore escaped dollar signs as literal text (not math) + result = result + .replace(new RegExp(DOUBLE_DOLLAR_PLACEHOLDER, 'g'), '\\$\\$') + .replace(new RegExp(SINGLE_DOLLAR_PLACEHOLDER, 'g'), '\\$'); + return result; + } + + if (delimiter === 'both') { + // 'both' mode: convert bracket delimiters to dollar style (dollars also work) + return processNonCode(src, (seg) => { + return seg + .replace(/\\\(([\s\S]+?)\\\)/g, (_m, g: string) => '$' + g + '$') + .replace(/\\\[([\s\S]+?)\\\]/g, (_m, g: string) => '$$' + g + '$$'); + }); } return src; - }, [activeTab.content, settings?.markdown?.math?.delimiter]); + }, [contentSource, settings?.markdown?.math?.delimiter]); const markdownContent = useMemo( () => ( = ({ activeTab, curr {processedContent} ), - [activeTab.content, markdownComponentsPlain] + [processedContent, markdownComponentsPlain] ); // PDFエクスポート処理 const handleExportPdf = useCallback(async () => { - if (typeof window === 'undefined') return; // SSR対策 + if (typeof window === 'undefined') return; const container = document.createElement('div'); container.style.background = colors.background; container.style.color = '#000'; container.className = 'markdown-body prose prose-github max-w-none'; document.body.appendChild(container); try { - // React 18+ の createRoot を使う(動的インポートでSSR安全) const ReactDOMClient = await import('react-dom/client'); const root = ReactDOMClient.createRoot(container); - // ThemeContext.Providerでラップ root.render( = ({ activeTab, curr ); setTimeout(() => { - // インラインCSSで強制的に黒文字にする - container.innerHTML = ` - - ${container.innerHTML} - `; - exportPdfFromHtml( - container.innerHTML, - (activeTab.name || 'document').replace(/\.[^/.]+$/, '') + '.pdf' - ); + container.innerHTML = + '' + + container.innerHTML; + exportPdfFromHtml(container.innerHTML, (activeTab.name || 'document').replace(/\.[^/.]+$/, '') + '.pdf'); try { root.unmount(); - } catch (e) { + } catch { /* ignore */ } document.body.removeChild(container); @@ -826,51 +272,33 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr // 自動スクロール: 新しいコンテンツが「末尾に追記」された場合のみスクロールする useEffect(() => { - if (typeof window === 'undefined') return; // SSR対策 + if (typeof window === 'undefined') return; const prev = prevContentRef.current; const current = activeTab.content || ''; - const collapseNewlines = (s: string) => s.replace(/\n{3,}/g, '\n\n'); - const trimTrailingWhitespace = (s: string) => s.replace(/[\s\u00A0]+$/g, ''); + const trimTrailingWhitespace = (s: string): string => s.replace(/[\s\u00A0]+$/g, ''); - // Strictly determine if newStr is the result of appending content to oldStr. - // Rules: - // - oldStr must be non-empty and strictly shorter than newStr - // - after trimming trailing whitespace/newlines from oldStr, it must match a prefix - // of newStr (also allowing newStr to contain extra leading newlines between old and new) - // - edits in the middle (changes not at the end) should NOT pass - // - limit the comparison window to the last N characters of oldStr for performance on huge docs - const isAppend = (oldStr: string | null, newStr: string) => { + const isAppend = (oldStr: string | null, newStr: string): boolean => { if (!oldStr) return false; if (newStr.length <= oldStr.length) return false; - const MAX_WINDOW = 2000; // compare up to last 2KB of the old content - - // Normalize collapsing excessive blank lines only for comparison (not for display) + const MAX_WINDOW = 2000; const oldTrimmed = trimTrailingWhitespace(oldStr); - const newTrimmed = newStr; // keep newStr intact for prefix checks + const newTrimmed = newStr; - // Fast path: exact prefix match (most common case) if (newTrimmed.startsWith(oldTrimmed)) return true; - // If old is very large, compare using a window at the end of oldTrimmed const start = Math.max(0, oldTrimmed.length - MAX_WINDOW); const oldWindow = oldTrimmed.slice(start); - // If the new string contains oldWindow at its start and the remainder is appended, - // ensure that the portion of old before the window hasn't been modified by checking - // that the prefix of newStr (up to start) equals the corresponding prefix of oldTrimmed. if (newTrimmed.startsWith(oldWindow)) { - // Verify the untouched prefix (if any) - if (start === 0) return true; // whole oldTrimmed was within window and matched + if (start === 0) return true; const oldPrefix = oldTrimmed.slice(0, start); const newPrefix = newTrimmed.slice(0, start); if (oldPrefix === newPrefix) return true; } - // Allow a relaxed match where multiple blank lines/newline-only differences exist - // between end of old and start of appended content: normalize sequences of 2+ newlines - const normalizeNewlines = (s: string) => s.replace(/\n{2,}/g, '\n\n'); + const normalizeNewlines = (s: string): string => s.replace(/\n{2,}/g, '\n\n'); const oldNormalized = normalizeNewlines(oldTrimmed); const newNormalized = normalizeNewlines(newTrimmed); if (newNormalized.startsWith(oldNormalized)) return true; @@ -885,20 +313,16 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); } } - } catch (err) { + } catch { const el = markdownContainerRef.current; if (el) el.scrollTop = el.scrollHeight; } - // 常に最新を保存 prevContentRef.current = current; }, [activeTab.content]); return ( -
    +
    {activeTab.name} {t('markdownPreview.preview')} @@ -917,7 +341,7 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr className="markdown-body prose prose-github max-w-none" style={{ background: colors.background, - color: colors.foreground + color: colors.foreground, }} > {markdownContent} @@ -926,4 +350,4 @@ const MarkdownPreviewTab: React.FC = ({ activeTab, curr ); }; -export default React.memo(MarkdownPreviewTab); +export default memo(MarkdownPreviewTab); diff --git a/src/components/Tab/ShortcutKeysTab.tsx b/src/components/Tab/ShortcutKeysTab.tsx index b7c76282..0518c344 100644 --- a/src/components/Tab/ShortcutKeysTab.tsx +++ b/src/components/Tab/ShortcutKeysTab.tsx @@ -168,7 +168,7 @@ export default function ShortcutKeysTab() { } return Array.from(groups.entries()).sort((a, b) => { // Custom sort order if needed, or just alphabetical - const order = ['file', 'search', 'view', 'execution', 'tab', 'git', 'project', 'other']; + const order = ['file', 'search', 'view', 'execution', 'tab', 'pane', 'git', 'project', 'other']; const indexA = order.indexOf(a[0]); const indexB = order.indexOf(b[0]); if (indexA !== -1 && indexB !== -1) return indexA - indexB; @@ -184,6 +184,7 @@ export default function ShortcutKeysTab() { view: { label: '表示', icon: }, execution: { label: '実行', icon: }, tab: { label: 'タブ', icon: }, // Using Folder for tabs as a container metaphor + pane: { label: 'ペイン', icon: }, git: { label: 'Git', icon: }, project: { label: 'プロジェクト', icon: }, other: { label: 'その他', icon: }, diff --git a/src/components/Tab/TabBar.tsx b/src/components/Tab/TabBar.tsx index 73ae2b1b..7dd34886 100644 --- a/src/components/Tab/TabBar.tsx +++ b/src/components/Tab/TabBar.tsx @@ -10,12 +10,13 @@ import { Save, Minus, } from 'lucide-react'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import { TabIcon } from './TabIcon'; import { useTabCloseConfirmation } from './useTabCloseConfirmation'; +import { DND_TAB } from '@/constants/dndTypes'; import { useFileSelector } from '@/context/FileSelectorContext'; import { useTranslation } from '@/context/I18nContext'; import { useTheme } from '@/context/ThemeContext'; @@ -26,10 +27,22 @@ interface TabBarProps { paneId: string; } +interface TabRect { + left: number; + bottom: number; +} + +interface TabContextMenuState { + isOpen: boolean; + tabId: string; + tabRect: TabRect | null; +} + /** * TabBar: 完全に自律的なタブバーコンポーネント - * - page.tsxからのpropsは不要 - * - TabContextを通じて直接タブ操作 + * - タブのクリック = タブをアクティブにする + * - タブの右クリック/長押し = コンテキストメニューを表示 + * - メニューはタブの真下に固定表示 */ export default function TabBar({ paneId }: TabBarProps) { const { colors } = useTheme(); @@ -46,49 +59,39 @@ export default function TabBar({ paneId }: TabBarProps) { const tabs = pane.tabs; const activeTabId = pane.activeTabId; - // メニューの開閉状態管理 - const [menuOpen, setMenuOpen] = useState(false); - const menuRef = useRef(null); - // メニューを閉じるヘルパー - const closeMenu = () => setMenuOpen(false); - - // タブコンテキストメニューの状態管理 - const [tabContextMenu, setTabContextMenu] = useState<{ - isOpen: boolean; - tabId: string; - x: number; - y: number; - }>({ isOpen: false, tabId: '', x: 0, y: 0 }); + // ペインメニューの開閉状態 + const [paneMenuOpen, setPaneMenuOpen] = useState(false); + const paneMenuRef = useRef(null); + + // タブコンテキストメニューの状態 + const [tabContextMenu, setTabContextMenu] = useState({ + isOpen: false, + tabId: '', + tabRect: null, + }); const tabContextMenuRef = useRef(null); + // タッチ検出用 + const touchStartPosRef = useRef<{ x: number; y: number } | null>(null); + const isLongPressRef = useRef(false); + // メニュー外クリックで閉じる useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (menuOpen && menuRef.current && !menuRef.current.contains(event.target as Node)) { - setMenuOpen(false); + function handleClickOutside(event: MouseEvent | TouchEvent) { + if (paneMenuOpen && paneMenuRef.current && !paneMenuRef.current.contains(event.target as Node)) { + setPaneMenuOpen(false); } - if ( - tabContextMenu.isOpen && - tabContextMenuRef.current && - !tabContextMenuRef.current.contains(event.target as Node) - ) { - setTabContextMenu({ isOpen: false, tabId: '', x: 0, y: 0 }); + if (tabContextMenu.isOpen && tabContextMenuRef.current && !tabContextMenuRef.current.contains(event.target as Node)) { + setTabContextMenu({ isOpen: false, tabId: '', tabRect: null }); } } document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); }; - }, [menuOpen, tabContextMenu.isOpen]); - - // タッチタイマーのクリーンアップ - useEffect(() => { - return () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - } - }; - }, []); + }, [paneMenuOpen, tabContextMenu.isOpen]); // 同名ファイルの重複チェック const nameCount: Record = {}; @@ -96,153 +99,251 @@ export default function TabBar({ paneId }: TabBarProps) { nameCount[tab.name] = (nameCount[tab.name] || 0) + 1; }); - // タブクリックハンドラ - const handleTabClick = (tabId: string) => { - activateTab(paneId, tabId); - }; - - // タブ閉じるハンドラ - const handleTabClose = (tabId: string) => { + // タブを閉じる + const handleTabClose = useCallback((tabId: string) => { const tab = tabs.find(t => t.id === tabId); if (tab) { requestClose(tabId, (tab as any).isDirty || false, () => closeTab(paneId, tabId)); } - }; + }, [tabs, requestClose, closeTab, paneId]); - // 新しいタブを追加(ファイル選択モーダルを開く) - const handleAddTab = () => { + // 新しいタブを追加 + const handleAddTab = useCallback(() => { openFileSelector(paneId); - }; + }, [openFileSelector, paneId]); // ペインを削除 - const handleRemovePane = () => { - // ペインが1つだけなら削除しない + const handleRemovePane = useCallback(() => { const flatPanes = flattenPanes(panes); if (flatPanes.length <= 1) return; removePane(paneId); - }; + }, [panes, removePane, paneId]); // 全タブを閉じる - const handleRemoveAllTabs = () => { + const handleRemoveAllTabs = useCallback(() => { tabs.forEach(tab => closeTab(paneId, tab.id)); - }; + }, [tabs, closeTab, paneId]); // タブをペインに移動 - const handleMoveTabToPane = (tabId: string, targetPaneId: string) => { + const handleMoveTabToPane = useCallback((tabId: string, targetPaneId: string) => { moveTab(paneId, targetPaneId, tabId); - setTabContextMenu({ isOpen: false, tabId: '', x: 0, y: 0 }); - }; + setTabContextMenu({ isOpen: false, tabId: '', tabRect: null }); + }, [moveTab, paneId]); - // タブ右クリックハンドラ - const handleTabRightClick = (e: React.MouseEvent, tabId: string) => { - e.preventDefault(); - e.stopPropagation(); + // コンテキストメニューを開く(タブの真下に固定) + const openTabContextMenu = useCallback((tabId: string, tabElement: HTMLElement) => { + const rect = tabElement.getBoundingClientRect(); setTabContextMenu({ isOpen: true, tabId, - x: e.clientX, - y: e.clientY, + tabRect: { left: rect.left, bottom: rect.bottom }, }); - }; + }, []); - // タッチデバイス用の長押しハンドラ - const touchTimerRef = useRef(null); - const touchStartPosRef = useRef<{ x: number; y: number } | null>(null); + // タブクリック = アクティブにする(メニューは開かない) + const handleTabClick = useCallback((e: React.MouseEvent, tabId: string) => { + const target = e.target as HTMLElement; + // 閉じるボタンがクリックされた場合は無視 + if (target.closest('[data-close-button]')) { + return; + } + activateTab(paneId, tabId); + }, [activateTab, paneId]); + + // タブ右クリック = コンテキストメニューを表示 + const handleTabRightClick = useCallback((e: React.MouseEvent, tabId: string, tabElement: HTMLElement) => { + e.preventDefault(); + e.stopPropagation(); + openTabContextMenu(tabId, tabElement); + }, [openTabContextMenu]); - const handleTouchStart = (e: React.TouchEvent, tabId: string) => { + // タッチ開始 = タッチ位置を記録 + const handleTouchStart = useCallback((e: React.TouchEvent, tabId: string, tabElement: HTMLElement) => { const touch = e.touches[0]; touchStartPosRef.current = { x: touch.clientX, y: touch.clientY }; + isLongPressRef.current = false; + }, []); - touchTimerRef.current = setTimeout(() => { - if (touchStartPosRef.current) { - setTabContextMenu({ - isOpen: true, - tabId, - x: touchStartPosRef.current.x, - y: touchStartPosRef.current.y, - }); - } - }, 500); // 500ms長押し - }; - - const handleTouchEnd = () => { - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; + // タッチ終了 = モバイルではタブをアクティブにする(コンテキストメニューは表示しない) + // 注意: e.preventDefault()を呼ぶとreact-dndのドロップイベントがブロックされるため呼ばない + const handleTouchEnd = useCallback((e: React.TouchEvent, tabId: string, tabElement: HTMLElement) => { + const target = e.target as HTMLElement; + + // 閉じるボタンがタップされた場合は無視 + if (target.closest('[data-close-button]')) { + touchStartPosRef.current = null; + return; } + + // タップ(短いタッチ)でタブをアクティブにする(onClickに任せる) + // コンテキストメニューは表示しない(PCの右クリックのみ) touchStartPosRef.current = null; - }; + isLongPressRef.current = false; + }, []); + + // タッチ移動 = タップキャンセル + const handleTouchMove = useCallback(() => { + touchStartPosRef.current = null; + }, []); + + // ショートカットキー + useKeyBinding('newTab', handleAddTab, [paneId]); + + useKeyBinding('closeTab', () => { + if (useTabStore.getState().activePane !== paneId) return; + if (activeTabId) handleTabClose(activeTabId); + }, [activeTabId, paneId]); + + useKeyBinding('removeAllTabs', () => { + if (useTabStore.getState().activePane !== paneId) return; + handleRemoveAllTabs(); + }, [tabs, paneId]); + + useKeyBinding('nextTab', () => { + if (useTabStore.getState().activePane !== paneId) return; + if (tabs.length === 0) return; + const currentIndex = tabs.findIndex(t => t.id === activeTabId); + const nextIndex = (currentIndex + 1) % tabs.length; + activateTab(paneId, tabs[nextIndex].id); + }, [tabs, activeTabId, paneId]); + + useKeyBinding('prevTab', () => { + if (useTabStore.getState().activePane !== paneId) return; + if (tabs.length === 0) return; + const currentIndex = tabs.findIndex(t => t.id === activeTabId); + const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; + activateTab(paneId, tabs[prevIndex].id); + }, [tabs, activeTabId, paneId]); + + useKeyBinding('openMdPreview', () => { + if (useTabStore.getState().activePane !== paneId) return; + const activeTab = tabs.find(t => t.id === activeTabId); + if (!activeTab) return; + + const ext = activeTab.name.split('.').pop()?.toLowerCase() || ''; + if (!(ext === 'md' || ext === 'mdx')) return; + + const leafPanes = flattenPanes(panes); + + if (leafPanes.length === 1) { + splitPane(paneId, 'vertical'); + const parent = getPane(paneId); + if (!parent?.children?.length) return; + + const newPane = parent.children.find(c => !c.tabs || c.tabs.length === 0) || parent.children[1] || parent.children[0]; + if (newPane) { + openTab( + { name: activeTab.name, path: activeTab.path, content: (activeTab as any).content }, + { kind: 'preview', paneId: newPane.id, targetPaneId: newPane.id } + ); + } + return; + } - const handleTouchMove = () => { - // タッチ移動時は長押しをキャンセル - if (touchTimerRef.current) { - clearTimeout(touchTimerRef.current); - touchTimerRef.current = null; + const other = leafPanes.filter(p => p.id !== paneId); + if (other.length === 0) return; + const emptyOther = other.find(p => !p.tabs || p.tabs.length === 0); + const randomPane = emptyOther || other[Math.floor(Math.random() * other.length)]; + openTab( + { name: activeTab.name, path: activeTab.path, content: (activeTab as any).content }, + { kind: 'preview', paneId: randomPane.id, targetPaneId: randomPane.id } + ); + }, [paneId, activeTabId, tabs, panes]); + + // ペインリスト(タブ移動用) + const flatPanes = flattenPanes(panes); + const availablePanes = flatPanes.map((p, idx) => ({ + id: p.id, + name: `Pane ${idx + 1}`, + })); + + // ホイールスクロール対応 + const handleWheel = (e: React.WheelEvent) => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + (e.currentTarget as HTMLDivElement).scrollBy({ left: e.deltaY, behavior: 'auto' }); + e.preventDefault(); } }; - // ショートカットキーの登録 - useKeyBinding( - 'newTab', - () => { - handleAddTab(); - }, + // コンテナへのドロップ + const [, containerDrop] = useDrop( + () => ({ + accept: DND_TAB, + drop: (item: any) => { + if (!item?.tabId) return; + if (item.fromPaneId === paneId) return; + moveTab(item.fromPaneId, paneId, item.tabId); + }, + }), [paneId] ); - // 個々のタブを分離したコンポーネントにして、そこにhooksを置く - function DraggableTab({ - tab, - tabIndex, - }: { - tab: any; - tabIndex: number; - }) { + // タブリストコンテナへの参照(自動スクロール用) + const tabListContainerRef = useRef(null); + + // アクティブタブが変更されたときに自動スクロールする + useEffect(() => { + if (!activeTabId || !tabListContainerRef.current) return; + + // アクティブタブの要素を探す + const container = tabListContainerRef.current; + const activeTabElement = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement; + + if (activeTabElement) { + // タブが見えているかどうかをチェック + const containerRect = container.getBoundingClientRect(); + const tabRect = activeTabElement.getBoundingClientRect(); + + // タブが左端より左にあるか、右端より右にあるか + const isOutOfViewLeft = tabRect.left < containerRect.left; + const isOutOfViewRight = tabRect.right > containerRect.right; + + if (isOutOfViewLeft || isOutOfViewRight) { + // スムーズスクロールでタブを表示位置に移動 + activeTabElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + } + }, [activeTabId]); + + // ドラッグ可能なタブコンポーネント + function DraggableTab({ tab, tabIndex }: { tab: any; tabIndex: number }) { const isActive = tab.id === activeTabId; const isDuplicate = nameCount[tab.name] > 1; const displayName = isDuplicate ? `${tab.name} (${tab.path})` : tab.name; const [dragOverSide, setDragOverSide] = useState<'left' | 'right' | null>(null); + const dragOverSideRef = useRef<'left' | 'right' | null>(null); const ref = useRef(null); - // Drag source + // dragOverSideが変更されたらrefも更新 + useEffect(() => { + dragOverSideRef.current = dragOverSide; + }, [dragOverSide]); + const [{ isDragging }, dragRef] = useDrag( () => ({ - type: 'TAB', - item: { tabId: tab.id, fromPaneId: paneId, index: tabIndex }, - collect: (monitor: any) => ({ - isDragging: monitor.isDragging(), - }), + type: DND_TAB, + item: { type: DND_TAB, tabId: tab.id, fromPaneId: paneId, index: tabIndex, tabName: tab.name }, + collect: (monitor: any) => ({ isDragging: monitor.isDragging() }), }), - [tab.id, paneId, tabIndex] + [tab.id, paneId, tabIndex, tab.name] ); - // Drop target on each tab const [{ isOver }, tabDrop] = useDrop( () => ({ - accept: 'TAB', + accept: DND_TAB, drop: (item: any, monitor: any) => { - if (!item || !item.tabId) return; - if (monitor && typeof monitor.isOver === 'function' && !monitor.isOver({ shallow: true })) return; - + if (!item?.tabId) return; + if (monitor && !monitor.isOver({ shallow: true })) return; + if (item.tabId === tab.id) return; + const fromPane = item.fromPaneId; - const draggedId = item.tabId; - - // Calculate target index based on side let targetIndex = tabIndex; - if (dragOverSide === 'right') { - targetIndex = tabIndex + 1; - } - - // Adjust index if moving within same pane and target is after source - // (This logic is usually handled by the store or array splice logic, but let's be safe) - // Actually, moveTabToIndex usually handles "insert at index". - - if (draggedId === tab.id) return; + // refを使用して最新の値を取得 + if (dragOverSideRef.current === 'right') targetIndex = tabIndex + 1; try { - // @ts-ignore - moveTabToIndex(fromPane, paneId, draggedId, targetIndex); + moveTabToIndex(fromPane, paneId, item.tabId, targetIndex); item.fromPaneId = paneId; item.index = targetIndex; } catch (err) { @@ -251,101 +352,86 @@ export default function TabBar({ paneId }: TabBarProps) { setDragOverSide(null); }, hover: (item, monitor) => { - if (!ref.current) return; - if (!monitor.isOver({ shallow: true })) { - setDragOverSide(null); - return; - } - - const hoverBoundingRect = ref.current.getBoundingClientRect(); - const hoverClientX = (monitor.getClientOffset() as any).x; - const hoverClientY = (monitor.getClientOffset() as any).y; - - const hoverMiddleX = (hoverBoundingRect.right - hoverBoundingRect.left) / 2; - const hoverClientXRelative = hoverClientX - hoverBoundingRect.left; - - if (hoverClientXRelative < hoverMiddleX) { - setDragOverSide('left'); - } else { - setDragOverSide('right'); - } + if (!ref.current) return; + if (!monitor.isOver({ shallow: true })) { + setDragOverSide(null); + return; + } + + const rect = ref.current.getBoundingClientRect(); + const clientX = (monitor.getClientOffset() as any).x; + const middleX = (rect.right - rect.left) / 2; + const relativeX = clientX - rect.left; + + setDragOverSide(relativeX < middleX ? 'left' : 'right'); }, - collect: (monitor) => ({ - isOver: monitor.isOver({ shallow: true }), - }), + collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }) }), }), - [paneId, tabIndex, dragOverSide] + [paneId, tabIndex, tab.id] ); - const opacity = isDragging ? 0.4 : 1; - - // Connect refs dragRef(tabDrop(ref)); return (
    handleTabClick(e, tab.id)} + onContextMenu={e => { + if (ref.current) handleTabRightClick(e, tab.id, ref.current); + }} + onTouchStart={e => { + if (ref.current) handleTouchStart(e, tab.id, ref.current); + }} + onTouchEnd={e => { + if (ref.current) handleTouchEnd(e, tab.id, ref.current); }} - onClick={() => handleTabClick(tab.id)} - onContextMenu={e => handleTabRightClick(e, tab.id)} - onTouchStart={e => handleTouchStart(e, tab.id)} - onTouchEnd={handleTouchEnd} onTouchMove={handleTouchMove} > - {/* Insertion Indicator */} + {/* ドロップインジケーター */} {isOver && dragOverSide === 'left' && ( -
    +
    )} {isOver && dragOverSide === 'right' && ( -
    +
    )} {displayName} + {(tab as any).isDirty ? ( ) : ( @@ -354,268 +440,81 @@ export default function TabBar({ paneId }: TabBarProps) { ); } - useKeyBinding( - 'closeTab', - () => { - if (activeTabId) { - handleTabClose(activeTabId); - } - }, - [activeTabId, paneId] - ); - - useKeyBinding( - 'removeAllTabs', - () => { - handleRemoveAllTabs(); - }, - [tabs, paneId] - ); - - useKeyBinding( - 'nextTab', - () => { - if (tabs.length === 0) return; - const currentIndex = tabs.findIndex(t => t.id === activeTabId); - const nextIndex = (currentIndex + 1) % tabs.length; - activateTab(paneId, tabs[nextIndex].id); - }, - [tabs, activeTabId, paneId] - ); - - useKeyBinding( - 'prevTab', - () => { - if (tabs.length === 0) return; - const currentIndex = tabs.findIndex(t => t.id === activeTabId); - const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; - activateTab(paneId, tabs[prevIndex].id); - }, - [tabs, activeTabId, paneId] - ); - - // Markdown を現在開いているタブのプレビューを別のペインで開く - useKeyBinding( - 'openMdPreview', - () => { - // アクティブなペインのみ処理する - if (useTabStore.getState().activePane !== paneId) return; - - const activeTab = tabs.find(t => t.id === activeTabId); - if (!activeTab) return; - - const name = activeTab.name || ''; - const ext = name.split('.').pop()?.toLowerCase() || ''; - if (!(ext === 'md' || ext === 'mdx')) return; - - const leafPanes = flattenPanes(panes); - - // 1つだけのペインなら、横に分割してプレビューを開く - if (leafPanes.length === 1) { - // ここでは横幅(side-by-side)に追加するために 'vertical' を指定 - splitPane(paneId, 'vertical'); - - // splitPaneは同期的にストアを更新するため、直後に取得して子ペインを探索する - const parent = getPane(paneId); - if (!parent || !parent.children || parent.children.length === 0) return; - - // 空のタブリストを持つ子ペインを新規作成ペインとして想定 - let newPane = parent.children.find(c => !c.tabs || c.tabs.length === 0); - if (!newPane) { - // フォールバックとして二番目の子を採用 - newPane = parent.children[1] || parent.children[0]; - } - - if (newPane) { - openTab( - { name: activeTab.name, path: activeTab.path, content: (activeTab as any).content }, - { kind: 'preview', paneId: newPane.id, targetPaneId: newPane.id } - ); - } - return; - } - - // 複数ペインの場合は、自分以外のペインのうちランダムなペインで開く - const other = leafPanes.filter(p => p.id !== paneId); - if (other.length === 0) return; - // Prefer an empty pane if available for preview; else random - const emptyOther = other.find(p => !p.tabs || p.tabs.length === 0); - const randomPane = emptyOther || other[Math.floor(Math.random() * other.length)]; - openTab( - { name: activeTab.name, path: activeTab.path, content: (activeTab as any).content }, - { kind: 'preview', paneId: randomPane.id, targetPaneId: randomPane.id } - ); - }, - [paneId, activeTabId, tabs, panes] - ); - - // ペインのリストを取得(タブ移動用) - const flatPanes = flattenPanes(panes); - const availablePanes = flatPanes.map((p, idx) => ({ - id: p.id, - name: `Pane ${idx + 1}`, - })); - - // タブリストのコンテナ参照とホイールハンドラ(縦スクロールを横スクロールに変換) - const tabsContainerRef = useRef(null); - - const handleWheel = (e: React.WheelEvent) => { - // 主に縦スクロールを横スクロールに変換する - // (タッチパッドやマウスホイールで縦方向の入力が来たときに横にスクロールする) - try { - if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { - // deltaY を横方向に適用 - (e.currentTarget as HTMLDivElement).scrollBy({ left: e.deltaY, behavior: 'auto' }); - e.preventDefault(); - } - } catch (err) { - // 万が一のためフォールバックとして直接調整 - (e.currentTarget as HTMLDivElement).scrollLeft += e.deltaY; - e.preventDefault(); - } - }; - - // コンテナ自体もドロップ可能(末尾に追加) - const [, containerDrop] = useDrop( - () => ({ - accept: 'TAB', - drop: (item: any, monitor: any) => { - if (!item || !item.tabId) return; - // ドロップ先はこのペインの末尾 - if (item.fromPaneId === paneId) return; // 同じペインであれば無視(個別タブ上で処理) - moveTab(item.fromPaneId, paneId, item.tabId); - }, - }), - [paneId] - ); - return (
    - {/* メニューボタン */} + {/* ペインメニューボタン */}
    - {/* メニュー表示 */} - {menuOpen && ( + {/* ペインメニュー */} + {paneMenuOpen && (
    - {/* タブ管理ボタン (dev ブランチに合わせた見た目/順序) */} - {/* ペイン分割 (dev と同じスタイルと順序) */} - {/* 区切り線 */}
    )} @@ -625,8 +524,7 @@ export default function TabBar({ paneId }: TabBarProps) {
    { - tabsContainerRef.current = node; - // container にドロップリファレンスを繋ぐ + tabListContainerRef.current = node; if (node) containerDrop(node as any); }} onWheel={handleWheel} @@ -634,83 +532,67 @@ export default function TabBar({ paneId }: TabBarProps) { {tabs.map((tab, tabIndex) => ( ))} - - {/* 新しいタブを追加ボタン */}
    - {/* タブコンテキストメニュー */} - {tabContextMenu.isOpen && ( + {/* タブコンテキストメニュー(タブの真下に固定) */} + {tabContextMenu.isOpen && tabContextMenu.tabRect && (
    - {/* mdファイルの場合、プレビューを開くボタンを表示 */} - {(() => { - const tab = tabs.find(t => t.id === tabContextMenu.tabId); - const isMdFile = tab?.name.toLowerCase().endsWith('.md'); - return ( - isMdFile && ( - - ) - ); - })()} - + {/* Markdownプレビュー */} + {tabs.find(t => t.id === tabContextMenu.tabId)?.name.toLowerCase().endsWith('.md') && ( + + )} - - {/* ペイン移動メニュー */} {availablePanes.length > 1 && ( <>
    {t('tabBar.moveToPane')}
    - {availablePanes - .filter(p => p.id !== paneId) - .map(p => ( - - ))} + {availablePanes.filter(p => p.id !== paneId).map(p => ( + + ))} )}
    @@ -721,17 +603,13 @@ export default function TabBar({ paneId }: TabBarProps) { ); } -// ペインをフラット化するヘルパー関数 +// ペインをフラット化 function flattenPanes(panes: any[]): any[] { const result: any[] = []; const traverse = (panes: any[]) => { for (const pane of panes) { - if (!pane.children || pane.children.length === 0) { - result.push(pane); - } - if (pane.children) { - traverse(pane.children); - } + if (!pane.children || pane.children.length === 0) result.push(pane); + if (pane.children) traverse(pane.children); } }; traverse(panes); diff --git a/src/components/Tab/text-editor/editors/CodeMirrorEditor.tsx b/src/components/Tab/text-editor/editors/CodeMirrorEditor.tsx index 20249acd..93ce564c 100644 --- a/src/components/Tab/text-editor/editors/CodeMirrorEditor.tsx +++ b/src/components/Tab/text-editor/editors/CodeMirrorEditor.tsx @@ -14,10 +14,11 @@ interface CodeMirrorEditorProps { tabSize: number; insertSpaces: boolean; fontSize?: number; + isActive?: boolean; } export default function CodeMirrorEditor(props: CodeMirrorEditorProps) { - const { tabId, fileName, content, onChange, onSelectionChange, tabSize, insertSpaces, fontSize = 14 } = props; + const { tabId, fileName, content, onChange, onSelectionChange, tabSize, insertSpaces, fontSize = 14, isActive = false } = props; // CodeMirrorインスタンスのref const cmRef = useRef(null); @@ -36,6 +37,19 @@ export default function CodeMirrorEditor(props: CodeMirrorEditorProps) { } }, [content]); + // タブがアクティブになった時にエディタにフォーカスを当てる + useEffect(() => { + if (!isActive || !cmRef.current) return; + + // 少し遅延を入れてフォーカスを当てる(DOMの更新を待つ) + // Note: cmRef.current could become null between check and callback, so keep optional chaining + const timeoutId = setTimeout(() => { + cmRef.current?.view?.focus(); + }, 50); + + return () => clearTimeout(timeoutId); + }, [isActive]); + return (
    (null); const monacoRef = useRef(null); const [isEditorReady, setIsEditorReady] = useState(false); @@ -83,34 +87,20 @@ export default function MonacoEditor({ // テーマ定義は外部モジュールに移譲 try { - defineAndSetMonacoThemes(mon, colors as any); + defineAndSetMonacoThemes(mon, colors, themeName); } catch (e) { console.warn('[MonacoEditor] Failed to define/set themes via monaco-themes:', e); } - // TypeScript/JavaScript設定 - mon.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - noSuggestionDiagnostics: false, - }); - mon.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ - noSemanticValidation: false, - noSyntaxValidation: false, - noSuggestionDiagnostics: false, - }); - mon.languages.typescript.typescriptDefaults.setCompilerOptions({ - target: mon.languages.typescript.ScriptTarget.ES2020, - allowNonTsExtensions: true, - moduleResolution: mon.languages.typescript.ModuleResolutionKind.NodeJs, - module: mon.languages.typescript.ModuleKind.CommonJS, - noEmit: true, - esModuleInterop: true, - jsx: mon.languages.typescript.JsxEmit.React, - reactNamespace: 'React', - allowJs: true, - typeRoots: ['node_modules/@types'], - }); + // 言語診断設定(初回のみ) + if (!isLanguageDefaultsConfigured) { + try { + configureMonacoLanguageDefaults(mon); + isLanguageDefaultsConfigured = true; + } catch (e) { + console.warn('[MonacoEditor] Failed to configure language defaults:', e); + } + } // 選択範囲の文字数(スペース除外)を検知 editor.onDidChangeCursorSelection(e => { @@ -233,6 +223,20 @@ export default function MonacoEditor({ return () => clearTimeout(timeoutId); }, [jumpToLine, jumpToColumn, isEditorReady]); + // タブがアクティブになった時にエディタにフォーカスを当てる + useEffect(() => { + if (!isActive || !isEditorReady || !editorRef.current) return; + + // 少し遅延を入れてフォーカスを当てる(DOMの更新を待つ) + const timeoutId = setTimeout(() => { + if (editorRef.current && !(editorRef.current as any)._isDisposed) { + editorRef.current.focus(); + } + }, 50); + + return () => clearTimeout(timeoutId); + }, [isActive, isEditorReady]); + // クリーンアップ useEffect(() => { return () => { @@ -269,7 +273,7 @@ export default function MonacoEditor({ fontSize, lineNumbers: 'on', roundedSelection: false, - scrollBeyondLastLine: false, + scrollBeyondLastLine: true, automaticLayout: true, minimap: { enabled: true, diff --git a/src/components/Tab/text-editor/editors/monaco-language-defaults.ts b/src/components/Tab/text-editor/editors/monaco-language-defaults.ts new file mode 100644 index 00000000..50932d46 --- /dev/null +++ b/src/components/Tab/text-editor/editors/monaco-language-defaults.ts @@ -0,0 +1,155 @@ +import type { Monaco } from '@monaco-editor/react'; + +/** + * Configure Monaco language defaults for diagnostics and validation + * Supports: TypeScript, JavaScript, CSS, SCSS, LESS, JSON, HTML + */ +export function configureMonacoLanguageDefaults(mon: Monaco): void { + // TypeScript/JavaScript設定 + configureTypeScriptDefaults(mon); + + // CSS/SCSS/LESS設定 + configureCSSDefaults(mon); + + // JSON設定 + configureJSONDefaults(mon); + + // HTML設定 + configureHTMLDefaults(mon); +} + +function configureTypeScriptDefaults(mon: Monaco): void { + const diagnosticsOptions = { + noSemanticValidation: false, + noSyntaxValidation: false, + noSuggestionDiagnostics: false, + }; + + mon.languages.typescript.typescriptDefaults.setDiagnosticsOptions(diagnosticsOptions); + mon.languages.typescript.javascriptDefaults.setDiagnosticsOptions(diagnosticsOptions); + + const compilerOptions = { + target: mon.languages.typescript.ScriptTarget.ES2020, + allowNonTsExtensions: true, + moduleResolution: mon.languages.typescript.ModuleResolutionKind.NodeJs, + module: mon.languages.typescript.ModuleKind.CommonJS, + noEmit: true, + esModuleInterop: true, + jsx: mon.languages.typescript.JsxEmit.React, + reactNamespace: 'React', + allowJs: true, + typeRoots: ['node_modules/@types'], + }; + + mon.languages.typescript.typescriptDefaults.setCompilerOptions(compilerOptions); + mon.languages.typescript.javascriptDefaults.setCompilerOptions(compilerOptions); +} + +function configureCSSDefaults(mon: Monaco): void { + const cssLintOptions = { + compatibleVendorPrefixes: 'warning' as const, + vendorPrefix: 'warning' as const, + duplicateProperties: 'warning' as const, + emptyRules: 'warning' as const, + importStatement: 'ignore' as const, + boxModel: 'ignore' as const, + universalSelector: 'ignore' as const, + zeroUnits: 'ignore' as const, + fontFaceProperties: 'warning' as const, + hexColorLength: 'error' as const, + argumentsInColorFunction: 'error' as const, + unknownProperties: 'warning' as const, + ieHack: 'ignore' as const, + unknownVendorSpecificProperties: 'ignore' as const, + propertyIgnoredDueToDisplay: 'warning' as const, + important: 'ignore' as const, + float: 'ignore' as const, + idSelector: 'ignore' as const, + }; + + const cssOptions = { + validate: true, + lint: cssLintOptions, + }; + + mon.languages.css.cssDefaults.setOptions(cssOptions); + mon.languages.scss.scssDefaults.setOptions(cssOptions); + mon.languages.less.lessDefaults.setOptions(cssOptions); +} + +function configureJSONDefaults(mon: Monaco): void { + mon.languages.json.jsonDefaults.setDiagnosticsOptions({ + validate: true, + allowComments: true, + trailingCommas: 'warning', + schemaValidation: 'warning', + schemaRequest: 'warning', + comments: 'warning', + }); + + // Common JSON schemas + mon.languages.json.jsonDefaults.setModeConfiguration({ + documentFormattingEdits: true, + documentRangeFormattingEdits: true, + completionItems: true, + hovers: true, + documentSymbols: true, + tokens: true, + colors: true, + foldingRanges: true, + diagnostics: true, + selectionRanges: true, + }); +} + +function configureHTMLDefaults(mon: Monaco): void { + mon.languages.html.htmlDefaults.setOptions({ + format: { + tabSize: 2, + insertSpaces: true, + wrapLineLength: 120, + unformatted: 'wbr', + contentUnformatted: 'pre,code,textarea', + indentInnerHtml: false, + preserveNewLines: true, + maxPreserveNewLines: 2, + indentHandlebars: false, + endWithNewline: false, + extraLiners: 'head, body, /html', + wrapAttributes: 'auto', + }, + suggest: { + html5: true, + }, + }); + + // Also configure handlebars if available + try { + mon.languages.html.handlebarDefaults?.setOptions({ + format: { + tabSize: 2, + insertSpaces: true, + }, + suggest: { + html5: true, + }, + }); + } catch (e) { + // handlebars might not be available + } + + // Also configure razor if available + try { + mon.languages.html.razorDefaults?.setOptions({ + format: { + tabSize: 2, + insertSpaces: true, + }, + suggest: { + html5: true, + }, + }); + } catch (e) { + // razor might not be available + } +} diff --git a/src/components/Tab/text-editor/editors/monaco-themes.ts b/src/components/Tab/text-editor/editors/monaco-themes.ts index 4e8b7c6f..bd3c0d8b 100644 --- a/src/components/Tab/text-editor/editors/monaco-themes.ts +++ b/src/components/Tab/text-editor/editors/monaco-themes.ts @@ -1,41 +1,43 @@ import type { Monaco } from '@monaco-editor/react'; + import type { ThemeColors } from '@/context/ThemeContext'; -let themesDefined = false; +// ライトテーマのリスト +const LIGHT_THEMES = ['light', 'solarizedLight', 'pastelSoft']; -const isHexLight = (hex?: string) => { - if (!hex) return false; - try { - const h = hex.replace('#', '').trim(); - if (h.length === 3) { - const r = parseInt(h[0] + h[0], 16); - const g = parseInt(h[1] + h[1], 16); - const b = parseInt(h[2] + h[2], 16); - const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return lum > 0.7; - } - if (h.length === 6) { - const r = parseInt(h.substring(0, 2), 16); - const g = parseInt(h.substring(2, 4), 16); - const b = parseInt(h.substring(4, 6), 16); - const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return lum > 0.7; - } - } catch (e) { - // ignore - } - return false; -}; +// 最後に定義したテーマ名をキャッシュ +let lastThemeName: string | null = null; -export function defineAndSetMonacoThemes(mon: Monaco, colors: ThemeColors) { +export function defineAndSetMonacoThemes(mon: Monaco, colors: ThemeColors, themeName: string) { try { - if (!themesDefined) { - // dark - mon.editor.defineTheme('pyxis-dark', { - base: 'vs-dark', + const needsRedefine = lastThemeName !== themeName; + + if (needsRedefine) { + const useLight = LIGHT_THEMES.includes(themeName); + + // pyxis-custom テーマを定義(EditorとDiffEditorの両方で使用) + mon.editor.defineTheme('pyxis-custom', { + base: useLight ? 'vs' : 'vs-dark', inherit: true, - rules: [ - { token: 'comment', foreground: '6A9955', fontStyle: 'italic' }, + rules: useLight + ? [ + { token: 'comment', foreground: '6B737A', fontStyle: 'italic' }, + { token: 'keyword', foreground: '0b63c6', fontStyle: 'bold' }, + { token: 'string', foreground: 'a31515' }, + { token: 'number', foreground: '005cc5' }, + { token: 'regexp', foreground: 'b31b1b' }, + { token: 'operator', foreground: '333333' }, + { token: 'delimiter', foreground: '333333' }, + { token: 'type', foreground: '0b7a65' }, + { token: 'parameter', foreground: '1750a0' }, + { token: 'function', foreground: '795e26' }, + { token: 'tag', foreground: '0b7a65', fontStyle: 'bold' }, + { token: 'attribute.name', foreground: '1750a0', fontStyle: 'italic' }, + { token: 'attribute.value', foreground: 'a31515' }, + { token: 'jsx.text', foreground: '2d2d2d' }, + ] + : [ + { token: 'comment', foreground: '6A9955', fontStyle: 'italic' }, { token: 'comment.doc', foreground: '6A9955', fontStyle: 'italic' }, { token: 'keyword', foreground: '569CD6', fontStyle: 'bold' }, { token: 'string', foreground: 'CE9178' }, @@ -50,98 +52,66 @@ export function defineAndSetMonacoThemes(mon: Monaco, colors: ThemeColors) { { token: 'operator', foreground: 'D4D4D4' }, { token: 'delimiter', foreground: 'D4D4D4' }, { token: 'delimiter.bracket', foreground: 'FFD700' }, - - // 型・クラス系 { token: 'type', foreground: '4EC9B0' }, { token: 'type.identifier', foreground: '4EC9B0' }, { token: 'namespace', foreground: '4EC9B0' }, { token: 'struct', foreground: '4EC9B0' }, { token: 'class', foreground: '4EC9B0' }, { token: 'interface', foreground: '4EC9B0' }, - - // 変数・パラメータ系 { token: 'parameter', foreground: '9CDCFE' }, { token: 'variable', foreground: '9CDCFE' }, - { token: 'property', foreground: 'D4D4D4' }, // プロパティは白系に + { token: 'property', foreground: 'D4D4D4' }, { token: 'identifier', foreground: '9CDCFE' }, - - // 関数・メソッド系 { token: 'function', foreground: 'DCDCAA' }, { token: 'function.call', foreground: 'DCDCAA' }, { token: 'method', foreground: 'DCDCAA' }, - - // JSX専用トークン(強調表示) { token: 'tag', foreground: '4EC9B0', fontStyle: 'bold' }, { token: 'tag.jsx', foreground: '4EC9B0', fontStyle: 'bold' }, { token: 'attribute.name', foreground: '9CDCFE', fontStyle: 'italic' }, { token: 'attribute.name.jsx', foreground: '9CDCFE', fontStyle: 'italic' }, { token: 'attribute.value', foreground: 'CE9178' }, - { token: 'jsx.text', foreground: 'D4D4D4' }, // JSX本文テキストは白色 + { token: 'jsx.text', foreground: 'D4D4D4' }, { token: 'delimiter.html', foreground: 'FFD700' }, { token: 'attribute.name.html', foreground: '9CDCFE' }, { token: 'tag.tsx', foreground: '4EC9B0', fontStyle: 'bold' }, - { token: 'tag.jsx', foreground: '4EC9B0', fontStyle: 'bold' }, { token: 'text', foreground: 'D4D4D4' }, - ], - colors: { - 'editor.background': colors.editorBg || '#1e1e1e', - 'editor.foreground': colors.editorFg || '#d4d4d4', - 'editor.lineHighlightBackground': colors.editorLineHighlight || '#2d2d30', - 'editor.selectionBackground': colors.editorSelection || '#264f78', - 'editor.inactiveSelectionBackground': '#3a3d41', - 'editorCursor.foreground': colors.editorCursor || '#aeafad', - 'editorWhitespace.foreground': '#404040', - 'editorIndentGuide.background': '#404040', - 'editorIndentGuide.activeBackground': '#707070', - 'editorBracketMatch.background': '#0064001a', - 'editorBracketMatch.border': '#888888', - }, + ], + colors: useLight + ? { + 'editor.background': colors.editorBg || '#ffffff', + 'editor.foreground': colors.editorFg || '#222222', + 'editor.lineHighlightBackground': colors.editorLineHighlight || '#f0f0f0', + 'editor.selectionBackground': colors.editorSelection || '#cce7ff', + 'editor.inactiveSelectionBackground': '#f3f3f3', + 'editorCursor.foreground': colors.editorCursor || '#0070f3', + 'editorWhitespace.foreground': '#d0d0d0', + 'editorIndentGuide.background': '#e0e0e0', + 'editorIndentGuide.activeBackground': '#c0c0c0', + 'editorBracketMatch.background': '#00000005', + 'editorBracketMatch.border': '#88888822', + } + : { + 'editor.background': colors.editorBg || '#1e1e1e', + 'editor.foreground': colors.editorFg || '#d4d4d4', + 'editor.lineHighlightBackground': colors.editorLineHighlight || '#2d2d30', + 'editor.selectionBackground': colors.editorSelection || '#264f78', + 'editor.inactiveSelectionBackground': '#3a3d41', + 'editorCursor.foreground': colors.editorCursor || '#aeafad', + 'editorWhitespace.foreground': '#404040', + 'editorIndentGuide.background': '#404040', + 'editorIndentGuide.activeBackground': '#707070', + 'editorBracketMatch.background': '#0064001a', + 'editorBracketMatch.border': '#888888', + }, }); - // light - mon.editor.defineTheme('pyxis-light', { - base: 'vs', - inherit: true, - rules: [ - { token: 'comment', foreground: '6B737A', fontStyle: 'italic' }, - { token: 'keyword', foreground: '0b63c6', fontStyle: 'bold' }, - { token: 'string', foreground: 'a31515' }, - { token: 'number', foreground: '005cc5' }, - { token: 'regexp', foreground: 'b31b1b' }, - { token: 'operator', foreground: '333333' }, - { token: 'delimiter', foreground: '333333' }, - { token: 'type', foreground: '0b7a65' }, - { token: 'parameter', foreground: '1750a0' }, - { token: 'function', foreground: '795e26' }, - { token: 'tag', foreground: '0b7a65', fontStyle: 'bold' }, - { token: 'attribute.name', foreground: '1750a0', fontStyle: 'italic' }, - { token: 'attribute.value', foreground: 'a31515' }, - { token: 'jsx.text', foreground: '2d2d2d' }, - ], - colors: { - 'editor.background': colors.editorBg || '#ffffff', - 'editor.foreground': colors.editorFg || '#222222', - 'editor.lineHighlightBackground': colors.editorLineHighlight || '#f0f0f0', - 'editor.selectionBackground': colors.editorSelection || '#cce7ff', - 'editor.inactiveSelectionBackground': '#f3f3f3', - 'editorCursor.foreground': colors.editorCursor || '#0070f3', - 'editorWhitespace.foreground': '#d0d0d0', - 'editorIndentGuide.background': '#e0e0e0', - 'editorIndentGuide.activeBackground': '#c0c0c0', - 'editorBracketMatch.background': '#00000005', - 'editorBracketMatch.border': '#88888822', - }, - }); - - themesDefined = true; + lastThemeName = themeName; } - - const bg = colors?.editorBg || (colors as any)?.background || '#1e1e1e'; - const useLight = isHexLight(bg) || (typeof (colors as any).background === 'string' && /white|fff/i.test((colors as any).background)); - mon.editor.setTheme(useLight ? 'pyxis-light' : 'pyxis-dark'); + + // テーマを適用 + mon.editor.setTheme('pyxis-custom'); } catch (e) { // keep MonacoEditor resilient - // eslint-disable-next-line no-console console.warn('[monaco-themes] Failed to define/set themes:', e); } } diff --git a/src/components/Tab/text-editor/hooks/useMonacoModels.ts b/src/components/Tab/text-editor/hooks/useMonacoModels.ts index b906dd21..bd9731a4 100644 --- a/src/components/Tab/text-editor/hooks/useMonacoModels.ts +++ b/src/components/Tab/text-editor/hooks/useMonacoModels.ts @@ -5,6 +5,8 @@ import { useCallback } from 'react'; import { getLanguage } from '../editors/editor-utils'; import { getModelLanguage, getEnhancedLanguage } from '../editors/monarch-jsx-language'; +import { MONACO_CONFIG } from '@/context/config'; + // Monarch言語用のヘルパー function getMonarchLanguage(fileName: string): string { // Use the model language for TSX/JSX so the TypeScript diagnostics run. @@ -28,9 +30,43 @@ function getMonarchLanguage(fileName: string): string { // モジュール共有のモデルMap(シングルトン) const sharedModelMap: Map = new Map(); +// LRU順序を追跡するリスト(最近使われたものが後ろ) +const modelAccessOrder: string[] = []; + // モジュール共有の currentModelIdRef 互換オブジェクト const sharedCurrentModelIdRef: { current: string | null } = { current: null }; +// LRU順序を更新するヘルパー +function updateModelAccessOrder(tabId: string): void { + const index = modelAccessOrder.indexOf(tabId); + if (index > -1) { + modelAccessOrder.splice(index, 1); + } + modelAccessOrder.push(tabId); +} + +// 最も古いモデルを削除してキャパシティを確保 +function enforceModelLimit( + monacoModelMap: Map, + maxModels: number +): void { + while (monacoModelMap.size >= maxModels && modelAccessOrder.length > 0) { + const oldestTabId = modelAccessOrder.shift(); + if (oldestTabId) { + const oldModel = monacoModelMap.get(oldestTabId); + if (oldModel) { + try { + oldModel.dispose(); + console.log('[useMonacoModels] Disposed oldest model (LRU):', oldestTabId); + } catch (e) { + console.warn('[useMonacoModels] Failed to dispose model:', e); + } + monacoModelMap.delete(oldestTabId); + } + } + } +} + export function useMonacoModels() { const monacoModelMapRef = { current: sharedModelMap } as { current: Map; @@ -48,7 +84,7 @@ export function useMonacoModels() { content: string, fileName: string ): monaco.editor.ITextModel | null => { - // entry log removed in cleanup + // entry log removed in cleanup const monacoModelMap = monacoModelMapRef.current; let model = monacoModelMap.get(tabId); @@ -57,6 +93,8 @@ export function useMonacoModels() { // may be attaching to the same underlying model. Instead we remove it from // our map and create a new model with a unique URI when languages differ. if (isModelSafe(model)) { + // Update LRU access order + updateModelAccessOrder(tabId); try { const desiredLang = getModelLanguage(fileName); const currentLang = model!.getLanguageId(); @@ -78,6 +116,9 @@ export function useMonacoModels() { } if (!model) { + // Enforce model limit before creating a new model + enforceModelLimit(monacoModelMap, MONACO_CONFIG.MAX_MONACO_MODELS); + try { // Use the tabId to construct a unique in-memory URI so different // tabs/files with the same base filename don't collide. @@ -103,11 +144,13 @@ export function useMonacoModels() { const uniqueUri = mon.Uri.parse(`${uri.toString()}__${Date.now()}`); const newModel = mon.editor.createModel(content, desiredLang, uniqueUri); monacoModelMap.set(tabId, newModel); + updateModelAccessOrder(tabId); return newModel; } // Languages already match — reuse safely. // reuse log removed in cleanup monacoModelMap.set(tabId, existingModel); + updateModelAccessOrder(tabId); return existingModel; } catch (e) { console.warn('[useMonacoModels] Reuse/create logic failed:', e); @@ -128,13 +171,16 @@ export function useMonacoModels() { // not critical } monacoModelMap.set(tabId, newModel); + updateModelAccessOrder(tabId); console.log( '[useMonacoModels] Created new model for:', tabId, 'language:', language, 'uri:', - uri.toString() + uri.toString(), + 'total models:', + monacoModelMap.size ); return newModel; } catch (createError: any) { @@ -158,6 +204,11 @@ export function useMonacoModels() { console.warn('[useMonacoModels] Failed to dispose model:', e); } monacoModelMap.delete(tabId); + // Remove from LRU access order + const index = modelAccessOrder.indexOf(tabId); + if (index > -1) { + modelAccessOrder.splice(index, 1); + } } }, []); @@ -172,6 +223,8 @@ export function useMonacoModels() { } }); monacoModelMap.clear(); + // Clear LRU access order + modelAccessOrder.length = 0; currentModelIdRef.current = null; }, [currentModelIdRef]); diff --git a/src/constants/dndTypes.ts b/src/constants/dndTypes.ts new file mode 100644 index 00000000..50ea7582 --- /dev/null +++ b/src/constants/dndTypes.ts @@ -0,0 +1,40 @@ +/** + * react-dnd用のドラッグタイプ定数 + * 全てのD&D関連コンポーネントで共通で使用 + */ + +// タブのドラッグタイプ +export const DND_TAB = 'TAB'; + +// ファイルツリーアイテムのドラッグタイプ +export const DND_FILE_TREE_ITEM = 'FILE_TREE_ITEM'; + +// ドラッグアイテムの型定義 +export interface TabDragItem { + type: typeof DND_TAB; + tabId: string; + fromPaneId: string; +} + +export interface FileTreeDragItem { + type: typeof DND_FILE_TREE_ITEM; + item: { + id: string; + name: string; + path: string; + type: 'file' | 'folder'; + isBufferArray?: boolean; + [key: string]: any; + }; +} + +export type DragItem = TabDragItem | FileTreeDragItem; + +// ドラッグタイプを判定するヘルパー関数 +export function isTabDragItem(item: any): item is TabDragItem { + return item && item.type === DND_TAB && typeof item.tabId === 'string'; +} + +export function isFileTreeDragItem(item: any): item is FileTreeDragItem { + return item && item.type === DND_FILE_TREE_ITEM && item.item; +} diff --git a/src/context/I18nContext.tsx b/src/context/I18nContext.tsx index 5a0e3a31..d14dc924 100644 --- a/src/context/I18nContext.tsx +++ b/src/context/I18nContext.tsx @@ -29,6 +29,9 @@ const I18nContext = createContext(undefined); const LOCALE_STORAGE_KEY = LOCALSTORAGE_KEY.LOCALE; +// 初期ロケール計算のキャッシュ(一度だけ計算) +let cachedInitialLocale: Locale | null = null; + /** * 有効化された言語パック拡張機能から利用可能な言語を取得 */ @@ -47,6 +50,11 @@ function detectBrowserLocale(): Locale { const browserLang = navigator.language.split('-')[0].toLowerCase(); const enabledLocales = getEnabledLocales(); + // まだ言語パックがロードされていない場合はブラウザ言語をそのまま使用 + if (enabledLocales.size === 0 && isSupportedLocale(browserLang)) { + return browserLang as Locale; + } + // 有効化された言語パックの中にブラウザ言語があるかチェック if (enabledLocales.has(browserLang) && isSupportedLocale(browserLang)) { return browserLang as Locale; @@ -78,6 +86,10 @@ function getSavedLocale(): Locale | null { if (saved && isSupportedLocale(saved)) { // 保存された言語が有効化された言語パックの中にあるかチェック const enabledLocales = getEnabledLocales(); + // まだ言語パックがロードされていない場合は保存値をそのまま使用 + if (enabledLocales.size === 0) { + return saved as Locale; + } if (enabledLocales.has(saved)) { return saved as Locale; } @@ -123,9 +135,12 @@ interface I18nProviderProps { } export function I18nProvider({ children, defaultLocale }: I18nProviderProps) { - // 初期ロケールの決定: 保存された値 → ブラウザ設定 → プロップス → デフォルト - const initialLocale = - getSavedLocale() || detectBrowserLocale() || defaultLocale || DEFAULT_LOCALE; + // 初期ロケールの決定(キャッシュを使用して一度だけ計算) + if (cachedInitialLocale === null) { + cachedInitialLocale = + getSavedLocale() || detectBrowserLocale() || defaultLocale || DEFAULT_LOCALE; + } + const initialLocale = cachedInitialLocale; const [locale, setLocaleState] = useState(initialLocale); const [translations, setTranslations] = useState>({}); diff --git a/src/context/config.ts b/src/context/config.ts index 54b77639..267a85ff 100644 --- a/src/context/config.ts +++ b/src/context/config.ts @@ -21,4 +21,10 @@ export const OUTPUT_CONFIG = { OUTPUT_MAX_MESSAGES: 30, }; +// Monaco editor related configuration +export const MONACO_CONFIG = { + // Maximum number of Monaco models to keep in memory + MAX_MONACO_MODELS: 5, +}; + export const DEFAULT_LOCALE = 'en'; diff --git a/src/engine/ai/contextBuilder.ts b/src/engine/ai/contextBuilder.ts index 5d902c31..1113ff02 100644 --- a/src/engine/ai/contextBuilder.ts +++ b/src/engine/ai/contextBuilder.ts @@ -181,3 +181,44 @@ export function getSelectedFileContexts( content: ctx.content, })); } + +// Custom instructions file path +export const CUSTOM_INSTRUCTIONS_PATH = '.pyxis/pyxis-instructions.md'; + +/** + * Extract custom instructions from file contexts if .pyxis/pyxis-instructions.md exists + */ +export function getCustomInstructions( + contexts: AIFileContext[] +): string | undefined { + const instructionsFile = contexts.find( + ctx => ctx.path === CUSTOM_INSTRUCTIONS_PATH || + ctx.path.endsWith('/.pyxis/pyxis-instructions.md') || + ctx.path === 'pyxis-instructions.md' + ); + + if (instructionsFile && instructionsFile.content) { + return instructionsFile.content; + } + + return undefined; +} + +/** + * Find custom instructions from a flat file list + */ +export function findCustomInstructionsFromFiles( + files: Array<{ path: string; content?: string }> +): string | undefined { + const instructionsFile = files.find( + f => f.path === CUSTOM_INSTRUCTIONS_PATH || + f.path.endsWith('/.pyxis/pyxis-instructions.md') || + f.path.endsWith('/pyxis-instructions.md') + ); + + if (instructionsFile && instructionsFile.content) { + return instructionsFile.content; + } + + return undefined; +} diff --git a/src/engine/ai/fetchAI.ts b/src/engine/ai/fetchAI.ts index d5ca3fe4..9939b890 100644 --- a/src/engine/ai/fetchAI.ts +++ b/src/engine/ai/fetchAI.ts @@ -1,19 +1,34 @@ // src/utils/ai/geminiClient.ts -const GEMINI_API_URL = - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'; +const GEMINI_STREAM_API_URL = + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-live-001:streamGenerateContent'; -export async function generateCodeEdit(prompt: string, apiKey: string): Promise { +/** + * Stream chat response from Gemini API + * @param message - User message + * @param context - Context strings + * @param apiKey - Gemini API key + * @param onChunk - Callback for each chunk of text + */ +export async function streamChatResponse( + message: string, + context: string[], + apiKey: string, + onChunk: (chunk: string) => void +): Promise { if (!apiKey) throw new Error('Gemini API key is missing'); + const contextText = context.length > 0 ? `\n\n参考コンテキスト:\n${context.join('\n---\n')}` : ''; + const prompt = `${message}${contextText}`; + try { - const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { + const response = await fetch(`${GEMINI_STREAM_API_URL}?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { - temperature: 0.1, // より確実な回答のため温度を下げる - maxOutputTokens: 4096, + temperature: 0.7, + maxOutputTokens: 2048, }, }), }); @@ -22,41 +37,82 @@ export async function generateCodeEdit(prompt: string, apiKey: string): Promise< throw new Error(`HTTP error! status: ${response.status}`); } - const data = await response.json(); - const result = data?.candidates?.[0]?.content?.parts?.[0]?.text; + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; - console.log('[original response]', result); + buffer += decoder.decode(value, { stream: true }); + + // Split by lines and process complete JSON objects + const lines = buffer.split('\n'); + + // Keep the last incomplete line in the buffer + buffer = lines.pop() || ''; - if (!result) { - throw new Error('No response from Gemini API'); + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + try { + const parsed = JSON.parse(trimmedLine); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + // Skip invalid JSON lines + console.warn('[streamChatResponse] Failed to parse chunk:', trimmedLine.substring(0, 100)); + } + } } - return result; + // Process any remaining buffer + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + console.warn('[streamChatResponse] Failed to parse final chunk'); + } + } } catch (error) { - throw new Error('Gemini API error: ' + (error as Error).message); + throw new Error('Gemini API streaming error: ' + (error as Error).message); } } -export async function generateChatResponse( - message: string, - context: string[], - apiKey: string -): Promise { +/** + * Stream code edit response from Gemini API + * @param prompt - Edit prompt + * @param apiKey - Gemini API key + * @param onChunk - Callback for each chunk of text + */ +export async function streamCodeEdit( + prompt: string, + apiKey: string, + onChunk: (chunk: string) => void +): Promise { if (!apiKey) throw new Error('Gemini API key is missing'); - const contextText = context.length > 0 ? `\n\n参考コンテキスト:\n${context.join('\n---\n')}` : ''; - - const prompt = `${message}${contextText}`; - try { - const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { + const response = await fetch(`${GEMINI_STREAM_API_URL}?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { - temperature: 0.7, - maxOutputTokens: 2048, + temperature: 0.1, + maxOutputTokens: 4096, }, }), }); @@ -65,16 +121,56 @@ export async function generateChatResponse( throw new Error(`HTTP error! status: ${response.status}`); } - const data = await response.json(); - const result = data?.candidates?.[0]?.content?.parts?.[0]?.text; + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; - if (!result) { - throw new Error('No response from Gemini API'); + buffer += decoder.decode(value, { stream: true }); + + // Split by lines and process complete JSON objects + const lines = buffer.split('\n'); + + // Keep the last incomplete line in the buffer + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + try { + const parsed = JSON.parse(trimmedLine); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + // Skip invalid JSON lines + console.warn('[streamCodeEdit] Failed to parse chunk:', trimmedLine.substring(0, 100)); + } + } } - console.log('[original response]', result); - return result; + // Process any remaining buffer + if (buffer.trim()) { + try { + const parsed = JSON.parse(buffer.trim()); + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text; + if (text) { + onChunk(text); + } + } catch (e) { + console.warn('[streamCodeEdit] Failed to parse final chunk'); + } + } } catch (error) { - throw new Error('Gemini API error: ' + (error as Error).message); + throw new Error('Gemini API streaming error: ' + (error as Error).message); } } diff --git a/src/engine/ai/patchApplier.ts b/src/engine/ai/patchApplier.ts new file mode 100644 index 00000000..493a03f6 --- /dev/null +++ b/src/engine/ai/patchApplier.ts @@ -0,0 +1,502 @@ +/** + * Multi-Patch Applier for AI Code Editing + * + * This module provides robust SEARCH/REPLACE block-based patch application + * similar to GitHub Copilot and Cursor's approach. + * + * Format: + * <<<<<<< SEARCH + * [exact text to find] + * ======= + * [replacement text] + * >>>>>>> REPLACE + */ + +export interface SearchReplaceBlock { + search: string; + replace: string; + lineNumber?: number; // Optional hint for fuzzy matching +} + +export interface PatchBlock { + filePath: string; + blocks: SearchReplaceBlock[]; + explanation?: string; + isNewFile?: boolean; + fullContent?: string; // For new files or full replacement +} + +export interface PatchResult { + success: boolean; + filePath: string; + originalContent: string; + patchedContent: string; + appliedBlocks: number; + failedBlocks: SearchReplaceBlock[]; + errors: string[]; + isNewFile?: boolean; +} + +export interface MultiPatchResult { + results: PatchResult[]; + totalSuccess: number; + totalFailed: number; + overallSuccess: boolean; +} + +/** + * Normalize line endings to LF + */ +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/** + * Normalize whitespace for comparison (trims trailing whitespace per line) + */ +function normalizeForComparison(text: string): string { + // More efficient regex-based approach for large files + return text.replace(/[ \t]+$/gm, ''); +} + +/** + * Calculate line-based similarity score (0-1) + */ +function calculateLineSimilarity(a: string, b: string): number { + if (a === b) return 1; + if (!a || !b) return 0; + + const aLines = a.split('\n'); + const bLines = b.split('\n'); + + if (aLines.length !== bLines.length) { + const lengthDiff = Math.abs(aLines.length - bLines.length); + if (lengthDiff > Math.max(aLines.length, bLines.length) * 0.2) { + return 0; + } + } + + let matches = 0; + const maxLines = Math.max(aLines.length, bLines.length); + + for (let i = 0; i < Math.min(aLines.length, bLines.length); i++) { + const lineA = aLines[i].trim(); + const lineB = bLines[i].trim(); + if (lineA === lineB) { + matches++; + } else if (lineA.length > 0 && lineB.length > 0) { + if (lineA.includes(lineB) || lineB.includes(lineA)) { + matches += 0.5; + } + } + } + + return matches / maxLines; +} + +/** + * Find exact match position in content + */ +function findExactMatch( + content: string, + search: string, + startFrom: number = 0 +): { index: number; matchedText: string } | null { + // Try exact match first + const exactIndex = content.indexOf(search, startFrom); + if (exactIndex !== -1) { + return { index: exactIndex, matchedText: search }; + } + + // Try with normalized whitespace + const normalizedContent = normalizeForComparison(content); + const normalizedSearch = normalizeForComparison(search); + + const normalizedIndex = normalizedContent.indexOf(normalizedSearch, startFrom); + if (normalizedIndex !== -1) { + // Find corresponding position in original content + const contentLines = content.split('\n'); + const normalizedLines = normalizedContent.split('\n'); + const searchLines = normalizedSearch.split('\n'); + + // Count which line the match starts on + let charCount = 0; + let startLine = 0; + for (let i = 0; i < normalizedLines.length; i++) { + if (charCount + normalizedLines[i].length >= normalizedIndex) { + startLine = i; + break; + } + charCount += normalizedLines[i].length + 1; // +1 for newline + } + + // Build the original index + let originalStartIndex = 0; + for (let i = 0; i < startLine; i++) { + originalStartIndex += contentLines[i].length + 1; + } + + // Calculate column offset within the line + const columnOffset = normalizedIndex - charCount; + originalStartIndex += Math.min(columnOffset, contentLines[startLine]?.length || 0); + + // Find end index + const endLine = startLine + searchLines.length - 1; + let originalEndIndex = originalStartIndex; + for (let i = startLine; i <= endLine && i < contentLines.length; i++) { + if (i === endLine) { + originalEndIndex += contentLines[i].length; + } else { + originalEndIndex += contentLines[i].length + 1; + } + } + + const matchedText = content.substring(originalStartIndex, originalEndIndex); + + // Verify the match + const matchSimilarity = calculateLineSimilarity(normalizedSearch, normalizeForComparison(matchedText)); + if (matchSimilarity > 0.9) { + return { index: originalStartIndex, matchedText }; + } + } + + return null; +} + +/** + * Find the best fuzzy match for search text in content + */ +function findFuzzyMatch( + content: string, + search: string, + startFrom: number = 0 +): { index: number; matchedText: string; confidence: number } | null { + const normalizedContent = normalizeForComparison(content); + const normalizedSearch = normalizeForComparison(search); + + const contentLines = content.split('\n'); + const searchLines = normalizedSearch.split('\n'); + + if (searchLines.length === 0) return null; + + const firstSearchLine = searchLines[0].trim(); + if (!firstSearchLine) return null; + + let bestMatch: { index: number; matchedText: string; confidence: number } | null = null; + + for (let i = 0; i < contentLines.length - searchLines.length + 1; i++) { + // Check if first line matches + const contentLineNormalized = contentLines[i].trim(); + if (!contentLineNormalized.includes(firstSearchLine) && + !firstSearchLine.includes(contentLineNormalized)) { + continue; + } + + // Check all lines + let matchScore = 0; + for (let j = 0; j < searchLines.length; j++) { + const contentLine = contentLines[i + j]?.trim() || ''; + const searchLine = searchLines[j].trim(); + + if (contentLine === searchLine) { + matchScore += 1; + } else if (contentLine.includes(searchLine) || searchLine.includes(contentLine)) { + matchScore += 0.7; + } + } + + const confidence = matchScore / searchLines.length; + + if (confidence > 0.8 && (!bestMatch || confidence > bestMatch.confidence)) { + // Calculate exact positions in original content + let startIndex = 0; + for (let k = 0; k < i; k++) { + startIndex += contentLines[k].length + 1; + } + + // Skip if before startFrom + if (startIndex < startFrom) continue; + + let endIndex = startIndex; + for (let k = i; k < i + searchLines.length && k < contentLines.length; k++) { + endIndex += contentLines[k].length + (k < i + searchLines.length - 1 ? 1 : 0); + } + + bestMatch = { + index: startIndex, + matchedText: content.substring(startIndex, endIndex), + confidence, + }; + } + } + + return bestMatch; +} + +/** + * Apply a single SEARCH/REPLACE block to content + */ +export function applySearchReplaceBlock( + content: string, + block: SearchReplaceBlock, + startFrom: number = 0 +): { success: boolean; content: string; error?: string; matchEnd?: number } { + const normalizedContent = normalizeLineEndings(content); + const normalizedSearch = normalizeLineEndings(block.search); + const normalizedReplace = normalizeLineEndings(block.replace); + + // Handle empty search (insert at beginning or end) + if (!normalizedSearch.trim()) { + if (block.lineNumber !== undefined && block.lineNumber > 0) { + const lines = normalizedContent.split('\n'); + const insertIndex = Math.min(block.lineNumber - 1, lines.length); + lines.splice(insertIndex, 0, normalizedReplace); + return { + success: true, + content: lines.join('\n'), + matchEnd: 0, + }; + } + return { + success: false, + content: normalizedContent, + error: 'Empty search pattern requires a lineNumber hint for insertion. Provide lineNumber in the SearchReplaceBlock.', + }; + } + + // Try exact match first + const exactMatch = findExactMatch(normalizedContent, normalizedSearch, startFrom); + if (exactMatch) { + const before = normalizedContent.substring(0, exactMatch.index); + const after = normalizedContent.substring(exactMatch.index + exactMatch.matchedText.length); + const newContent = before + normalizedReplace + after; + + return { + success: true, + content: newContent, + matchEnd: exactMatch.index + normalizedReplace.length, + }; + } + + // Try fuzzy match + const fuzzyMatch = findFuzzyMatch(normalizedContent, normalizedSearch, startFrom); + if (fuzzyMatch && fuzzyMatch.confidence > 0.85) { + const before = normalizedContent.substring(0, fuzzyMatch.index); + const after = normalizedContent.substring(fuzzyMatch.index + fuzzyMatch.matchedText.length); + const newContent = before + normalizedReplace + after; + + return { + success: true, + content: newContent, + matchEnd: fuzzyMatch.index + normalizedReplace.length, + }; + } + + return { + success: false, + content: normalizedContent, + error: `Could not find matching text for search block`, + }; +} + +/** + * Apply multiple SEARCH/REPLACE blocks to content + * Blocks are applied in order + */ +export function applyMultipleBlocks( + content: string, + blocks: SearchReplaceBlock[] +): { success: boolean; content: string; appliedCount: number; failedBlocks: SearchReplaceBlock[]; errors: string[] } { + let currentContent = normalizeLineEndings(content); + const failedBlocks: SearchReplaceBlock[] = []; + const errors: string[] = []; + let appliedCount = 0; + + for (const block of blocks) { + const result = applySearchReplaceBlock(currentContent, block, 0); + + if (result.success) { + currentContent = result.content; + appliedCount++; + } else { + failedBlocks.push(block); + errors.push(result.error || 'Unknown error'); + } + } + + return { + success: failedBlocks.length === 0, + content: currentContent, + appliedCount, + failedBlocks, + errors, + }; +} + +/** + * Apply a complete patch block (potentially with multiple search/replace operations) + */ +export function applyPatchBlock( + originalContent: string, + patch: PatchBlock +): PatchResult { + // Handle new file creation + if (patch.isNewFile && patch.fullContent !== undefined) { + return { + success: true, + filePath: patch.filePath, + originalContent: '', + patchedContent: normalizeLineEndings(patch.fullContent), + appliedBlocks: 1, + failedBlocks: [], + errors: [], + isNewFile: true, + }; + } + + // Handle full file replacement (legacy format) + if (patch.fullContent !== undefined && patch.blocks.length === 0) { + return { + success: true, + filePath: patch.filePath, + originalContent, + patchedContent: normalizeLineEndings(patch.fullContent), + appliedBlocks: 1, + failedBlocks: [], + errors: [], + }; + } + + // Apply search/replace blocks + const result = applyMultipleBlocks(originalContent, patch.blocks); + + return { + success: result.success, + filePath: patch.filePath, + originalContent, + patchedContent: result.content, + appliedBlocks: result.appliedCount, + failedBlocks: result.failedBlocks, + errors: result.errors, + }; +} + +/** + * Apply multiple patch blocks to multiple files + */ +export function applyMultiplePatches( + patches: PatchBlock[], + fileContents: Map +): MultiPatchResult { + const results: PatchResult[] = []; + let totalSuccess = 0; + let totalFailed = 0; + + for (const patch of patches) { + const originalContent = fileContents.get(patch.filePath) || ''; + const result = applyPatchBlock(originalContent, patch); + + results.push(result); + if (result.success) { + totalSuccess++; + } else { + totalFailed++; + } + } + + return { + results, + totalSuccess, + totalFailed, + overallSuccess: totalFailed === 0, + }; +} + +/** + * Parse SEARCH/REPLACE blocks from raw text + */ +export function parseSearchReplaceBlocks(text: string): SearchReplaceBlock[] { + const blocks: SearchReplaceBlock[] = []; + + // Pattern for SEARCH/REPLACE blocks + const blockPattern = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g; + + let match; + while ((match = blockPattern.exec(text)) !== null) { + blocks.push({ + search: match[1], + replace: match[2], + }); + } + + return blocks; +} + +/** + * Validate that search text exists in content + */ +export function validateSearchExists(content: string, search: string): boolean { + const normalizedContent = normalizeLineEndings(content); + const normalizedSearch = normalizeLineEndings(search); + + // Exact match + if (normalizedContent.includes(normalizedSearch)) { + return true; + } + + // Try normalized comparison + const normalizedContentTrimmed = normalizeForComparison(normalizedContent); + const normalizedSearchTrimmed = normalizeForComparison(normalizedSearch); + + if (normalizedContentTrimmed.includes(normalizedSearchTrimmed)) { + return true; + } + + // Fuzzy match + const match = findFuzzyMatch(normalizedContent, normalizedSearch); + return match !== null && match.confidence > 0.85; +} + +/** + * Format a patch block for display/debugging + */ +export function formatPatchBlock(block: SearchReplaceBlock): string { + return `<<<<<<< SEARCH +${block.search} +======= +${block.replace} +>>>>>>> REPLACE`; +} + +/** + * Create a simple single replacement patch + */ +export function createSimplePatch( + filePath: string, + search: string, + replace: string, + explanation?: string +): PatchBlock { + return { + filePath, + blocks: [{ search, replace }], + explanation, + }; +} + +/** + * Create a new file patch + */ +export function createNewFilePatch( + filePath: string, + content: string, + explanation?: string +): PatchBlock { + return { + filePath, + blocks: [], + fullContent: content, + isNewFile: true, + explanation, + }; +} diff --git a/src/engine/ai/prompts.ts b/src/engine/ai/prompts.ts index 6b820efb..02778bf5 100644 --- a/src/engine/ai/prompts.ts +++ b/src/engine/ai/prompts.ts @@ -1,131 +1,283 @@ -// AI Agent用のプロンプトテンプレート +/** + * AI Agent Prompt Templates + * + * Multi-patch editing system using SEARCH/REPLACE blocks + * for precise, minimal code changes. + */ -const SYSTEM_PROMPT = `あなたは優秀なコード編集アシスタントです。 -ユーザーからコードの編集指示を受けて、適切な変更を提案してください。 +const SYSTEM_PROMPT = `You are an expert code editing assistant. You receive code editing instructions and provide precise, minimal changes. -重要: 必ず以下の形式で回答してください。この形式を厳密に守ってください。 +CRITICAL: You MUST follow the exact response format below. Do not deviate from this format. -制約: -- 変更は最小限に留める -- 既存のコードスタイルに合わせる -- 変更理由を簡潔に説明する +## Response Format -回答形式(必須): -変更が必要な各ファイルについて、必ず以下の正確な形式で回答してください: +For each file you need to modify, use this EXACT format: -## 変更ファイル: [ファイルパス] +### File: [filepath] +**Reason**: [brief explanation of the change] -**変更理由**: [変更理由の説明] +Use SEARCH/REPLACE blocks to specify changes. Each block finds exact text and replaces it: - -[変更後のファイル全体の内容をここに記述] - +\`\`\` +<<<<<<< SEARCH +[exact lines to find - must match the file exactly] +======= +[replacement lines] +>>>>>>> REPLACE +\`\`\` ---- +### Multiple Changes in One File + +You can have multiple SEARCH/REPLACE blocks for the same file: + +\`\`\` +<<<<<<< SEARCH +[first section to find] +======= +[first replacement] +>>>>>>> REPLACE +\`\`\` + +\`\`\` +<<<<<<< SEARCH +[second section to find] +======= +[second replacement] +>>>>>>> REPLACE +\`\`\` + +### New File Creation + +For new files, use the NEW_FILE tag: + +### File: [new/filepath] +**Reason**: Creating new file + +\`\`\` +<<<<<<< NEW_FILE +[entire file content] +>>>>>>> NEW_FILE +\`\`\` + +## Rules + +1. SEARCH blocks must match EXACTLY (including whitespace and indentation) +2. Include enough context lines (3-5 lines before/after) to ensure unique matching +3. Keep changes minimal - only change what's necessary +4. Preserve existing code style and formatting +5. Each SEARCH/REPLACE pair handles ONE logical change +6. For deletions, use empty REPLACE section +7. Order matters - apply changes top-to-bottom in the file + +## Example + +For adding a new function parameter: + +\`\`\` +<<<<<<< SEARCH +function greet(name: string) { + console.log(\`Hello, \${name}!\`); +} +======= +function greet(name: string, greeting: string = "Hello") { + console.log(\`\${greeting}, \${name}!\`); +} +>>>>>>> REPLACE +\`\`\``; + +/** + * Format history messages to a compact form + * - User messages: instruction content only + * - Assistant messages (edit): changed file paths and explanations only (no code content) + * - Assistant messages (ask): answer content + */ +function formatHistoryMessages( + previousMessages?: Array<{ type: string; content: string; mode?: string; editResponse?: any }> +): string { + if (!previousMessages || previousMessages.length === 0) return ''; -注意事項: -- ## 変更ファイル: と **変更理由**: の後には改行を入れてください -- コードブロックは で囲んでください -- [ファイルパス]の部分には、## 変更ファイル: に記載したものと同じファイルパスを記述してください -- これらのタグは絶対に変更・省略しないでください -- ファイルパスは提供されたパスを正確にコピーしてください + // Summarize last 5 messages + return previousMessages + .slice(-5) + .map(msg => { + const role = msg.type === 'user' ? 'User' : 'Assistant'; + const modeLabel = msg.mode === 'edit' ? '[Edit]' : '[Chat]'; -必ずマークダウン形式で、上記の構造を守って回答してください。`; + // For assistant edit messages, generate summary from editResponse + if (msg.type === 'assistant' && msg.mode === 'edit' && msg.editResponse) { + const files = msg.editResponse.changedFiles || []; + if (files.length > 0) { + const summary = files + .map((f: any) => '- ' + f.path + ': ' + (f.explanation || 'modified')) + .join('\n'); + return '### ' + role + ' ' + modeLabel + '\nChanged files:\n' + summary; + } + } + + // Otherwise, include content directly (truncate if too long) + const content = msg.content.length > 500 ? msg.content.slice(0, 500) + '...' : msg.content; + return '### ' + role + ' ' + modeLabel + '\n' + content; + }) + .join('\n\n'); +} + +/** + * Format custom instructions from .pyxis/pyxis-instructions.md + */ +function formatCustomInstructions(customInstructions?: string): string { + if (!customInstructions || customInstructions.trim().length === 0) { + return ''; + } + + return `## Project-Specific Instructions + +The project has provided the following custom instructions that you MUST follow: + + +${customInstructions} + + +`; +} export const ASK_PROMPT_TEMPLATE = ( files: Array<{ path: string; content: string }>, question: string, - previousMessages?: Array<{ type: string; content: string; mode?: string }> + previousMessages?: Array<{ type: string; content: string; mode?: string; editResponse?: any }>, + customInstructions?: string ) => { - // 直近5件のメッセージをまとめる - const history = - previousMessages && previousMessages.length > 0 - ? previousMessages - .slice(-5) - .map( - msg => - `### ${msg.type === 'user' ? 'ユーザー' : 'アシスタント'}: ${msg.mode === 'edit' ? '編集' : '会話'}\n${msg.content}` - ) - .join('\n\n') - : ''; + const history = formatHistoryMessages(previousMessages); + const customInstr = formatCustomInstructions(customInstructions); const fileContexts = files .map( file => ` -## ファイル: ${file.path} - +## File: ${file.path} +\`\`\` ${file.content} - +\`\`\` ` ) .join('\n'); - return `あなたは優秀なコードアシスタント。ユーザーの質問に対して、ファイル内容や履歴を参考に、分かりやすく回答しろ。ユーザーの母国語に合わせて。 + return `You are an expert code assistant. Answer the user's question clearly and concisely, referencing the provided files and conversation history as needed. Match the user's language in your response. -${history ? `## これまでの会話履歴\n${history}\n` : ''} +${customInstr}${history ? '## Conversation History\n' + history + '\n' : ''} -${fileContexts ? `## 提供されたファイル\n${fileContexts}\n` : ''} +${fileContexts ? '## Provided Files\n' + fileContexts + '\n' : ''} -## 質問 +## Question ${question} --- -回答は分かりやすく簡潔にお願いします。コード例が必要な場合は適切なコードブロックを使ってください。`; +Provide a clear, helpful response. Use code blocks when showing code examples.`; }; export const EDIT_PROMPT_TEMPLATE = ( files: Array<{ path: string; content: string }>, instruction: string, - previousMessages?: Array<{ type: string; content: string; mode?: string }> + previousMessages?: Array<{ type: string; content: string; mode?: string; editResponse?: any }>, + customInstructions?: string ) => { - // 直近5件のメッセージをまとめる - const history = - previousMessages && previousMessages.length > 0 - ? previousMessages - .slice(-5) - .map( - msg => - `### ${msg.type === 'user' ? 'ユーザー' : 'アシスタント'}: ${msg.mode === 'edit' ? '編集' : '会話'}\n${msg.content}` - ) - .join('\n\n') - : ''; + const history = formatHistoryMessages(previousMessages); + const customInstr = formatCustomInstructions(customInstructions); + // Current file contents (these are the editing targets) const fileContexts = files .map( file => ` -## ファイル: ${file.path} - +## File: ${file.path} +\`\`\` ${file.content} - +\`\`\` ` ) .join('\n'); return `${SYSTEM_PROMPT} -${history ? `## これまでの会話履歴\n${history}\n` : ''} +${customInstr}${history ? '## Conversation History\n' + history + '\n' : ''} -## 提供されたファイル +## Files to Edit (Current State) ${fileContexts} -## 編集指示 +## Edit Instructions ${instruction} --- -新規ファイルを作成する場合は、必ず「新規ファイル」と明記してください。 - -新規ファイルの場合の回答形式: -## 変更ファイル: [新規作成するファイルパス] -**変更理由**: 新規ファイルの作成 - -[新規ファイルの全内容] - +IMPORTANT REMINDERS: +- Use SEARCH/REPLACE blocks for ALL changes +- SEARCH text must match the file EXACTLY +- Include 3-5 lines of context around the change +- For new files, use <<<<<<< NEW_FILE ... >>>>>>> NEW_FILE +- Keep changes minimal and focused +- Multiple SEARCH/REPLACE blocks can be used for multiple changes in the same file +- Separate each file's changes with "### File: [filepath]"`; +}; + +/** + * Legacy format support - for full file replacement when patch fails + */ +export const EDIT_PROMPT_TEMPLATE_LEGACY = ( + files: Array<{ path: string; content: string }>, + instruction: string, + previousMessages?: Array<{ type: string; content: string; mode?: string; editResponse?: any }>, + customInstructions?: string +) => { + const history = formatHistoryMessages(previousMessages); + const customInstr = formatCustomInstructions(customInstructions); + + const LEGACY_SYSTEM_PROMPT = `You are an expert code editing assistant. You receive code editing instructions and provide changes. + +IMPORTANT: Follow the exact response format below. + +Response Format: +For each file that needs changes, use this format: + +## Changed File: [filepath] +**Reason**: [explanation of the change] + + +[complete modified file content here] + + +--- + +Rules: +- Keep changes minimal +- Match existing code style +- Provide brief explanations +- Use the exact tags shown above`; + + const fileContexts = files + .map( + file => ` +## File: ${file.path} + +${file.content} + +` + ) + .join('\n'); + + return `${LEGACY_SYSTEM_PROMPT} + +${customInstr}${history ? '## Conversation History\n' + history + '\n' : ''} + +## Files to Edit (Current State) +${fileContexts} + +## Edit Instructions +${instruction} + --- +For new files, specify "New File" in the reason. -重要: -- この形式を厳密に守ってください -- 新規ファイルの場合は「新規ファイル」と必ず明記してください -- コードブロックは で囲んでください -- 複数ファイルの場合は上記ブロックを繰り返してください -- 各ファイルブロックの最後には --- を記載してください`; +New File Format: +## Changed File: [new/filepath] +**Reason**: New file creation + +[new file content] + +---`; }; diff --git a/src/engine/ai/responseParser.ts b/src/engine/ai/responseParser.ts index eedc20b7..3dac2226 100644 --- a/src/engine/ai/responseParser.ts +++ b/src/engine/ai/responseParser.ts @@ -1,51 +1,167 @@ -// AI応答パーサー - 強化版 +/** + * AI Response Parser - Enhanced with Multi-Patch Support + * + * Supports two formats: + * 1. New SEARCH/REPLACE block format (preferred) + * 2. Legacy full-file replacement format (fallback) + */ + +import { + type PatchBlock, + type SearchReplaceBlock, + applyPatchBlock, +} from './patchApplier'; export interface ParsedFile { path: string; originalContent: string; suggestedContent: string; explanation: string; + isNewFile?: boolean; + patchBlocks?: SearchReplaceBlock[]; } export interface ParseResult { changedFiles: ParsedFile[]; message: string; raw: string; + usedPatchFormat: boolean; } /** - * パス正規化 - ケースインセンシティブ比較用 + * Normalize path for case-insensitive comparison */ export function normalizePath(path: string): string { return path.replace(/^\/|\/$/g, '').toLowerCase(); } /** - * ファイルパスを抽出(新規ファイル含む) + * Extract file paths from response (supports both formats) */ export function extractFilePathsFromResponse(response: string): string[] { - const fileBlockPattern = //g; const foundPaths: string[] = []; const seen = new Set(); + // Pattern 1: ### File: [path] + const fileHeaderPattern = /###\s*File:\s*(.+?)(?:\n|$)/g; let match; - while ((match = fileBlockPattern.exec(response)) !== null) { + while ((match = fileHeaderPattern.exec(response)) !== null) { + const filePath = match[1].trim(); + if (filePath && !seen.has(filePath)) { + foundPaths.push(filePath); + seen.add(filePath); + } + } + + // Pattern 2: Legacy format + const legacyPattern = //g; + while ((match = legacyPattern.exec(response)) !== null) { + const filePath = match[1].trim(); + if (filePath && !seen.has(filePath)) { + foundPaths.push(filePath); + seen.add(filePath); + } + } + + // Pattern 3: ## Changed File: [path] + const changedFilePattern = /##\s*(?:Changed\s+)?File:\s*(.+?)(?:\n|$)/g; + while ((match = changedFilePattern.exec(response)) !== null) { const filePath = match[1].trim(); if (filePath && !seen.has(filePath)) { foundPaths.push(filePath); seen.add(filePath); } } + return foundPaths; } /** - * ファイルブロックを抽出 + * Parse SEARCH/REPLACE blocks for a specific file section + */ +function parseFilePatchSection( + section: string +): { blocks: SearchReplaceBlock[]; isNewFile: boolean; fullContent?: string } { + const blocks: SearchReplaceBlock[] = []; + let isNewFile = false; + let fullContent: string | undefined; + + // Check for NEW_FILE format + const newFilePattern = /<<<<<<< NEW_FILE\n([\s\S]*?)\n>>>>>>> NEW_FILE/g; + const newFileMatch = newFilePattern.exec(section); + if (newFileMatch) { + isNewFile = true; + fullContent = newFileMatch[1]; + return { blocks, isNewFile, fullContent }; + } + + // Parse SEARCH/REPLACE blocks + const blockPattern = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g; + let match; + while ((match = blockPattern.exec(section)) !== null) { + blocks.push({ + search: match[1], + replace: match[2], + }); + } + + return { blocks, isNewFile, fullContent }; +} + +/** + * Extract file sections with their patch blocks + */ +function extractFilePatchSections( + response: string +): Map { + const sections = new Map< + string, + { blocks: SearchReplaceBlock[]; explanation: string; isNewFile: boolean; fullContent?: string } + >(); + + // Split by file headers + const fileHeaderRegex = /###\s*File:\s*(.+?)(?:\n|$)/g; + const matches: { path: string; index: number }[] = []; + + let match; + while ((match = fileHeaderRegex.exec(response)) !== null) { + matches.push({ + path: match[1].trim(), + index: match.index + match[0].length, + }); + } + + // Process each file section + for (let i = 0; i < matches.length; i++) { + const currentMatch = matches[i]; + const nextIndex = i + 1 < matches.length ? matches[i + 1].index - matches[i + 1].path.length - 10 : response.length; + const section = response.substring(currentMatch.index, nextIndex); + + // Extract explanation + const reasonMatch = section.match(/\*\*Reason\*\*:\s*(.+?)(?:\n|$)/); + const explanation = reasonMatch ? reasonMatch[1].trim() : ''; + + // Parse patch blocks + const parsed = parseFilePatchSection(section); + + sections.set(currentMatch.path, { + blocks: parsed.blocks, + explanation, + isNewFile: parsed.isNewFile, + fullContent: parsed.fullContent, + }); + } + + return sections; +} + +/** + * Extract legacy format file blocks */ export function extractFileBlocks(response: string): Array<{ path: string; content: string }> { const blocks: Array<{ path: string; content: string }> = []; - // 正規パターン: ... + // Standard pattern: ... const fileBlockPattern = /\s*\n([\s\S]*?)\n\s*/g; @@ -57,14 +173,13 @@ export function extractFileBlocks(response: string): Array<{ path: string; conte }); } - // フォールバック: ENDタグのパスが一致しない場合も拾う + // Fallback: END tag path doesn't match if (blocks.length === 0) { const loosePattern = /\s*\n([\s\S]*?)/g; let looseMatch; while ((looseMatch = loosePattern.exec(response)) !== null) { const startPath = looseMatch[1].trim(); const endPath = looseMatch[3].trim(); - // パスが正規化して一致する場合のみ追加 if (normalizePath(startPath) === normalizePath(endPath)) { blocks.push({ path: startPath, @@ -74,7 +189,7 @@ export function extractFileBlocks(response: string): Array<{ path: string; conte } } - // さらなるフォールバック: 閉じタグがない場合 + // Further fallback: missing END tag if (blocks.length === 0) { const unclosedPattern = /\s*\n([\s\S]*?)(?=[\s\S]*$/, ''); if (content.trim()) { blocks.push({ @@ -97,26 +211,24 @@ export function extractFileBlocks(response: string): Array<{ path: string; conte } /** - * 変更理由を抽出 + * Extract change reasons from response */ export function extractReasons(response: string): Map { const reasonMap = new Map(); - // パターン1: ## 変更ファイル: ... **変更理由**: ... (最優先、改行まで) - const reasonPattern1 = /##\s*変更ファイル:\s*(.+?)\s*\n+\*\*変更理由\*\*:\s*(.+?)(?=\n)/gs; - + // Pattern 1: ### File: ... **Reason**: ... + const pattern1 = /###\s*File:\s*(.+?)\s*\n+\*\*Reason\*\*:\s*(.+?)(?=\n)/g; let match1; - while ((match1 = reasonPattern1.exec(response)) !== null) { + while ((match1 = pattern1.exec(response)) !== null) { const path = match1[1].trim(); const reason = match1[2].trim(); reasonMap.set(path, reason); } - // パターン2: **ファイル名**: ... **理由**: ... - const reasonPattern2 = /\*\*ファイル名\*\*:\s*(.+?)\s*\n+\*\*理由\*\*:\s*(.+?)(?=\n|$)/gs; - + // Pattern 2: ## Changed File: ... **Reason**: ... + const pattern2 = /##\s*(?:Changed\s+)?File:\s*(.+?)\s*\n+\*\*(?:Reason|変更理由)\*\*:\s*(.+?)(?=\n)/g; let match2; - while ((match2 = reasonPattern2.exec(response)) !== null) { + while ((match2 = pattern2.exec(response)) !== null) { const path = match2[1].trim(); const reason = match2[2].trim(); if (!reasonMap.has(path)) { @@ -124,11 +236,10 @@ export function extractReasons(response: string): Map { } } - // パターン3: [ファイルパス] - [理由] - const reasonPattern3 = /^-?\s*\[?(.+?\.(?:ts|tsx|js|jsx|json|md|css|html))\]?\s*[-:]\s*(.+)$/gm; - + // Pattern 3: Japanese format + const pattern3 = /##\s*変更ファイル:\s*(.+?)\s*\n+\*\*変更理由\*\*:\s*(.+?)(?=\n)/g; let match3; - while ((match3 = reasonPattern3.exec(response)) !== null) { + while ((match3 = pattern3.exec(response)) !== null) { const path = match3[1].trim(); const reason = match3[2].trim(); if (!reasonMap.has(path)) { @@ -136,12 +247,10 @@ export function extractReasons(response: string): Map { } } - // パターン4: ## File: ... Reason: ... (英語版) - const reasonPattern4 = - /##\s*(?:File|ファイル):\s*(.+?)\s*\n+(?:\*\*)?(?:Reason|理由)(?:\*\*)?:\s*(.+?)(?=\n)/gs; - + // Pattern 4: **ファイル名**: ... **理由**: ... + const pattern4 = /\*\*ファイル名\*\*:\s*(.+?)\s*\n+\*\*理由\*\*:\s*(.+?)(?=\n|$)/g; let match4; - while ((match4 = reasonPattern4.exec(response)) !== null) { + while ((match4 = pattern4.exec(response)) !== null) { const path = match4[1].trim(); const reason = match4[2].trim(); if (!reasonMap.has(path)) { @@ -149,12 +258,10 @@ export function extractReasons(response: string): Map { } } - // パターン5: 変更: ファイルパス - 理由 - const reasonPattern5 = - /^(?:変更|Change|Modified):\s*(.+?\.(?:ts|tsx|js|jsx|json|md|css|html|py|java|go|rs))\s*[-:]\s*(.+)$/gm; - + // Pattern 5: [filepath] - [reason] + const pattern5 = /^-?\s*\[?(.+?\.(?:ts|tsx|js|jsx|json|md|css|html))\]?\s*[-:]\s*(.+)$/gm; let match5; - while ((match5 = reasonPattern5.exec(response)) !== null) { + while ((match5 = pattern5.exec(response)) !== null) { const path = match5[1].trim(); const reason = match5[2].trim(); if (!reasonMap.has(path)) { @@ -162,117 +269,200 @@ export function extractReasons(response: string): Map { } } + // Pattern 6: Change/Modified: filepath - reason + const pattern6 = + /^(?:変更|Change|Modified):\s*(.+?\.(?:ts|tsx|js|jsx|json|md|css|html|py|java|go|rs))\s*[-:]\s*(.+)$/gm; + let match6; + while ((match6 = pattern6.exec(response)) !== null) { + const path = match6[1].trim(); + const reason = match6[2].trim(); + if (!reasonMap.has(path)) { + reasonMap.set(path, reason); + } + } + + // Pattern 7: ## File: ... Reason: ... (English format without bold) + const pattern7 = /##\s*File:\s*(.+?)\s*\n+Reason:\s*(.+?)(?=\n|$)/g; + let match7; + while ((match7 = pattern7.exec(response)) !== null) { + const path = match7[1].trim(); + const reason = match7[2].trim(); + if (!reasonMap.has(path)) { + reasonMap.set(path, reason); + } + } + return reasonMap; } /** - * メッセージをクリーンアップ + * Clean up message by removing code blocks and metadata */ export function cleanupMessage(response: string): string { let cleaned = response; - // ファイルブロックを削除(厳密なマッチング) + // Remove SEARCH/REPLACE blocks + cleaned = cleaned.replace(/<<<<<<< SEARCH[\s\S]*?>>>>>>> REPLACE/g, ''); + cleaned = cleaned.replace(/<<<<<<< NEW_FILE[\s\S]*?>>>>>>> NEW_FILE/g, ''); + + // Remove legacy file blocks cleaned = cleaned.replace( /]+>[\s\S]*?]+>/g, '' ); - - // 閉じタグがないブロックも削除 cleaned = cleaned.replace(/]+>[\s\S]*$/g, ''); - // メタデータを削除(日本語・英語両対応) - cleaned = cleaned.replace(/^##\s*(?:変更ファイル|File|Changed File):.*$/gm, ''); - cleaned = cleaned.replace(/^\*\*(?:変更理由|Reason|Change Reason)\*\*:.+$/gm, ''); + // Remove metadata lines + cleaned = cleaned.replace(/^###\s*File:.*$/gm, ''); + cleaned = cleaned.replace(/^##\s*(?:Changed\s+)?File:.*$/gm, ''); + cleaned = cleaned.replace(/^##\s*変更ファイル:.*$/gm, ''); + cleaned = cleaned.replace(/^\*\*(?:Reason|変更理由)\*\*:.+$/gm, ''); cleaned = cleaned.replace(/^\*\*(?:ファイル名|File Name|Filename)\*\*:.+$/gm, ''); cleaned = cleaned.replace(/^\*\*(?:理由|Reason)\*\*:.+$/gm, ''); - cleaned = cleaned.replace(/^(?:Reason|理由):\s*.+$/gm, ''); // 単体のReason行 + cleaned = cleaned.replace(/^(?:Reason|理由):\s*.+$/gm, ''); cleaned = cleaned.replace( /^(?:変更|Change|Modified):\s*.+?\.(?:ts|tsx|js|jsx|json|md|css|html|py|java|go|rs)\s*[-:].*$/gm, '' ); cleaned = cleaned.replace(/^---+$/gm, ''); - // コードブロックのマーカーを削除(```の中身は保持) + // Remove empty code blocks + cleaned = cleaned.replace(/^```[a-z]*\s*```$/gm, ''); cleaned = cleaned.replace(/^```[a-z]*\s*$/gm, ''); + cleaned = cleaned.replace(/^```\s*$/gm, ''); - // 連続する空行を1つに + // Normalize multiple newlines cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); return cleaned.trim(); } /** - * AI編集レスポンスをパース(強化版) + * Check if response uses patch format (SEARCH/REPLACE blocks) + */ +function usesPatchFormat(response: string): boolean { + return ( + response.includes('<<<<<<< SEARCH') || + response.includes('<<<<<<< NEW_FILE') + ); +} + +/** + * Parse AI edit response (supports both patch and legacy formats) */ export function parseEditResponse( response: string, originalFiles: Array<{ path: string; content: string }> ): ParseResult { const changedFiles: ParsedFile[] = []; + const usedPatchFormat = usesPatchFormat(response); - // パスの正規化マップを作成 + // Create normalized path map const normalizedOriginalFiles = new Map(originalFiles.map(f => [normalizePath(f.path), f])); - // ファイルブロックを抽出 - const fileBlocks = extractFileBlocks(response); - - // 変更理由を抽出 - const reasonMap = extractReasons(response); - - // 各ブロックを処理 - for (const block of fileBlocks) { - const normalizedPath = normalizePath(block.path); - const originalFile = normalizedOriginalFiles.get(normalizedPath); - - if (originalFile) { - // 理由を検索(複数パターン対応) - let explanation = reasonMap.get(block.path) || reasonMap.get(originalFile.path); - - // 理由が見つからない場合、正規化パスで再検索 - if (!explanation) { - for (const [key, value] of reasonMap.entries()) { - if (normalizePath(key) === normalizedPath) { - explanation = value; - break; - } - } + if (usedPatchFormat) { + // Parse new SEARCH/REPLACE format + const fileSections = extractFilePatchSections(response); + + fileSections.forEach((section, filePath) => { + const normalizedPath = normalizePath(filePath); + const originalFile = normalizedOriginalFiles.get(normalizedPath); + + if (section.isNewFile && section.fullContent !== undefined) { + // New file creation + changedFiles.push({ + path: filePath, + originalContent: '', + suggestedContent: section.fullContent, + explanation: section.explanation || 'New file', + isNewFile: true, + patchBlocks: [], + }); + } else if (originalFile && section.blocks.length > 0) { + // Apply patches to existing file + const patchBlock: PatchBlock = { + filePath: originalFile.path, + blocks: section.blocks, + explanation: section.explanation, + }; + + const result = applyPatchBlock(originalFile.content, patchBlock); + + changedFiles.push({ + path: originalFile.path, + originalContent: originalFile.content, + suggestedContent: result.patchedContent, + explanation: section.explanation || 'Modified', + patchBlocks: section.blocks, + }); } + }); + } else { + // Fall back to legacy format parsing + const fileBlocks = extractFileBlocks(response); + const reasonMap = extractReasons(response); + + for (const block of fileBlocks) { + const normalizedPath = normalizePath(block.path); + const originalFile = normalizedOriginalFiles.get(normalizedPath); + + if (originalFile) { + let explanation = reasonMap.get(block.path) || reasonMap.get(originalFile.path); + + // Search by normalized path if not found + if (!explanation) { + reasonMap.forEach((value, key) => { + if (normalizePath(key) === normalizedPath && !explanation) { + explanation = value; + } + }); + } - changedFiles.push({ - path: originalFile.path, - originalContent: originalFile.content, - suggestedContent: block.content, - explanation: explanation || 'No explanation provided', - }); + changedFiles.push({ + path: originalFile.path, + originalContent: originalFile.content, + suggestedContent: block.content, + explanation: explanation || 'No explanation provided', + }); + } else { + // New file in legacy format + const explanation = reasonMap.get(block.path) || 'New file'; + changedFiles.push({ + path: block.path, + originalContent: '', + suggestedContent: block.content, + explanation, + isNewFile: true, + }); + } } } - // メッセージをクリーンアップ + // Clean up message let message = cleanupMessage(response); - // メッセージが不十分な場合のフォールバック + // Fallback message handling const hasValidMessage = message && message.replace(/\s/g, '').length >= 5; if (changedFiles.length === 0 && !hasValidMessage) { - // 解析失敗時のデバッグ情報 - const failureNote = 'レスポンスの解析に失敗しました。プロンプトを調整してください。'; - const safeResponse = response.replace(/```/g, '```' + '\u200B'); - const rawBlock = `\n\n---\n\nRaw response:\n\n\`\`\`text\n${safeResponse}\n\`\`\``; + const failureNote = 'Failed to parse response. Ensure you use the correct SEARCH/REPLACE block format (<<<<<<< SEARCH ... >>>>>>> REPLACE) or legacy file tags ().'; + const safeResponse = response.replace(/```/g, '```\u200B'); + const rawBlock = '\n\n---\n\nRaw response:\n\n```text\n' + safeResponse + '\n```'; message = failureNote + rawBlock; } else if (changedFiles.length > 0 && !hasValidMessage) { - // ファイルが変更されたがメッセージが不十分 - message = `${changedFiles.length}個のファイルの編集を提案しました。`; + message = 'Suggested edits for ' + changedFiles.length + ' file(s).'; } return { changedFiles, message, raw: response, + usedPatchFormat, }; } /** - * レスポンスの品質チェック + * Validate response quality */ export function validateResponse(response: string): { isValid: boolean; @@ -287,22 +477,43 @@ export function validateResponse(response: string): { return { isValid: false, errors, warnings }; } - // ファイルブロックの検証 - const startTags = response.match(/]+>/g) || []; - const endTags = response.match(/]+>/g) || []; + const usesPatch = usesPatchFormat(response); - if (startTags.length !== endTags.length) { - errors.push(`Mismatched tags: ${startTags.length} START vs ${endTags.length} END`); - } + if (usesPatch) { + // Validate SEARCH/REPLACE format + const searchCount = (response.match(/<<<<<<< SEARCH/g) || []).length; + const replaceCount = (response.match(/>>>>>>> REPLACE/g) || []).length; + const newFileStartCount = (response.match(/<<<<<<< NEW_FILE/g) || []).length; + const newFileEndCount = (response.match(/>>>>>>> NEW_FILE/g) || []).length; - if (startTags.length === 0) { - warnings.push('No file blocks found'); - } + if (searchCount !== replaceCount) { + errors.push('Mismatched SEARCH/REPLACE: ' + searchCount + ' SEARCH vs ' + replaceCount + ' REPLACE'); + } + + if (newFileStartCount !== newFileEndCount) { + errors.push('Mismatched NEW_FILE tags: ' + newFileStartCount + ' start vs ' + newFileEndCount + ' end'); + } + + if (searchCount === 0 && newFileStartCount === 0) { + warnings.push('No patch blocks found'); + } + } else { + // Validate legacy format + const startTags = response.match(/]+>/g) || []; + const endTags = response.match(/]+>/g) || []; - // タグのペアが正しいか検証 - const blocks = extractFileBlocks(response); - if (blocks.length < startTags.length) { - warnings.push('Some file blocks may be malformed'); + if (startTags.length !== endTags.length) { + errors.push('Mismatched tags: ' + startTags.length + ' START vs ' + endTags.length + ' END'); + } + + if (startTags.length === 0) { + warnings.push('No file blocks found'); + } + + const blocks = extractFileBlocks(response); + if (blocks.length < startTags.length) { + warnings.push('Some file blocks may be malformed'); + } } return { diff --git a/src/engine/cmd/global/git.ts b/src/engine/cmd/global/git.ts index 537b601b..d8ea0a8a 100644 --- a/src/engine/cmd/global/git.ts +++ b/src/engine/cmd/global/git.ts @@ -15,18 +15,21 @@ import { gitFileSystem } from '@/engine/core/gitFileSystem'; import { toAppPath, joinPath } from '@/engine/core/pathResolver'; import { syncManager } from '@/engine/core/syncManager'; import { authRepository } from '@/engine/user/authRepository'; +import type { TerminalUI } from '@/engine/cmd/terminalUI'; /** * [NEW ARCHITECTURE] Git操作を管理するクラス * - IndexedDBへの同期はfileRepositoryが自動的に実行 * - Git操作後の逆同期はsyncManagerを使用 * - バッチ処理機能を削除(不要) + * - TerminalUI API provides advanced terminal display features */ export class GitCommands { private fs: FS; private dir: string; private projectId: string; private projectName: string; + private terminalUI?: TerminalUI; constructor(projectName: string, projectId: string) { this.fs = gitFileSystem.getFS()!; @@ -35,6 +38,10 @@ export class GitCommands { this.projectName = projectName; } + setTerminalUI(ui: TerminalUI) { + this.terminalUI = ui; + } + // ======================================== // ユーティリティメソッド // ======================================== @@ -1297,7 +1304,7 @@ export class GitCommands { // 動的インポートで循環参照を回避 const { push } = await import('./gitOperations/push'); - return push(this.fs, this.dir, options); + return push(this.fs, this.dir, options, this.terminalUI); } /** diff --git a/src/engine/cmd/global/gitOperations/push.ts b/src/engine/cmd/global/gitOperations/push.ts index 3b4de38d..b3e1ff40 100644 --- a/src/engine/cmd/global/gitOperations/push.ts +++ b/src/engine/cmd/global/gitOperations/push.ts @@ -11,6 +11,7 @@ import { TreeBuilder } from './github/TreeBuilder'; import { parseGitHubUrl } from './github/utils'; import { authRepository } from '@/engine/user/authRepository'; +import type { TerminalUI } from '@/engine/cmd/terminalUI'; export interface PushOptions { remote?: string; @@ -150,12 +151,23 @@ async function findCommonAncestor( } } -export async function push(fs: FS, dir: string, options: PushOptions = {}): Promise { +export async function push( + fs: FS, + dir: string, + options: PushOptions = {}, + ui?: TerminalUI +): Promise { const { remote = 'origin', branch, force = false } = options; try { + // Start spinner if TerminalUI is available + if (ui) { + await ui.spinner.start('Enumerating objects...'); + } + const token = await authRepository.getAccessToken(); if (!token) { + if (ui) await ui.spinner.stop(); throw new Error('GitHub authentication required. Please sign in first.'); } diff --git a/src/engine/cmd/global/npm.ts b/src/engine/cmd/global/npm.ts index c1ea0c6b..6e2f5054 100644 --- a/src/engine/cmd/global/npm.ts +++ b/src/engine/cmd/global/npm.ts @@ -6,18 +6,21 @@ * - package.jsonなどの設定ファイルは IndexedDB に保存 * - NpmInstallクラスが .gitignore を考慮して IndexedDB を更新 * - fileRepository.createFile() を使用して自動的に管理 + * - TerminalUI API provides advanced terminal display features */ import { NpmInstall } from './npmOperations/npmInstall'; import { fileRepository } from '@/engine/core/fileRepository'; import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; +import type { TerminalUI } from '@/engine/cmd/terminalUI'; export class NpmCommands { private currentDir: string; private projectName: string; private projectId: string; private setLoading?: (isLoading: boolean) => void; + private terminalUI?: TerminalUI; constructor( projectName: string, @@ -35,6 +38,10 @@ export class NpmCommands { this.setLoading = callback; } + setTerminalUI(ui: TerminalUI) { + this.terminalUI = ui; + } + async downloadAndInstallPackage(packageName: string, version: string = 'latest'): Promise { const npmInstall = new NpmInstall(this.projectName, this.projectId); npmInstall.startBatchProcessing(); @@ -52,7 +59,15 @@ export class NpmCommands { // npm install コマンドの実装 async install(packageName?: string, flags: string[] = []): Promise { - this.setLoading?.(true); + const startTime = Date.now(); + const ui = this.terminalUI; + + // Use TerminalUI spinner if available, otherwise fall back to setLoading + const useTerminalUI = !!ui; + if (!useTerminalUI) { + this.setLoading?.(true); + } + try { // IndexedDBからpackage.jsonを単一取得(インデックス経由) const packageFile = await fileRepository.getFileByPath(this.projectId, '/package.json'); @@ -95,21 +110,36 @@ export class NpmCommands { return 'up to date, audited 0 packages in 0.1s\n\nfound 0 vulnerabilities'; } - let output = `Installing ${packageNames.length} packages...\n`; let installedCount = 0; + let failedPackages: string[] = []; const npmInstall = new NpmInstall(this.projectName, this.projectId); + + // Set up progress callback to log all packages (direct + transitive) + if (ui) { + npmInstall.setInstallProgressCallback(async (pkgName, pkgVersion, _isDirect) => { + await ui.spinner.update(`reify:${pkgName}@${pkgVersion}: timing reifyNode:node_modules/${pkgName} (${pkgVersion})`); + }); + } + npmInstall.startBatchProcessing(); + try { - for (const pkg of packageNames) { + // Start spinner with initial message + if (ui) { + await ui.spinner.start(`reify: resolving ${packageNames.length} packages...`); + } + + for (let i = 0; i < packageNames.length; i++) { + const pkg = packageNames[i]; const versionSpec = allDependencies[pkg]; const version = versionSpec.replace(/^[\^~]/, ''); + try { - await npmInstall.installWithDependencies(pkg, version); + await npmInstall.installWithDependencies(pkg, version, { isDirect: true }); installedCount++; - output += ` ✓ ${pkg}@${version} (with dependencies)\n`; } catch (error) { - output += ` ✗ ${pkg}@${version} - ${(error as Error).message}\n`; + failedPackages.push(`${pkg}@${version}: ${(error as Error).message}`); } } } finally { @@ -121,11 +151,27 @@ export class NpmCommands { } catch {} } } + + // Stop spinner + if (ui) { + await ui.spinner.stop(); + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + let output = ''; + + // Output warnings for failed packages + if (failedPackages.length > 0) { + for (const failed of failedPackages) { + output += `npm WARN ${failed}\n`; + } + output += '\n'; + } if (installedCount === 0) { - output += `\nup to date, audited ${packageNames.length} packages in ${Math.random() * 2 + 1}s\n\nfound 0 vulnerabilities`; + output += `up to date, audited ${packageNames.length} packages in ${elapsed}s\n\nfound 0 vulnerabilities`; } else { - output += `\nadded/updated ${installedCount} packages in ${Math.random() * 2 + 1}s\n\nfound 0 vulnerabilities`; + output += `added ${installedCount} packages, and audited ${packageNames.length} packages in ${elapsed}s\n\nfound 0 vulnerabilities`; } return output; } else { @@ -142,6 +188,11 @@ export class NpmCommands { } try { + // Start spinner + if (ui) { + await ui.spinner.start(`http fetch GET https://registry.npmjs.org/${packageName}`); + } + const packageInfo = await this.fetchPackageInfo(packageName); const version = packageInfo.version; @@ -168,44 +219,82 @@ export class NpmCommands { ); const isActuallyInstalled = nodeFiles.length > 0; + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + if (isInPackageJson && isActuallyInstalled) { + if (ui) { + await ui.spinner.stop(); + } try { const npmInstall = new NpmInstall(this.projectName, this.projectId); // ensure .bin entries exist for already-installed package // await npmInstall.ensureBinsForPackage(packageName).catch(() => {}); } catch {} - return `updated 1 package in ${Math.random() * 2 + 1}s\n\n~ ${packageName}@${version}\nupdated 1 package and audited 1 package in ${Math.random() * 0.5 + 0.5}s\n\nfound 0 vulnerabilities`; + return `up to date, audited 1 package in ${elapsed}s\n\nfound 0 vulnerabilities`; } else { const npmInstall = new NpmInstall(this.projectName, this.projectId); + + // Set up progress callback to log all packages (direct + transitive) + if (ui) { + npmInstall.setInstallProgressCallback(async (pkgName, _pkgVersion, _isDirect) => { + await ui.spinner.update(`reify:${pkgName}: timing reifyNode:node_modules/${pkgName}`); + }); + } + npmInstall.startBatchProcessing(); try { - await npmInstall.installWithDependencies(packageName, version); + await npmInstall.installWithDependencies(packageName, version, { isDirect: true }); } finally { await npmInstall.finishBatchProcessing(); - try { - await npmInstall.ensureBinsForPackage(packageName).catch(() => {}); - } catch {} + try { + await npmInstall.ensureBinsForPackage(packageName).catch(() => {}); + } catch {} + } + + // Stop spinner + if (ui) { + await ui.spinner.stop(); } - return `added packages with dependencies in ${Math.random() * 2 + 1}s\n\n+ ${packageName}@${version}\nadded packages and audited packages in ${Math.random() * 0.5 + 0.5}s\n\nfound 0 vulnerabilities`; + + const finalElapsed = ((Date.now() - startTime) / 1000).toFixed(1); + return `added 1 package, and audited 1 package in ${finalElapsed}s\n\nfound 0 vulnerabilities`; } } catch (error) { + if (ui) { + await ui.spinner.stop(); + } throw new Error(`Failed to install ${packageName}: ${(error as Error).message}`); } } } catch (error) { throw new Error(`npm install failed: ${(error as Error).message}`); } finally { - this.setLoading?.(false); + if (!useTerminalUI) { + this.setLoading?.(false); + } } } // npm uninstall コマンドの実装 async uninstall(packageName: string): Promise { - this.setLoading?.(true); + const startTime = Date.now(); + const ui = this.terminalUI; + const useTerminalUI = !!ui; + + if (!useTerminalUI) { + this.setLoading?.(true); + } + try { + // Start spinner + if (ui) { + await ui.spinner.start(`reify: removing ${packageName}...`); + } + // IndexedDBからpackage.jsonを単一取得(インデックス経由) const packageFile = await fileRepository.getFileByPath(this.projectId, '/package.json'); if (!packageFile) { + if (ui) await ui.spinner.stop(); return `npm ERR! Cannot find package.json`; } const packageJson = JSON.parse(packageFile.content); @@ -225,6 +314,7 @@ export class NpmCommands { } if (!wasInDependencies && !wasInDevDependencies) { + if (ui) await ui.spinner.stop(); return `npm WARN ${packageName} is not a dependency of ${this.projectName}`; } @@ -240,11 +330,14 @@ export class NpmCommands { try { const removedPackages = await npmInstall.uninstallWithDependencies(packageName); const totalRemoved = removedPackages.length; + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (ui) await ui.spinner.stop(); + if (totalRemoved === 0) { - return `removed 1 package in 0.1s\n\n- ${packageName}\nremoved 1 package and audited 0 packages in 0.1s\n\nfound 0 vulnerabilities`; + return `removed 1 package in ${elapsed}s\n\nfound 0 vulnerabilities`; } else { - const removedList = removedPackages.join(', '); - return `removed ${totalRemoved + 1} packages in 0.1s\n\n- ${packageName}\n- ${removedList} (orphaned dependencies)\nremoved ${totalRemoved + 1} packages and audited 0 packages in 0.1s\n\nfound 0 vulnerabilities`; + return `removed ${totalRemoved + 1} packages in ${elapsed}s\n\nfound 0 vulnerabilities`; } } catch (error) { // 依存関係解決に失敗した場合は、単純にメインパッケージのみ削除 @@ -259,12 +352,17 @@ export class NpmCommands { for (const file of packageFiles) { await fileRepository.deleteFile(file.id); } - return `removed 1 package in 0.1s\n\n- ${packageName}\nremoved 1 package and audited 0 packages in 0.1s\n\nfound 0 vulnerabilities`; + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + if (ui) await ui.spinner.stop(); + return `removed 1 package in ${elapsed}s\n\nfound 0 vulnerabilities`; } } catch (error) { + if (ui) await ui.spinner.stop(); throw new Error(`npm uninstall failed: ${(error as Error).message}`); } finally { - this.setLoading?.(false); + if (!useTerminalUI) { + this.setLoading?.(false); + } } } diff --git a/src/engine/cmd/global/npmOperations/npmInstall.ts b/src/engine/cmd/global/npmOperations/npmInstall.ts index 8fb40ee5..f4c25366 100644 --- a/src/engine/cmd/global/npmOperations/npmInstall.ts +++ b/src/engine/cmd/global/npmOperations/npmInstall.ts @@ -21,10 +21,24 @@ interface PackageInfo { tarball: string; } +/** + * Callback type for logging installation progress + * packageName: Name of the package being installed + * isDirect: true if this is a direct dependency, false if transitive + */ +export type InstallProgressCallback = ( + packageName: string, + version: string, + isDirect: boolean +) => Promise | void; + export class NpmInstall { private projectName: string; private projectId: string; + // Callback for progress logging + private onInstallProgress?: InstallProgressCallback; + // 再利用可能な TextDecoder をクラスで保持して、頻繁なインスタンス生成を避ける private textDecoder = new TextDecoder('utf-8', { fatal: false }); @@ -102,6 +116,14 @@ export class NpmInstall { } } + /** + * Set a callback to receive progress updates for each package installation + * This is called for both direct and transitive dependencies + */ + setInstallProgressCallback(callback: InstallProgressCallback): void { + this.onInstallProgress = callback; + } + // バッチ処理を開始 startBatchProcessing(): void { this.batchProcessing = true; @@ -601,10 +623,11 @@ export class NpmInstall { async installWithDependencies( packageName: string, version: string = 'latest', - options?: { autoAddGitignore?: boolean; ignoreEntry?: string } + options?: { autoAddGitignore?: boolean; ignoreEntry?: string; isDirect?: boolean } ): Promise { const resolvedVersion = this.resolveVersion(version); const packageKey = `${packageName}@${resolvedVersion}`; + const isDirect = options?.isDirect ?? true; // 循環依存の検出 if (this.installingPackages.has(packageKey)) { @@ -664,6 +687,11 @@ export class NpmInstall { // インストール処理中マークに追加 this.installingPackages.add(packageKey); + // Progress callback: notify about this package installation + if (this.onInstallProgress) { + await this.onInstallProgress(packageName, resolvedVersion, isDirect); + } + console.log(`[npm.installWithDependencies] Installing ${packageKey}...`); // パッケージ情報を取得 @@ -685,7 +713,8 @@ export class NpmInstall { await Promise.all( batch.map(async ([depName, depVersion]) => { try { - await this.installWithDependencies(depName, this.resolveVersion(depVersion)); + // Transitive dependencies are marked as isDirect: false + await this.installWithDependencies(depName, this.resolveVersion(depVersion), { isDirect: false }); } catch (error) { console.warn( `[npm.installWithDependencies] Failed to install dependency ${depName}@${depVersion}: ${(error as Error).message}` diff --git a/src/engine/cmd/handlers/gitHandler.ts b/src/engine/cmd/handlers/gitHandler.ts index c74a9fe7..81ccbd0f 100644 --- a/src/engine/cmd/handlers/gitHandler.ts +++ b/src/engine/cmd/handlers/gitHandler.ts @@ -1,4 +1,5 @@ import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; +import { createTerminalUI, TerminalUI } from '@/engine/cmd/terminalUI'; export async function handleGitCommand( args: string[], @@ -11,7 +12,14 @@ export async function handleGitCommand( return; } + // Create TerminalUI instance for advanced display features + const ui = createTerminalUI(writeOutput); + const git = terminalCommandRegistry.getGitCommands(projectName, projectId); + + // Pass TerminalUI to git commands for advanced output + git.setTerminalUI(ui); + const gitCmd = args[0]; switch (gitCmd) { diff --git a/src/engine/cmd/handlers/npmHandler.ts b/src/engine/cmd/handlers/npmHandler.ts index c8b0b557..e913a8a7 100644 --- a/src/engine/cmd/handlers/npmHandler.ts +++ b/src/engine/cmd/handlers/npmHandler.ts @@ -1,4 +1,5 @@ import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; +import { createTerminalUI, TerminalUI } from '@/engine/cmd/terminalUI'; export async function handleNPMCommand( args: string[], @@ -12,12 +13,18 @@ export async function handleNPMCommand( return; } + // Create TerminalUI instance for advanced display features + const ui = createTerminalUI(writeOutput); + const npm = terminalCommandRegistry.getNpmCommands( projectName, projectId, `/projects/${projectName}` ); + // Pass the TerminalUI to npm commands for advanced output + npm.setTerminalUI(ui); + if (setLoading) { npm.setLoadingHandler(setLoading); } diff --git a/src/engine/cmd/shell/builtins.ts b/src/engine/cmd/shell/builtins.ts index c5284037..75f9cf6e 100644 --- a/src/engine/cmd/shell/builtins.ts +++ b/src/engine/cmd/shell/builtins.ts @@ -9,6 +9,10 @@ export type StreamCtx = { onSignal: (fn: (sig: string) => void) => void; projectName?: string; projectId?: string; + /** Terminal columns (width) */ + terminalColumns?: number; + /** Terminal rows (height) */ + terminalRows?: number; }; // トークンを正規化(オブジェクト→文字列変換のみ、オプション展開は削除) @@ -389,6 +393,8 @@ export default function adaptUnixToStream(unix: any) { filePath: entryPath, debugConsole, onInput, + terminalColumns: ctx.terminalColumns, + terminalRows: ctx.terminalRows, }); // NodeRuntimeを実行 diff --git a/src/engine/cmd/shell/streamShell.ts b/src/engine/cmd/shell/streamShell.ts index ce09d807..a0fd1b27 100644 --- a/src/engine/cmd/shell/streamShell.ts +++ b/src/engine/cmd/shell/streamShell.ts @@ -1,6 +1,7 @@ import EventEmitter from 'events'; import { PassThrough, Readable, Writable } from 'stream'; +import type { StreamCtx } from './builtins'; import expandBraces from './braceExpand'; import type { UnixCommands } from '../global/unix'; @@ -176,6 +177,10 @@ type ShellOptions = { unix: UnixCommands; // injection for tests fileRepository?: typeof fileRepository; // injection for tests commandRegistry?: any; + /** Terminal columns (width). Updated dynamically on resize. */ + terminalColumns?: number; + /** Terminal rows (height). Updated dynamically on resize. */ + terminalRows?: number; }; type TokenObj = { text: string; quote: 'single' | 'double' | null; cmdSub?: string }; @@ -199,6 +204,8 @@ export class StreamShell { private projectId: string; private commandRegistry: any; private foregroundProc: Process | null = null; + private _terminalColumns: number; + private _terminalRows: number; constructor(opts: ShellOptions) { this.projectName = opts.projectName; @@ -206,6 +213,22 @@ export class StreamShell { this.unix = opts.unix || null; this.fileRepository = opts.fileRepository; // optional this.commandRegistry = opts.commandRegistry; + this._terminalColumns = opts.terminalColumns ?? 80; + this._terminalRows = opts.terminalRows ?? 24; + } + + /** Update terminal size (call on resize) */ + setTerminalSize(columns: number, rows: number) { + this._terminalColumns = columns; + this._terminalRows = rows; + } + + get terminalColumns() { + return this._terminalColumns; + } + + get terminalRows() { + return this._terminalRows; } private async getUnix() { @@ -711,14 +734,16 @@ export class StreamShell { // Note: use the readable side of stdin (stdinStream) so builtins can // read from it when connected via pipe. stdout/stderr use the writable // stream backing so handlers can write into them. - const ctx = { + const ctx: StreamCtx = { stdin: proc.stdinStream, stdout: proc.stdoutStream, stderr: proc.stderrStream, onSignal: (fn: (sig: string) => void) => proc.on('signal', fn), projectName: this.projectName, projectId: this.projectId, - } as any; + terminalColumns: this.terminalColumns, + terminalRows: this.terminalRows, + }; // Normalizer for values written to stdout/stderr to avoid '[object Object]' const normalizeForWrite = (v: any) => { diff --git a/src/engine/cmd/terminalRegistry.ts b/src/engine/cmd/terminalRegistry.ts index a4c029b2..e4eb74d3 100644 --- a/src/engine/cmd/terminalRegistry.ts +++ b/src/engine/cmd/terminalRegistry.ts @@ -1,12 +1,15 @@ import { GitCommands } from './global/git'; import { NpmCommands } from './global/npm'; import { UnixCommands } from './global/unix'; +import type StreamShell from './shell/streamShell'; + +import type { fileRepository } from '@/engine/core/fileRepository'; type ProjectEntry = { unix?: UnixCommands; git?: GitCommands; npm?: NpmCommands; - shell?: any; + shell?: StreamShell; createdAt: number; }; @@ -57,7 +60,13 @@ class TerminalCommandRegistry { async getShell( projectName: string, projectId: string, - opts?: { unix?: any; commandRegistry?: any; fileRepository?: any } + opts?: { + unix?: UnixCommands; + commandRegistry?: unknown; + fileRepository?: typeof fileRepository; + terminalColumns?: number; + terminalRows?: number; + } ) { const entry = this.getOrCreateEntry(projectId); if (entry.shell) return entry.shell; @@ -72,6 +81,8 @@ class TerminalCommandRegistry { unix, commandRegistry, fileRepository: opts && opts.fileRepository, + terminalColumns: opts?.terminalColumns, + terminalRows: opts?.terminalRows, }); return entry.shell; } catch (e) { @@ -80,6 +91,16 @@ class TerminalCommandRegistry { } } + /** + * Update terminal size for a project's shell + */ + updateShellSize(projectId: string, columns: number, rows: number): void { + const entry = this.projects.get(projectId); + if (entry?.shell && typeof entry.shell.setTerminalSize === 'function') { + entry.shell.setTerminalSize(columns, rows); + } + } + /** * Dispose and remove all command instances for a project */ diff --git a/src/engine/cmd/terminalUI.ts b/src/engine/cmd/terminalUI.ts new file mode 100644 index 00000000..488216e2 --- /dev/null +++ b/src/engine/cmd/terminalUI.ts @@ -0,0 +1,480 @@ +/** + * TerminalUI - Advanced terminal display API + * + * This module provides a systematic and professional API for advanced terminal + * display capabilities including spinners, progress indicators, status lines, + * and interactive output. It abstracts xterm.js ANSI escape codes into a + * clean, reusable interface. + * + * Usage: + * const ui = new TerminalUI(writeCallback); + * await ui.spinner.start('Loading packages...'); + * // ... do work ... + * await ui.spinner.stop(); + * await ui.status('Completed in 2.3s'); + */ + +// ANSI escape codes +export const ANSI = { + // Cursor control + CURSOR_HIDE: '\x1b[?25l', + CURSOR_SHOW: '\x1b[?25h', + CURSOR_SAVE: '\x1b[s', + CURSOR_RESTORE: '\x1b[u', + + // Line control + CLEAR_LINE: '\r\x1b[K', // Clear entire line + CLEAR_TO_END: '\x1b[0K', // Clear from cursor to end of line + CLEAR_TO_START: '\x1b[1K', // Clear from cursor to start of line + + // Cursor movement + MOVE_UP: (n: number) => `\x1b[${n}A`, + MOVE_DOWN: (n: number) => `\x1b[${n}B`, + MOVE_RIGHT: (n: number) => `\x1b[${n}C`, + MOVE_LEFT: (n: number) => `\x1b[${n}D`, + MOVE_TO_COL: (n: number) => `\x1b[${n}G`, + MOVE_TO: (row: number, col: number) => `\x1b[${row};${col}H`, + + // Colors (foreground) + FG: { + BLACK: '\x1b[30m', + RED: '\x1b[31m', + GREEN: '\x1b[32m', + YELLOW: '\x1b[33m', + BLUE: '\x1b[34m', + MAGENTA: '\x1b[35m', + CYAN: '\x1b[36m', + WHITE: '\x1b[37m', + GRAY: '\x1b[90m', + BRIGHT_RED: '\x1b[91m', + BRIGHT_GREEN: '\x1b[92m', + BRIGHT_YELLOW: '\x1b[93m', + BRIGHT_BLUE: '\x1b[94m', + BRIGHT_MAGENTA: '\x1b[95m', + BRIGHT_CYAN: '\x1b[96m', + BRIGHT_WHITE: '\x1b[97m', + }, + + // Colors (background) + BG: { + BLACK: '\x1b[40m', + RED: '\x1b[41m', + GREEN: '\x1b[42m', + YELLOW: '\x1b[43m', + BLUE: '\x1b[44m', + MAGENTA: '\x1b[45m', + CYAN: '\x1b[46m', + WHITE: '\x1b[47m', + }, + + // Text styles + RESET: '\x1b[0m', + BOLD: '\x1b[1m', + DIM: '\x1b[2m', + ITALIC: '\x1b[3m', + UNDERLINE: '\x1b[4m', + BLINK: '\x1b[5m', + REVERSE: '\x1b[7m', + HIDDEN: '\x1b[8m', + STRIKETHROUGH: '\x1b[9m', +} as const; + +// Spinner frame sets +export const SPINNERS = { + // npm-like braille spinner + BRAILLE: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + // Classic dots + DOTS: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], + // Simple line + LINE: ['-', '\\', '|', '/'], + // Growing dots + GROWING: ['. ', '.. ', '...', ' '], + // Arrow + ARROW: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], + // Box bounce + BOUNCE: ['▖', '▘', '▝', '▗'], +} as const; + +export type SpinnerType = keyof typeof SPINNERS; + +/** + * Write callback type - function to write directly to the terminal + */ +export type WriteCallback = (text: string) => Promise | void; + +/** + * Spinner controller for animated loading indicators + */ +export class SpinnerController { + private frames: string[]; + private frameIndex = 0; + private intervalId: ReturnType | null = null; + private message = ''; + private write: WriteCallback; + private color: string; + private interval: number; + private isRunning = false; + + constructor( + write: WriteCallback, + type: SpinnerType = 'BRAILLE', + color: string = ANSI.FG.CYAN, + interval = 80 + ) { + this.write = write; + this.frames = [...SPINNERS[type]]; + this.color = color; + this.interval = interval; + } + + /** + * Get the current spinner frame with color + */ + private getFrame(): string { + const frame = this.frames[this.frameIndex % this.frames.length]; + return `${this.color}${frame}${ANSI.RESET}`; + } + + /** + * Start the spinner with an optional message + */ + async start(message = ''): Promise { + if (this.isRunning) return; + this.isRunning = true; + this.message = message; + this.frameIndex = 0; + + // Hide cursor and write initial frame in single write to avoid newline issues + const display = this.message ? `${this.getFrame()} ${this.message}` : this.getFrame(); + await this.write(ANSI.CURSOR_HIDE + display); + + // Start animation + this.intervalId = setInterval(async () => { + this.frameIndex++; + // Clear line and rewrite in single write to avoid newline issues + const display = this.message ? `${this.getFrame()} ${this.message}` : this.getFrame(); + await this.write(ANSI.CLEAR_LINE + display); + }, this.interval); + } + + /** + * Update the spinner message while running + */ + async update(message: string): Promise { + this.message = message; + if (!this.isRunning) return; + + // Immediately update display - combine clear and write to avoid newline issues + const display = this.message ? `${this.getFrame()} ${this.message}` : this.getFrame(); + await this.write(ANSI.CLEAR_LINE + display); + } + + /** + * Stop the spinner and optionally show a final message + */ + async stop(finalMessage?: string): Promise { + if (!this.isRunning) return; + this.isRunning = false; + + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + // Clear the spinner line and show cursor - combine into single write + // If there's a final message, include it with newline + if (finalMessage) { + await this.write(ANSI.CLEAR_LINE + finalMessage + '\n' + ANSI.CURSOR_SHOW); + } else { + await this.write(ANSI.CLEAR_LINE + ANSI.CURSOR_SHOW); + } + } + + /** + * Stop with success indicator + */ + async success(message: string): Promise { + await this.stop(`${ANSI.FG.GREEN}✓${ANSI.RESET} ${message}`); + } + + /** + * Stop with error indicator + */ + async error(message: string): Promise { + await this.stop(`${ANSI.FG.RED}✗${ANSI.RESET} ${message}`); + } + + /** + * Stop with warning indicator + */ + async warn(message: string): Promise { + await this.stop(`${ANSI.FG.YELLOW}⚠${ANSI.RESET} ${message}`); + } + + /** + * Stop with info indicator + */ + async info(message: string): Promise { + await this.stop(`${ANSI.FG.CYAN}ℹ${ANSI.RESET} ${message}`); + } + + /** + * Check if spinner is currently running + */ + get running(): boolean { + return this.isRunning; + } +} + +/** + * Progress bar for showing completion percentage + */ +export class ProgressBar { + private write: WriteCallback; + private width: number; + private current = 0; + private total = 100; + private message = ''; + private filledChar: string; + private emptyChar: string; + private isActive = false; + + constructor( + write: WriteCallback, + width = 30, + filledChar = '█', + emptyChar = '░' + ) { + this.write = write; + this.width = width; + this.filledChar = filledChar; + this.emptyChar = emptyChar; + } + + /** + * Start the progress bar + */ + async start(total = 100, message = ''): Promise { + this.total = total; + this.current = 0; + this.message = message; + this.isActive = true; + + await this.write(ANSI.CURSOR_HIDE); + await this.render(); + } + + /** + * Update progress + */ + async update(current: number, message?: string): Promise { + if (!this.isActive) return; + this.current = Math.min(current, this.total); + if (message !== undefined) { + this.message = message; + } + await this.render(); + } + + /** + * Increment progress by a step + */ + async increment(step = 1, message?: string): Promise { + await this.update(this.current + step, message); + } + + /** + * Render the progress bar + */ + private async render(): Promise { + const percent = Math.round((this.current / this.total) * 100); + const filled = Math.round((this.current / this.total) * this.width); + const empty = this.width - filled; + + const bar = `${ANSI.FG.GREEN}${this.filledChar.repeat(filled)}${ANSI.FG.GRAY}${this.emptyChar.repeat(empty)}${ANSI.RESET}`; + const percentStr = `${percent}%`.padStart(4); + + const display = this.message + ? `${bar} ${percentStr} ${this.message}` + : `${bar} ${percentStr}`; + + await this.write(ANSI.CLEAR_LINE + display); + } + + /** + * Complete the progress bar + */ + async complete(message?: string): Promise { + this.current = this.total; + if (message !== undefined) { + this.message = message; + } + await this.render(); + await this.write('\n'); + await this.write(ANSI.CURSOR_SHOW); + this.isActive = false; + } +} + +/** + * Status line for updating in-place status messages + */ +export class StatusLine { + private write: WriteCallback; + private isActive = false; + + constructor(write: WriteCallback) { + this.write = write; + } + + /** + * Start status line mode + */ + async start(): Promise { + this.isActive = true; + await this.write(ANSI.CURSOR_HIDE); + } + + /** + * Update status text (replaces current line) + */ + async update(text: string): Promise { + if (!this.isActive) { + await this.write(text); + return; + } + await this.write(ANSI.CLEAR_LINE + text); + } + + /** + * End status line mode and move to new line + */ + async end(finalText?: string): Promise { + if (finalText) { + await this.write(ANSI.CLEAR_LINE + finalText); + } + await this.write('\n'); + await this.write(ANSI.CURSOR_SHOW); + this.isActive = false; + } +} + +/** + * Main TerminalUI class - provides access to all terminal UI components + */ +export class TerminalUI { + private write: WriteCallback; + + // UI components + public spinner: SpinnerController; + public progress: ProgressBar; + public status: StatusLine; + + constructor(write: WriteCallback, spinnerType: SpinnerType = 'BRAILLE') { + this.write = write; + this.spinner = new SpinnerController(write, spinnerType); + this.progress = new ProgressBar(write); + this.status = new StatusLine(write); + } + + /** + * Write raw text to terminal + */ + async print(text: string): Promise { + await this.write(text); + } + + /** + * Write text followed by newline + */ + async println(text: string): Promise { + await this.write(text + '\n'); + } + + /** + * Clear the current line + */ + async clearLine(): Promise { + await this.write(ANSI.CLEAR_LINE); + } + + /** + * Write colored text + */ + async colored(text: string, color: string): Promise { + await this.write(`${color}${text}${ANSI.RESET}`); + } + + /** + * Write success message (green checkmark) + */ + async success(message: string): Promise { + await this.write(`${ANSI.FG.GREEN}✓${ANSI.RESET} ${message}\n`); + } + + /** + * Write error message (red X) + */ + async error(message: string): Promise { + await this.write(`${ANSI.FG.RED}✗${ANSI.RESET} ${message}\n`); + } + + /** + * Write warning message (yellow triangle) + */ + async warn(message: string): Promise { + await this.write(`${ANSI.FG.YELLOW}⚠${ANSI.RESET} ${message}\n`); + } + + /** + * Write info message (cyan info icon) + */ + async info(message: string): Promise { + await this.write(`${ANSI.FG.CYAN}ℹ${ANSI.RESET} ${message}\n`); + } + + /** + * Write a tree item (for directory listings, etc) + */ + async treeItem(text: string, isLast = false, indent = 0): Promise { + const prefix = ' '.repeat(indent) + (isLast ? '└── ' : '├── '); + await this.write(`${ANSI.FG.GRAY}${prefix}${ANSI.RESET}${text}\n`); + } + + /** + * Write a dimmed/secondary text + */ + async dim(text: string): Promise { + await this.write(`${ANSI.FG.GRAY}${text}${ANSI.RESET}`); + } + + /** + * Write bold text + */ + async bold(text: string): Promise { + await this.write(`${ANSI.BOLD}${text}${ANSI.RESET}`); + } + + /** + * Create a new spinner with custom settings + */ + createSpinner(type: SpinnerType = 'BRAILLE', color: string = ANSI.FG.CYAN, interval = 80): SpinnerController { + return new SpinnerController(this.write, type, color, interval); + } + + /** + * Create a new progress bar with custom settings + */ + createProgressBar(width = 30, filledChar = '█', emptyChar = '░'): ProgressBar { + return new ProgressBar(this.write, width, filledChar, emptyChar); + } +} + +/** + * Create a TerminalUI instance from a write callback + */ +export function createTerminalUI(write: WriteCallback, spinnerType: SpinnerType = 'BRAILLE'): TerminalUI { + return new TerminalUI(write, spinnerType); +} + +export default TerminalUI; diff --git a/src/engine/core/database.ts b/src/engine/core/database.ts index 20cc4848..5ade9e70 100644 --- a/src/engine/core/database.ts +++ b/src/engine/core/database.ts @@ -2,11 +2,13 @@ * ProjectDB - Wrapper class for backward compatibility * Uses FileRepository internally * @deprecated Use fileRepository directly for new code + * + * NOTE: ChatSpace operations have been removed. Use chatStorageAdapter directly. */ import { fileRepository } from './fileRepository'; -import type { Project, ProjectFile, ChatSpace, ChatSpaceMessage } from '@/types'; +import type { Project, ProjectFile } from '@/types'; class ProjectDB { async init(): Promise { @@ -63,45 +65,6 @@ class ProjectDB { async clearAIReview(projectId: string, filePath: string): Promise { return fileRepository.clearAIReview(projectId, filePath); } - - async createChatSpace(projectId: string, name: string): Promise { - return fileRepository.createChatSpace(projectId, name); - } - - async saveChatSpace(chatSpace: ChatSpace): Promise { - return fileRepository.saveChatSpace(chatSpace); - } - - async getChatSpaces(projectId: string): Promise { - return fileRepository.getChatSpaces(projectId); - } - - async deleteChatSpace(chatSpaceId: string): Promise { - return fileRepository.deleteChatSpace(chatSpaceId); - } - - async addMessageToChatSpace( - chatSpaceId: string, - message: Omit - ): Promise { - return fileRepository.addMessageToChatSpace(chatSpaceId, message); - } - - async updateChatSpaceMessage( - chatSpaceId: string, - messageId: string, - updates: Partial - ): Promise { - return fileRepository.updateChatSpaceMessage(chatSpaceId, messageId, updates); - } - - async updateChatSpaceSelectedFiles(chatSpaceId: string, selectedFiles: string[]): Promise { - return fileRepository.updateChatSpaceSelectedFiles(chatSpaceId, selectedFiles); - } - - async renameChatSpace(chatSpaceId: string, newName: string): Promise { - return fileRepository.renameChatSpace(chatSpaceId, newName); - } } export const projectDB = new ProjectDB(); diff --git a/src/engine/core/fileRepository.ts b/src/engine/core/fileRepository.ts index b8b96617..7642cdc6 100644 --- a/src/engine/core/fileRepository.ts +++ b/src/engine/core/fileRepository.ts @@ -20,6 +20,16 @@ import { import { LOCALSTORAGE_KEY } from '@/context/config'; import { coreInfo, coreWarn, coreError } from '@/engine/core/coreLogger'; import { initialFileContents } from '@/engine/initialFileContents'; +import { + createChatSpace as chatCreateChatSpace, + saveChatSpace as chatSaveChatSpace, + getChatSpaces as chatGetChatSpaces, + deleteChatSpace as chatDeleteChatSpace, + addMessageToChatSpace as chatAddMessageToChatSpace, + updateChatSpaceMessage as chatUpdateChatSpaceMessage, + updateChatSpaceSelectedFiles as chatUpdateChatSpaceSelectedFiles, + renameChatSpace as chatRenameChatSpace, +} from '@/engine/storage/chatStorageAdapter'; import { Project, ProjectFile, ChatSpace, ChatSpaceMessage } from '@/types'; // ユニークID生成関数 @@ -56,7 +66,7 @@ const getParentPath = pathGetParentPath; export class FileRepository { private dbName = 'PyxisProjects'; - private version = 4; + private version = 5; // Breaking change: ChatSpace operations now use chatStorageAdapter private db: IDBDatabase | null = null; private static instance: FileRepository | null = null; private projectNameCache: Map = new Map(); // projectId -> projectName @@ -1338,226 +1348,80 @@ export class FileRepository { } // ==================== チャットスペース操作 ==================== + // NOTE: ChatSpace操作はchatStorageAdapterに委譲 + // これらのメソッドは後方互換性のために残しているが、新規コードではchatStorageAdapterを直接使用すること /** * チャットスペース作成 + * @deprecated chatStorageAdapter.createChatSpace を直接使用してください */ async createChatSpace(projectId: string, name: string): Promise { - await this.init(); - - const chatSpace: ChatSpace = { - id: generateUniqueId('chatspace'), - name, - projectId, - messages: [], - selectedFiles: [], - createdAt: new Date(), - updatedAt: new Date(), - }; - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const request = store.add(chatSpace); - - request.onsuccess = () => resolve(chatSpace); - request.onerror = () => reject(request.error); - }); + return chatCreateChatSpace(projectId, name); } /** * チャットスペース保存 + * @deprecated chatStorageAdapter.saveChatSpace を直接使用してください */ async saveChatSpace(chatSpace: ChatSpace): Promise { - if (!this.db) throw new Error('Database not initialized'); - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const request = store.put({ ...chatSpace, updatedAt: new Date() }); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); + return chatSaveChatSpace(chatSpace); } /** * プロジェクトの全チャットスペース取得 + * @deprecated chatStorageAdapter.getChatSpaces を直接使用してください */ async getChatSpaces(projectId: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readonly'); - const store = transaction.objectStore('chatSpaces'); - const index = store.index('projectId'); - const request = index.getAll(projectId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const chatSpaces = request.result.map((cs: any) => ({ - ...cs, - createdAt: new Date(cs.createdAt), - updatedAt: new Date(cs.updatedAt), - })); - resolve(chatSpaces); - }; - }); + return chatGetChatSpaces(projectId); } /** * チャットスペース削除 + * @deprecated chatStorageAdapter.deleteChatSpace を直接使用してください */ - async deleteChatSpace(chatSpaceId: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const request = store.delete(chatSpaceId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(); - }); + async deleteChatSpace(projectId: string, chatSpaceId: string): Promise { + return chatDeleteChatSpace(projectId, chatSpaceId); } /** * チャットスペースにメッセージ追加 + * @deprecated chatStorageAdapter.addMessageToChatSpace を直接使用してください */ async addMessageToChatSpace( + projectId: string, chatSpaceId: string, message: Omit ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - // まずチャットスペースを取得 - const transaction = this.db.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const chatSpaceRequest = store.get(chatSpaceId); - - return new Promise((resolve, reject) => { - chatSpaceRequest.onsuccess = () => { - const chatSpace = chatSpaceRequest.result; - - if (!chatSpace) { - reject(new Error(`Chat space with id ${chatSpaceId} not found`)); - return; - } - - const newMessage: ChatSpaceMessage = { - ...message, - id: generateUniqueId('message'), - }; - - chatSpace.messages.push(newMessage); - chatSpace.updatedAt = new Date(); - - const putRequest = store.put(chatSpace); - putRequest.onerror = () => reject(putRequest.error); - putRequest.onsuccess = () => resolve(newMessage); - }; - - chatSpaceRequest.onerror = () => reject(chatSpaceRequest.error); - }); + return chatAddMessageToChatSpace(projectId, chatSpaceId, message as ChatSpaceMessage); } /** - * チャットスペース内の既存メッセージを更新する(部分更新をサポート) - * 主に editResponse を差し替える用途で使う想定 + * チャットスペース内の既存メッセージを更新する + * @deprecated chatStorageAdapter.updateChatSpaceMessage を直接使用してください */ async updateChatSpaceMessage( + projectId: string, chatSpaceId: string, messageId: string, updates: Partial ): Promise { - if (!this.db) throw new Error('Database not initialized'); - - const transaction = this.db.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const chatSpaceRequest = store.get(chatSpaceId); - - return new Promise((resolve, reject) => { - chatSpaceRequest.onsuccess = () => { - const chatSpace = chatSpaceRequest.result as ChatSpace | undefined; - if (!chatSpace) { - resolve(null); - return; - } - - const idx = (chatSpace.messages || []).findIndex( - (m: ChatSpaceMessage) => m.id === messageId - ); - if (idx === -1) { - resolve(null); - return; - } - - const existing = chatSpace.messages[idx]; - const updatedMessage: ChatSpaceMessage = { - ...existing, - ...updates, - // updated timestamp unless explicitly provided - timestamp: updates.timestamp ? updates.timestamp : new Date(), - }; - - chatSpace.messages[idx] = updatedMessage; - chatSpace.updatedAt = new Date(); - - const putRequest = store.put(chatSpace); - putRequest.onerror = () => reject(putRequest.error); - putRequest.onsuccess = () => resolve(updatedMessage); - }; - - chatSpaceRequest.onerror = () => reject(chatSpaceRequest.error); - }); + return chatUpdateChatSpaceMessage(projectId, chatSpaceId, messageId, updates); } /** * チャットスペースの選択ファイル更新 + * @deprecated chatStorageAdapter.updateChatSpaceSelectedFiles を直接使用してください */ - async updateChatSpaceSelectedFiles(chatSpaceId: string, selectedFiles: string[]): Promise { - if (!this.db) throw new Error('Database not initialized'); - - return new Promise(async (resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const request = store.get(chatSpaceId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const chatSpace = request.result; - if (chatSpace) { - chatSpace.selectedFiles = selectedFiles; - chatSpace.updatedAt = new Date(); - store.put(chatSpace); - } - resolve(); - }; - }); + async updateChatSpaceSelectedFiles(projectId: string, chatSpaceId: string, selectedFiles: string[]): Promise { + return chatUpdateChatSpaceSelectedFiles(projectId, chatSpaceId, selectedFiles); } /** * チャットスペース名変更 + * @deprecated chatStorageAdapter.renameChatSpace を直接使用してください */ - async renameChatSpace(chatSpaceId: string, newName: string): Promise { - if (!this.db) throw new Error('Database not initialized'); - - return new Promise((resolve, reject) => { - const transaction = this.db!.transaction(['chatSpaces'], 'readwrite'); - const store = transaction.objectStore('chatSpaces'); - const request = store.get(chatSpaceId); - - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const chatSpace = request.result; - if (chatSpace) { - chatSpace.name = newName; - chatSpace.updatedAt = new Date(); - store.put(chatSpace); - } - resolve(); - }; - }); + async renameChatSpace(projectId: string, chatSpaceId: string, newName: string): Promise { + return chatRenameChatSpace(projectId, chatSpaceId, newName); } } diff --git a/src/engine/core/project.ts b/src/engine/core/project.ts index 48ea5495..7872d68d 100644 --- a/src/engine/core/project.ts +++ b/src/engine/core/project.ts @@ -15,6 +15,7 @@ import { fileRepository } from './fileRepository'; import { LOCALSTORAGE_KEY } from '@/context/config'; import { terminalCommandRegistry } from '@/engine/cmd/terminalRegistry'; +import { createChatSpace } from '@/engine/storage/chatStorageAdapter'; import { FileItem } from '@/types'; import { Project, ProjectFile } from '@/types/'; @@ -349,7 +350,7 @@ export const useProject = () => { await initializeProjectGit(newProject, files); try { - await fileRepository.createChatSpace(newProject.id, `新規チャット`); + await createChatSpace(newProject.id, `新規チャット`); } catch (error) { console.warn('[Project] Failed to create initial chat space (non-critical):', error); } diff --git a/src/engine/core/syncManager.ts b/src/engine/core/syncManager.ts index 606b9426..5fd1b9d3 100644 --- a/src/engine/core/syncManager.ts +++ b/src/engine/core/syncManager.ts @@ -6,6 +6,7 @@ import { fileRepository } from './fileRepository'; import { gitFileSystem } from './gitFileSystem'; +import { parseGitignore, isPathIgnored, GitIgnoreRule } from './gitignore'; import { coreInfo, coreWarn, coreError } from '@/engine/core/coreLogger'; import { ProjectFile } from '@/types'; @@ -50,9 +51,48 @@ export class SyncManager { } } + /** + * Get and parse .gitignore rules for a project + */ + private async getGitignoreRules(projectId: string): Promise { + try { + const gitignoreFile = await fileRepository.getFileByPath(projectId, '/.gitignore'); + if (!gitignoreFile || !gitignoreFile.content) { + return []; + } + return parseGitignore(gitignoreFile.content); + } catch (error) { + // No .gitignore file or error reading it + return []; + } + } + + /** + * Check if a path should be ignored based on .gitignore rules + * @param rules Parsed gitignore rules + * @param path File path (will be normalized by removing leading slashes) + * @returns true if path should be ignored + */ + private shouldIgnorePath(rules: GitIgnoreRule[], path: string): boolean { + if (rules.length === 0) return false; + + // Normalize path (remove leading slash for consistent matching) + const normalizedPath = path.replace(/^\/+/, ''); + + // Check if path is ignored (false = not a directory for type-specific rules) + const ignored = isPathIgnored(rules, normalizedPath, false); + + if (ignored) { + coreInfo(`[SyncManager] Path "${path}" is ignored by .gitignore`); + } + + return ignored; + } + /** * IndexedDB → lightning-fs への同期 * 通常のファイル操作後に呼び出される + * .gitignore ルールを適用してフィルタリング */ async syncFromIndexedDBToFS(projectId: string, projectName: string): Promise { // notify listeners that a sync is starting @@ -62,6 +102,23 @@ export class SyncManager { const projectDir = gitFileSystem.getProjectDir(projectName); await gitFileSystem.ensureDirectory(projectDir); + // Get .gitignore rules + const gitignoreRules = await this.getGitignoreRules(projectId); + coreInfo(`[SyncManager] Loaded ${gitignoreRules.length} .gitignore rules`); + + // Filter out ignored files + const filteredDbFiles = dbFiles.filter(file => { + // Always include .gitignore itself (using consistent path format) + if (file.path === '/.gitignore') return true; + + // Check if file should be ignored + return !this.shouldIgnorePath(gitignoreRules, file.path); + }); + + coreInfo( + `[SyncManager] Filtered files: ${dbFiles.length} -> ${filteredDbFiles.length} (${dbFiles.length - filteredDbFiles.length} ignored)` + ); + // get FS snapshot (ignore errors — treat as empty) let existingFsFiles: Array<{ path: string; content: string; type: 'file' | 'folder' }> = []; try { @@ -71,10 +128,10 @@ export class SyncManager { } const existingFsMap = new Map(existingFsFiles.map(f => [f.path, f] as const)); - const dbFilePaths = new Set(dbFiles.map(f => f.path)); + const dbFilePaths = new Set(filteredDbFiles.map(f => f.path)); // create directories first (shortest path first) - const dirs = dbFiles + const dirs = filteredDbFiles .filter(f => f.type === 'folder') .sort((a, b) => a.path.length - b.path.length); await Promise.all( @@ -86,7 +143,7 @@ export class SyncManager { ); // write files (batch to avoid too many concurrent ops) - const files = dbFiles.filter(f => f.type === 'file'); + const files = filteredDbFiles.filter(f => f.type === 'file'); coreInfo(`[SyncManager] Syncing ${files.length} files (diff)`); const BATCH = 10; for (let i = 0; i < files.length; i += BATCH) { diff --git a/src/engine/extensions/extensionManager.ts b/src/engine/extensions/extensionManager.ts index 77e64620..c105fe57 100644 --- a/src/engine/extensions/extensionManager.ts +++ b/src/engine/extensions/extensionManager.ts @@ -632,6 +632,16 @@ class ExtensionManager { // to satisfy TypeScript and avoid unsafe direct casting warnings. return module as unknown as SystemModuleMap[T]; } + case 'pathUtils': { + const { toAppPath, getParentPath, toGitPath, fromGitPath, normalizePath } = await import('@/engine/core/pathResolver'); + return { + normalizePath, + toAppPath, + getParentPath, + toGitPath, + fromGitPath, + } as SystemModuleMap[T]; + } case 'commandRegistry': { const { commandRegistry } = await import('./commandRegistry'); return commandRegistry as SystemModuleMap[T]; @@ -650,6 +660,52 @@ class ExtensionManager { } } }, + registerTranspiler: async (transpilerConfig: any) => { + // RuntimeRegistryにトランスパイラーを登録 + try { + const { runtimeRegistry } = await import('@/engine/runtime/RuntimeRegistry'); + const { ExtensionTranspilerProvider } = await import('@/engine/runtime/providers/ExtensionTranspilerProvider'); + + const provider = new ExtensionTranspilerProvider( + transpilerConfig.id, + transpilerConfig.supportedExtensions || [], + transpilerConfig.transpile, + transpilerConfig.needsTranspile + ); + + runtimeRegistry.registerTranspiler(provider); + console.log(`[${extensionId}] Registered transpiler: ${transpilerConfig.id}`); + } catch (error) { + console.error(`[${extensionId}] Failed to register transpiler:`, error); + throw error; + } + }, + registerRuntime: async (runtimeConfig: any) => { + // RuntimeRegistryにランタイムを登録 + try { + const { runtimeRegistry } = await import('@/engine/runtime/RuntimeRegistry'); + + // Create a runtime provider from the config + const provider = { + id: runtimeConfig.id, + name: runtimeConfig.name, + supportedExtensions: runtimeConfig.supportedExtensions || [], + canExecute: runtimeConfig.canExecute, + initialize: runtimeConfig.initialize, + execute: runtimeConfig.execute, + executeCode: runtimeConfig.executeCode, + clearCache: runtimeConfig.clearCache, + dispose: runtimeConfig.dispose, + isReady: runtimeConfig.isReady, + }; + + runtimeRegistry.registerRuntime(provider); + console.log(`[${extensionId}] Registered runtime: ${runtimeConfig.id}`); + } catch (error) { + console.error(`[${extensionId}] Failed to register runtime:`, error); + throw error; + } + }, // strict stubs — will be replaced after real API instances are created tabs: { registerTabType: notInitialized('tabs.registerTabType'), diff --git a/src/engine/extensions/systemModuleTypes.ts b/src/engine/extensions/systemModuleTypes.ts index ae37f6b9..60ac8ff2 100644 --- a/src/engine/extensions/systemModuleTypes.ts +++ b/src/engine/extensions/systemModuleTypes.ts @@ -16,6 +16,7 @@ import type { UnixCommands } from '@/engine/cmd/global/unix'; import type { StreamShell } from '@/engine/cmd/shell/streamShell'; import type { FileRepository } from '@/engine/core/fileRepository'; import type { normalizeCjsEsm } from '@/engine/runtime/normalizeCjsEsm'; +import type { toAppPath, getParentPath, toGitPath, fromGitPath } from '@/engine/core/pathResolver'; /** * normalizeCjsEsmモジュールの型定義 @@ -23,6 +24,17 @@ import type { normalizeCjsEsm } from '@/engine/runtime/normalizeCjsEsm'; */ export type NormalizeCjsEsmModule = typeof normalizeCjsEsm; +/** + * pathUtilsモジュールの型定義 + */ +export interface PathUtilsModule { + normalizePath: typeof toAppPath; + toAppPath: typeof toAppPath; + getParentPath: typeof getParentPath; + toGitPath: typeof toGitPath; + fromGitPath: typeof fromGitPath; +} + /** * システムモジュールの型マップ * この型を使用して getSystemModule の戻り値型を推論する @@ -30,6 +42,7 @@ export type NormalizeCjsEsmModule = typeof normalizeCjsEsm; export interface SystemModuleMap { fileRepository: FileRepository; normalizeCjsEsm: NormalizeCjsEsmModule; + pathUtils: PathUtilsModule; commandRegistry: CommandRegistry; /** Terminal/CLI command singletons provider */ systemBuiltinCommands: { diff --git a/src/engine/extensions/types.ts b/src/engine/extensions/types.ts index 940bd7f3..ba485be7 100644 --- a/src/engine/extensions/types.ts +++ b/src/engine/extensions/types.ts @@ -180,6 +180,28 @@ export interface ExtensionContext { /** システムモジュールへのアクセス (型安全) */ getSystemModule: (moduleName: T) => Promise; + /** トランスパイラーを登録(transpiler拡張機能用) */ + registerTranspiler?: (config: { + id: string; + supportedExtensions: string[]; + needsTranspile?: (filePath: string) => boolean; + transpile: (code: string, options: any) => Promise<{ code: string; map?: string; dependencies?: string[] }>; + }) => Promise; + + /** ランタイムを登録(language-runtime拡張機能用) */ + registerRuntime?: (config: { + id: string; + name: string; + supportedExtensions: string[]; + canExecute: (filePath: string) => boolean; + initialize?: (projectId: string, projectName: string) => Promise; + execute: (options: any) => Promise; + executeCode?: (code: string, options: any) => Promise; + clearCache?: () => void; + dispose?: () => Promise; + isReady?: () => boolean; + }) => Promise; + /** 他の拡張機能との通信 (オプション・未実装) */ messaging?: { send: (targetId: string, message: unknown) => Promise; diff --git a/src/engine/helper/resize.ts b/src/engine/helper/resize.ts index 923ca833..61a31119 100644 --- a/src/engine/helper/resize.ts +++ b/src/engine/helper/resize.ts @@ -1,171 +1,60 @@ +/** + * リサイズフック - 汎用useResizeフックを使用したシンプルな実装 + * + * 従来の実装では各サイドバー/パネル用に個別のフックがあり、 + * 同じパターン(mousedown/touchstart -> move -> end)が繰り返されていた。 + * 新しい実装では useResize フックを使用して、コードの重複を排除。 + */ + +import { useResize } from '@/hooks/useResize'; + // 右サイドバー用リサイズフック export const useRightSidebarResize = ( rightSidebarWidth: number, setRightSidebarWidth: (width: number) => void ) => { - return (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); - const isTouch = 'touches' in e; - const startX = isTouch ? e.touches[0].clientX : e.clientX; - const initialWidth = rightSidebarWidth; - const minWidth = 120; - const maxWidth = window.innerWidth * 0.7; - - let rafId: number | null = null; - const widthRef = { current: initialWidth }; - - const handleMove = (e: MouseEvent | TouchEvent) => { - e.preventDefault(); - const currentX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const deltaX = startX - currentX; - const newWidth = initialWidth + deltaX; - const clampedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth)); - widthRef.current = clampedWidth; - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - rafId = requestAnimationFrame(() => { - setRightSidebarWidth(widthRef.current); - const sidebar = document.querySelector('[data-sidebar="right"]') as HTMLElement; - if (sidebar) { - sidebar.style.width = `${widthRef.current}px`; - } - }); - }; - - const handleEnd = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - document.removeEventListener('mousemove', handleMove as EventListener); - document.removeEventListener('mouseup', handleEnd); - document.removeEventListener('touchmove', handleMove as EventListener); - document.removeEventListener('touchend', handleEnd); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.touchAction = ''; - }; - - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - document.body.style.touchAction = 'none'; - document.addEventListener('mousemove', handleMove as EventListener); - document.addEventListener('mouseup', handleEnd); - document.addEventListener('touchmove', handleMove as EventListener); - document.addEventListener('touchend', handleEnd); - }; + const { startResizeInverted } = useResize({ + direction: 'vertical', + initialSize: rightSidebarWidth, + minSize: 120, + maxSize: typeof window !== 'undefined' ? window.innerWidth * 0.7 : 1000, + onResize: setRightSidebarWidth, + targetSelector: '[data-sidebar="right"]', + }); + + return startResizeInverted; }; +// 左サイドバー用リサイズフック export const useLeftSidebarResize = ( leftSidebarWidth: number, setLeftSidebarWidth: (width: number) => void ) => { - return (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); - const isTouch = 'touches' in e; - const startX = isTouch ? e.touches[0].clientX : e.clientX; - const initialWidth = leftSidebarWidth; - const minWidth = 120; - const maxWidth = window.innerWidth * 0.7; - - let rafId: number | null = null; - const widthRef = { current: initialWidth }; - - const handleMove = (e: MouseEvent | TouchEvent) => { - e.preventDefault(); - const currentX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const deltaX = currentX - startX; - const newWidth = initialWidth + deltaX; - const clampedWidth = Math.max(minWidth, Math.min(maxWidth, newWidth)); - widthRef.current = clampedWidth; - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - rafId = requestAnimationFrame(() => { - setLeftSidebarWidth(widthRef.current); - const sidebar = document.querySelector('[data-sidebar="left"]') as HTMLElement; - if (sidebar) { - sidebar.style.width = `${widthRef.current}px`; - } - }); - }; - - const handleEnd = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - document.removeEventListener('mousemove', handleMove as EventListener); - document.removeEventListener('mouseup', handleEnd); - document.removeEventListener('touchmove', handleMove as EventListener); - document.removeEventListener('touchend', handleEnd); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.touchAction = ''; - }; - - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - document.body.style.touchAction = 'none'; - document.addEventListener('mousemove', handleMove as EventListener); - document.addEventListener('mouseup', handleEnd); - document.addEventListener('touchmove', handleMove as EventListener); - document.addEventListener('touchend', handleEnd); - }; + const { startResize } = useResize({ + direction: 'vertical', + initialSize: leftSidebarWidth, + minSize: 120, + maxSize: typeof window !== 'undefined' ? window.innerWidth * 0.7 : 1000, + onResize: setLeftSidebarWidth, + targetSelector: '[data-sidebar="left"]', + }); + + return startResize; }; +// ボトムパネル用リサイズフック export const useBottomPanelResize = ( bottomPanelHeight: number, setBottomPanelHeight: (height: number) => void ) => { - return (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); - const isTouch = 'touches' in e; - const startY = isTouch ? e.touches[0].clientY : e.clientY; - const initialHeight = bottomPanelHeight; - const minHeight = 100; - const maxHeight = window.innerHeight; - - let rafId: number | null = null; - const heightRef = { current: initialHeight }; - - const handleMove = (e: MouseEvent | TouchEvent) => { - e.preventDefault(); - const currentY = 'touches' in e ? e.touches[0].clientY : e.clientY; - const deltaY = startY - currentY; - const newHeight = initialHeight + deltaY; - const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight)); - heightRef.current = clampedHeight; - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - rafId = requestAnimationFrame(() => { - setBottomPanelHeight(heightRef.current); - const panel = document.querySelector('[data-panel="bottom"]') as HTMLElement; - if (panel) { - panel.style.height = `${heightRef.current}px`; - } - }); - }; - - const handleEnd = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - document.removeEventListener('mousemove', handleMove as EventListener); - document.removeEventListener('mouseup', handleEnd); - document.removeEventListener('touchmove', handleMove as EventListener); - document.removeEventListener('touchend', handleEnd); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.body.style.touchAction = ''; - }; - - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - document.body.style.touchAction = 'none'; - document.addEventListener('mousemove', handleMove as EventListener); - document.addEventListener('mouseup', handleEnd); - document.addEventListener('touchmove', handleMove as EventListener); - document.addEventListener('touchend', handleEnd); - }; + const { startResizeInverted } = useResize({ + direction: 'horizontal', + initialSize: bottomPanelHeight, + minSize: 100, + maxSize: typeof window !== 'undefined' ? window.innerHeight : 1000, + onResize: setBottomPanelHeight, + targetSelector: '[data-panel="bottom"]', + }); + + return startResizeInverted; }; diff --git a/src/engine/runtime/RuntimeProvider.ts b/src/engine/runtime/RuntimeProvider.ts new file mode 100644 index 00000000..3abff399 --- /dev/null +++ b/src/engine/runtime/RuntimeProvider.ts @@ -0,0 +1,149 @@ +/** + * Runtime Provider Interface + * + * ランタイムの抽象インターフェース + * - 各言語ランタイム(Node.js、Python等)はこのインターフェースを実装 + * - 拡張可能で体系的な設計 + * - メモリリーク防止を最優先 + */ + +/** + * ランタイム実行オプション + */ +export interface RuntimeExecutionOptions { + /** プロジェクトID */ + projectId: string; + /** プロジェクト名 */ + projectName: string; + /** 実行するファイルのパス */ + filePath: string; + /** コマンドライン引数 */ + argv?: string[]; + /** デバッグコンソール */ + debugConsole?: { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + clear: () => void; + }; + /** 入力コールバック(readline等) */ + onInput?: (prompt: string, callback: (input: string) => void) => void; + /** ターミナル幅 */ + terminalColumns?: number; + /** ターミナル高さ */ + terminalRows?: number; +} + +/** + * ランタイム実行結果 + */ +export interface RuntimeExecutionResult { + /** 標準出力 */ + stdout?: string; + /** 標準エラー出力 */ + stderr?: string; + /** 実行結果(REPLモード用) */ + result?: unknown; + /** 終了コード */ + exitCode?: number; +} + +/** + * ランタイムプロバイダーインターフェース + * + * すべてのランタイム(Node.js、Python、その他言語)はこのインターフェースを実装する + */ +export interface RuntimeProvider { + /** + * ランタイムの識別子(例: "nodejs", "python") + */ + readonly id: string; + + /** + * ランタイムの表示名(例: "Node.js", "Python") + */ + readonly name: string; + + /** + * サポートするファイル拡張子のリスト + */ + readonly supportedExtensions: string[]; + + /** + * ファイルがこのランタイムで実行可能か判定 + */ + canExecute(filePath: string): boolean; + + /** + * ランタイムの初期化 + * - プロジェクト切り替え時に呼ばれる + * - 必要なリソースの準備(例: Pyodideの初期化) + */ + initialize?(projectId: string, projectName: string): Promise; + + /** + * ファイルを実行 + * - メモリリークを起こさないよう注意 + * - キャッシュ戦略を適切に使用 + */ + execute(options: RuntimeExecutionOptions): Promise; + + /** + * コードスニペットを実行(REPLモード) + * - 一時的なコード実行用 + */ + executeCode?(code: string, options: RuntimeExecutionOptions): Promise; + + /** + * キャッシュをクリア + * - メモリリーク防止のため定期的に呼ばれる + */ + clearCache?(): void; + + /** + * ランタイムのクリーンアップ + * - プロジェクト切り替え時やアンマウント時に呼ばれる + */ + dispose?(): Promise; + + /** + * ランタイムが準備完了しているか + */ + isReady?(): boolean; +} + +/** + * トランスパイラープロバイダーインターフェース + * + * TypeScript、JSX等のトランスパイルが必要な言語用 + */ +export interface TranspilerProvider { + /** + * トランスパイラーの識別子 + */ + readonly id: string; + + /** + * サポートするファイル拡張子 + */ + readonly supportedExtensions: string[]; + + /** + * トランスパイルが必要か判定 + */ + needsTranspile(filePath: string, content?: string): boolean; + + /** + * コードをトランスパイル + */ + transpile(code: string, options: { + filePath: string; + isTypeScript?: boolean; + isESModule?: boolean; + isJSX?: boolean; + }): Promise<{ + code: string; + map?: string; + dependencies?: string[]; + }>; +} diff --git a/src/engine/runtime/RuntimeRegistry.ts b/src/engine/runtime/RuntimeRegistry.ts new file mode 100644 index 00000000..a66f6d6d --- /dev/null +++ b/src/engine/runtime/RuntimeRegistry.ts @@ -0,0 +1,223 @@ +/** + * Runtime Registry + * + * ランタイムプロバイダーの登録・管理 + * - ビルトインランタイム(Node.js)の登録 + * - 拡張機能ランタイム(Python等)の動的登録 + * - ファイル拡張子に基づくランタイムの自動選択 + */ + +import { runtimeInfo, runtimeWarn } from './runtimeLogger'; + +import type { RuntimeProvider, TranspilerProvider } from './RuntimeProvider'; + +/** + * RuntimeRegistry + * + * シングルトンパターンでランタイムプロバイダーを管理 + */ +export class RuntimeRegistry { + private static instance: RuntimeRegistry | null = null; + + private runtimeProviders: Map = new Map(); + private transpilerProviders: Map = new Map(); + private extensionToRuntime: Map = new Map(); // .js -> "nodejs" + private extensionToTranspiler: Map = new Map(); // .ts -> ["typescript"] + + private constructor() { + runtimeInfo('🔧 RuntimeRegistry initialized'); + } + + /** + * シングルトンインスタンスを取得 + */ + static getInstance(): RuntimeRegistry { + if (!RuntimeRegistry.instance) { + RuntimeRegistry.instance = new RuntimeRegistry(); + } + return RuntimeRegistry.instance; + } + + /** + * ランタイムプロバイダーを登録 + */ + registerRuntime(provider: RuntimeProvider): void { + const id = provider.id; + + if (this.runtimeProviders.has(id)) { + runtimeWarn(`⚠️ Runtime provider already registered: ${id}, replacing...`); + } + + this.runtimeProviders.set(id, provider); + + // 拡張子とランタイムのマッピングを登録 + for (const ext of provider.supportedExtensions) { + this.extensionToRuntime.set(ext, id); + } + + runtimeInfo(`✅ Runtime provider registered: ${id} (${provider.supportedExtensions.join(', ')})`); + } + + /** + * トランスパイラープロバイダーを登録 + */ + registerTranspiler(provider: TranspilerProvider): void { + const id = provider.id; + + if (this.transpilerProviders.has(id)) { + runtimeWarn(`⚠️ Transpiler provider already registered: ${id}, replacing...`); + } + + this.transpilerProviders.set(id, provider); + + // 拡張子とトランスパイラーのマッピングを登録 + for (const ext of provider.supportedExtensions) { + if (!this.extensionToTranspiler.has(ext)) { + this.extensionToTranspiler.set(ext, []); + } + this.extensionToTranspiler.get(ext)!.push(id); + } + + runtimeInfo(`✅ Transpiler provider registered: ${id} (${provider.supportedExtensions.join(', ')})`); + } + + /** + * ランタイムプロバイダーを登録解除 + */ + unregisterRuntime(id: string): void { + const provider = this.runtimeProviders.get(id); + if (!provider) { + runtimeWarn(`⚠️ Runtime provider not found: ${id}`); + return; + } + + // 拡張子マッピングを削除 + for (const ext of provider.supportedExtensions) { + if (this.extensionToRuntime.get(ext) === id) { + this.extensionToRuntime.delete(ext); + } + } + + this.runtimeProviders.delete(id); + runtimeInfo(`🗑️ Runtime provider unregistered: ${id}`); + } + + /** + * トランスパイラープロバイダーを登録解除 + */ + unregisterTranspiler(id: string): void { + const provider = this.transpilerProviders.get(id); + if (!provider) { + runtimeWarn(`⚠️ Transpiler provider not found: ${id}`); + return; + } + + // 拡張子マッピングを削除 + for (const ext of provider.supportedExtensions) { + const transpilers = this.extensionToTranspiler.get(ext); + if (transpilers) { + const index = transpilers.indexOf(id); + if (index > -1) { + transpilers.splice(index, 1); + } + if (transpilers.length === 0) { + this.extensionToTranspiler.delete(ext); + } + } + } + + this.transpilerProviders.delete(id); + runtimeInfo(`🗑️ Transpiler provider unregistered: ${id}`); + } + + /** + * ファイルパスに基づいてランタイムプロバイダーを取得 + */ + getRuntimeForFile(filePath: string): RuntimeProvider | null { + // 拡張子を取得 + const ext = this.getExtension(filePath); + if (!ext) { + return null; + } + + // 拡張子に対応するランタイムIDを取得 + const runtimeId = this.extensionToRuntime.get(ext); + if (!runtimeId) { + return null; + } + + // ランタイムプロバイダーを取得 + return this.runtimeProviders.get(runtimeId) || null; + } + + /** + * IDでランタイムプロバイダーを取得 + */ + getRuntime(id: string): RuntimeProvider | null { + return this.runtimeProviders.get(id) || null; + } + + /** + * ファイルパスに基づいてトランスパイラープロバイダーを取得 + */ + getTranspilerForFile(filePath: string): TranspilerProvider | null { + const ext = this.getExtension(filePath); + if (!ext) { + return null; + } + + const transpilerIds = this.extensionToTranspiler.get(ext); + if (!transpilerIds || transpilerIds.length === 0) { + return null; + } + + // 最初に登録されたトランスパイラーを返す(優先順位) + const transpilerId = transpilerIds[0]; + return this.transpilerProviders.get(transpilerId) || null; + } + + /** + * IDでトランスパイラープロバイダーを取得 + */ + getTranspiler(id: string): TranspilerProvider | null { + return this.transpilerProviders.get(id) || null; + } + + /** + * 登録されているすべてのランタイムプロバイダーを取得 + */ + getAllRuntimes(): RuntimeProvider[] { + return Array.from(this.runtimeProviders.values()); + } + + /** + * 登録されているすべてのトランスパイラープロバイダーを取得 + */ + getAllTranspilers(): TranspilerProvider[] { + return Array.from(this.transpilerProviders.values()); + } + + /** + * ファイルの拡張子を取得 + */ + private getExtension(filePath: string): string | null { + const match = filePath.match(/(\.[^.]+)$/); + return match ? match[1] : null; + } + + /** + * すべてのプロバイダーをクリア(テスト用) + */ + clear(): void { + this.runtimeProviders.clear(); + this.transpilerProviders.clear(); + this.extensionToRuntime.clear(); + this.extensionToTranspiler.clear(); + runtimeInfo('🗑️ RuntimeRegistry cleared'); + } +} + +/** + * シングルトンインスタンスをエクスポート + */ +export const runtimeRegistry = RuntimeRegistry.getInstance(); diff --git a/src/engine/runtime/builtinRuntimes.ts b/src/engine/runtime/builtinRuntimes.ts new file mode 100644 index 00000000..471f18eb --- /dev/null +++ b/src/engine/runtime/builtinRuntimes.ts @@ -0,0 +1,24 @@ +/** + * Builtin Runtime Providers + * + * ビルトインランタイムプロバイダーの初期化 + * - Node.jsランタイムは常にビルトイン + * - アプリケーション起動時に自動登録 + */ + +import { NodeRuntimeProvider } from './providers/NodeRuntimeProvider'; +import { runtimeInfo } from './runtimeLogger'; +import { runtimeRegistry } from './RuntimeRegistry'; + +/** + * ビルトインランタイムプロバイダーを初期化・登録 + */ +export function initializeBuiltinRuntimes(): void { + runtimeInfo('🔧 Initializing builtin runtime providers...'); + + // Node.jsランタイムプロバイダーを登録 + const nodeProvider = new NodeRuntimeProvider(); + runtimeRegistry.registerRuntime(nodeProvider); + + runtimeInfo('✅ Builtin runtime providers initialized'); +} diff --git a/src/engine/runtime/moduleLoader.ts b/src/engine/runtime/moduleLoader.ts index 8c85da03..0fa9545b 100644 --- a/src/engine/runtime/moduleLoader.ts +++ b/src/engine/runtime/moduleLoader.ts @@ -12,10 +12,10 @@ import { ModuleCache } from './moduleCache'; import { ModuleResolver } from './moduleResolver'; import { normalizePath, dirname } from './pathUtils'; import { runtimeInfo, runtimeWarn, runtimeError } from './runtimeLogger'; +import { runtimeRegistry } from './RuntimeRegistry'; import { transpileManager } from './transpileManager'; import { fileRepository } from '@/engine/core/fileRepository'; -import { extensionManager } from '@/engine/extensions/extensionManager'; /** * モジュール実行キャッシュ(循環参照対策) @@ -218,42 +218,39 @@ export class ModuleLoader { } runtimeInfo('🔄 Transpiling module (extracting dependencies):', filePath); - const isTypeScript = /\.(ts|tsx|mts|cts)$/.test(filePath); - const isJSX = /\.(jsx|tsx)$/.test(filePath); - - // TypeScript/JSXの場合は拡張機能のトランスパイラを使用 - if (isTypeScript || isJSX) { - const activeExtensions = extensionManager.getActiveExtensions(); - for (const ext of activeExtensions) { - if (ext.activation.runtimeFeatures?.transpiler) { - try { - runtimeInfo(`🔌 Using extension transpiler: ${ext.manifest.id}`); - - const result = (await ext.activation.runtimeFeatures.transpiler(content, { - filePath, - isTypeScript, - isJSX, - })) as { code: string; map?: string; dependencies?: string[] }; - - const deps = result.dependencies || []; - await this.cache.set(filePath, { - originalPath: filePath, - contentHash: version, - code: result.code, - sourceMap: result.map, - deps, - mtime: Date.now(), - size: result.code.length, - }); - - return { code: result.code, dependencies: deps }; - } catch (error) { - runtimeError(`❌ Extension transpiler failed: ${ext.manifest.id}`, error); - throw error; - } - } + const isTypeScript = /\.(ts|mts|cts)$/.test(filePath); + + // TypeScriptの場合はRegistryからトランスパイラを取得 + if (isTypeScript) { + const transpiler = runtimeRegistry.getTranspilerForFile(filePath); + if (!transpiler) { + throw new Error(`No transpiler found for ${filePath}. Please install the TypeScript runtime extension.`); + } + + try { + runtimeInfo(`🔌 Using transpiler: ${transpiler.id}`); + + const result = await transpiler.transpile(content, { + filePath, + isTypeScript, + }); + + const deps = result.dependencies || []; + await this.cache.set(filePath, { + originalPath: filePath, + contentHash: version, + code: result.code, + sourceMap: result.map, + deps, + mtime: Date.now(), + size: result.code.length, + }); + + return { code: result.code, dependencies: deps }; + } catch (error) { + runtimeError(`❌ Transpiler failed: ${transpiler.id}`, error); + throw error; } - throw new Error(`No transpiler extension found for ${filePath}`); } // 普通のJSの場合はnormalizeCjsEsmのみ @@ -521,6 +518,29 @@ export class ModuleLoader { const clearInterval = this.globals.clearInterval || globalThis.clearInterval; const global = this.globals.global || globalThis; + // Temporarily spoof navigator for supports-color browser.js detection + // supports-color checks globalThis.navigator.userAgentData and userAgent + // Without this, iOS Safari returns 0 (no color) because it doesn't match Chrome/Chromium + const originalNavigator = globalThis.navigator; + const spoofedNavigator = { + ...(originalNavigator || {}), + userAgent: 'Mozilla/5.0 Chrome/120.0.0.0', + userAgentData: { + brands: [{ brand: 'Chromium', version: 120 }], // version as number for > 93 comparison + }, + }; + + // Apply spoofed navigator to globalThis + try { + Object.defineProperty(globalThis, 'navigator', { + value: spoofedNavigator, + configurable: true, + writable: true, + }); + } catch (e) { + // If we can't modify navigator, continue anyway + } + // コードをラップして実行。console を受け取るようにして、モジュール内の // console.log 呼び出しがここで用意した sandboxConsole を使うようにする。 // 同期実行のため async は削除 @@ -564,6 +584,17 @@ export class ModuleLoader { // This is especially useful for Prettier where some plugins may fail // but the core functionality might still work return module.exports || {}; + } finally { + // Restore original navigator + try { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + } catch (e) { + // Ignore restoration errors + } } } diff --git a/src/engine/runtime/nodeRuntime.ts b/src/engine/runtime/nodeRuntime.ts index 1b45c90c..1abaaf1b 100644 --- a/src/engine/runtime/nodeRuntime.ts +++ b/src/engine/runtime/nodeRuntime.ts @@ -31,6 +31,10 @@ export interface ExecutionOptions { clear: () => void; }; onInput?: (prompt: string, callback: (input: string) => void) => void; + /** Terminal columns (width). If not provided, defaults to 80. */ + terminalColumns?: number; + /** Terminal rows (height). If not provided, defaults to 24. */ + terminalRows?: number; } /** @@ -44,6 +48,8 @@ export class NodeRuntime { private builtInModules: BuiltInModules; private moduleLoader: ModuleLoader; private projectDir: string; + private terminalColumns: number; + private terminalRows: number; // イベントループ追跡 private activeTimers: Set = new Set(); @@ -55,6 +61,8 @@ export class NodeRuntime { this.debugConsole = options.debugConsole; this.onInput = options.onInput; this.projectDir = `/projects/${this.projectName}`; + this.terminalColumns = options.terminalColumns ?? 80; + this.terminalRows = options.terminalRows ?? 24; // ビルトインモジュールの初期化(onInputを渡す) this.builtInModules = createBuiltInModules({ @@ -334,10 +342,26 @@ export class NodeRuntime { WeakSet, // Node.js グローバル - global: globalThis, + // Create a custom global with spoofed navigator for color support detection + // supports-color browser.js checks navigator.userAgent for Chromium + // Without this, iOS Safari returns 0 (no color) because it doesn't match Chrome/Chromium + global: { + ...globalThis, + navigator: { + ...(globalThis.navigator || {}), + userAgent: 'Mozilla/5.0 Chrome/120.0.0.0', + userAgentData: { + brands: [{ brand: 'Chromium', version: '120' }], + }, + }, + }, process: { env: { LANG: 'en', + // chalk, colors, etc. color libraries check these environment variables + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + FORCE_COLOR: '3', // Force color level 3 (truecolor) }, argv: ['node', currentFilePath].concat(argv || []), cwd: () => this.projectDir, @@ -366,6 +390,10 @@ export class NodeRuntime { return true; }, isTTY: true, + columns: this.terminalColumns, + rows: this.terminalRows, + getColorDepth: () => 24, // 24-bit color (truecolor) + hasColors: (count?: number) => count === undefined || count <= 16777216, }, stderr: { write: (data: string) => { @@ -377,6 +405,10 @@ export class NodeRuntime { return true; }, isTTY: true, + columns: this.terminalColumns, + rows: this.terminalRows, + getColorDepth: () => 24, + hasColors: (count?: number) => count === undefined || count <= 16777216, }, }, Buffer: this.builtInModules.Buffer, diff --git a/src/engine/runtime/providers/ExtensionTranspilerProvider.ts b/src/engine/runtime/providers/ExtensionTranspilerProvider.ts new file mode 100644 index 00000000..97243c1d --- /dev/null +++ b/src/engine/runtime/providers/ExtensionTranspilerProvider.ts @@ -0,0 +1,71 @@ +/** + * Extension-based Transpiler Provider + * + * 拡張機能のトランスパイラーをTranspilerProviderインターフェースでラップ + */ + +import { runtimeInfo, runtimeError } from '../runtimeLogger'; + +import type { TranspilerProvider } from '../RuntimeProvider'; + +/** + * 拡張機能のトランスパイラーをラップ + */ +export class ExtensionTranspilerProvider implements TranspilerProvider { + readonly id: string; + readonly supportedExtensions: string[]; + + private transpilerFn: (code: string, options: any) => Promise; + private needsTranspileFn?: (filePath: string) => boolean; + + constructor( + id: string, + supportedExtensions: string[], + transpilerFn: (code: string, options: any) => Promise, + needsTranspileFn?: (filePath: string) => boolean + ) { + this.id = id; + this.supportedExtensions = supportedExtensions; + this.transpilerFn = transpilerFn; + this.needsTranspileFn = needsTranspileFn; + } + + needsTranspile(filePath: string, content?: string): boolean { + if (this.needsTranspileFn) { + return this.needsTranspileFn(filePath); + } + // デフォルト: サポートする拡張子の場合はトランスパイルが必要 + return this.supportedExtensions.some(ext => filePath.endsWith(ext)); + } + + async transpile( + code: string, + options: { + filePath: string; + isTypeScript?: boolean; + isESModule?: boolean; + isJSX?: boolean; + } + ): Promise<{ + code: string; + map?: string; + dependencies?: string[]; + }> { + try { + runtimeInfo(`🔄 Transpiling with ${this.id}: ${options.filePath}`); + + const result = await this.transpilerFn(code, options); + + runtimeInfo(`✅ Transpiled with ${this.id}: ${options.filePath}`); + + return { + code: result.code, + map: result.map, + dependencies: result.dependencies || [], + }; + } catch (error) { + runtimeError(`❌ Transpile failed with ${this.id}:`, error); + throw error; + } + } +} diff --git a/src/engine/runtime/providers/NodeRuntimeProvider.ts b/src/engine/runtime/providers/NodeRuntimeProvider.ts new file mode 100644 index 00000000..286eafd8 --- /dev/null +++ b/src/engine/runtime/providers/NodeRuntimeProvider.ts @@ -0,0 +1,124 @@ +/** + * Node.js Runtime Provider + * + * ビルトインのNode.jsランタイムプロバイダー + * - 既存のNodeRuntimeをラップ + * - RuntimeProviderインターフェースを実装 + */ + +import { NodeRuntime } from '../nodeRuntime'; +import { runtimeInfo } from '../runtimeLogger'; + +import { fileRepository } from '@/engine/core/fileRepository'; + +import type { RuntimeProvider, RuntimeExecutionOptions, RuntimeExecutionResult } from '../RuntimeProvider'; + +export class NodeRuntimeProvider implements RuntimeProvider { + readonly id = 'nodejs'; + readonly name = 'Node.js'; + readonly supportedExtensions = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']; + + private runtimeInstances: Map = new Map(); + + canExecute(filePath: string): boolean { + return this.supportedExtensions.some(ext => filePath.endsWith(ext)); + } + + async initialize(projectId: string, projectName: string): Promise { + runtimeInfo(`🚀 Initializing Node.js runtime for project: ${projectName}`); + // Node.jsランタイムは遅延初期化(execute時に作成) + } + + async execute(options: RuntimeExecutionOptions): Promise { + const { projectId, projectName, filePath, argv = [], debugConsole, onInput, terminalColumns, terminalRows } = options; + + try { + // NodeRuntimeインスタンスを作成(プロジェクトごとにキャッシュ) + const key = `${projectId}-${filePath}`; + + // 既存のキャッシュはメモリリーク防止のためクリア + if (this.runtimeInstances.has(key)) { + const existing = this.runtimeInstances.get(key)!; + existing.clearCache(); + this.runtimeInstances.delete(key); + } + + const runtime = new NodeRuntime({ + projectId, + projectName, + filePath, + debugConsole, + onInput, + terminalColumns, + terminalRows, + }); + + // 実行 + await runtime.execute(filePath, argv); + + // イベントループの完了を待つ + await runtime.waitForEventLoop(); + + return { + exitCode: 0, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + stderr: errorMessage, + exitCode: 1, + }; + } + } + + async executeCode(code: string, options: RuntimeExecutionOptions): Promise { + const { projectId, projectName } = options; + + try { + // 一時ファイルを作成 + const tempFilePath = '/temp-code.js'; + await fileRepository.createFile(projectId, tempFilePath, code, 'file'); + + // 実行 + const result = await this.execute({ + ...options, + filePath: tempFilePath, + }); + + // 一時ファイルを削除 + try { + const tempFile = await fileRepository.getFileByPath(projectId, tempFilePath); + if (tempFile) { + await fileRepository.deleteFile(tempFile.id); + } + } catch (e) { + // 削除失敗は無視 + } + + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + stderr: errorMessage, + exitCode: 1, + }; + } + } + + clearCache(): void { + runtimeInfo('🗑️ Clearing Node.js runtime cache'); + for (const runtime of this.runtimeInstances.values()) { + runtime.clearCache(); + } + this.runtimeInstances.clear(); + } + + async dispose(): Promise { + runtimeInfo('🗑️ Disposing Node.js runtime'); + this.clearCache(); + } + + isReady(): boolean { + return true; // Node.jsランタイムは常に準備完了 + } +} diff --git a/src/engine/storage/chatStorageAdapter.ts b/src/engine/storage/chatStorageAdapter.ts index 62a50b74..5d4ec58d 100644 --- a/src/engine/storage/chatStorageAdapter.ts +++ b/src/engine/storage/chatStorageAdapter.ts @@ -1,23 +1,35 @@ import { storageService, STORES } from '@/engine/storage'; import type { ChatSpace, ChatSpaceMessage } from '@/types'; -function makeKey(id: string) { - return `chatSpace:${id}`; +/** + * キー形式: chatSpace:${projectId}:${spaceId} + * プロジェクト単位での効率的な取得を可能にする + */ +function makeKey(projectId: string, spaceId: string): string { + return `chatSpace:${projectId}:${spaceId}`; } +/** + * プロジェクトのチャットスペース一覧を取得 + */ export async function getChatSpaces(projectId: string): Promise { if (!projectId) return []; + const all = (await storageService.getAll(STORES.CHAT_SPACES)) || []; const spaces: ChatSpace[] = []; + const prefix = `chatSpace:${projectId}:`; + for (const e of all) { - try { - const data = e.data as ChatSpace; - if (data.projectId === projectId) spaces.push(data); - } catch (e) { - console.warn('[chatStorageAdapter] malformed entry', e); + if (e.id.startsWith(prefix)) { + try { + spaces.push(e.data as ChatSpace); + } catch (err) { + console.warn('[chatStorageAdapter] malformed entry', err); + } } } - // sort by updatedAt desc + + // updatedAt descでソート spaces.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); return spaces; } @@ -34,24 +46,24 @@ export async function createChatSpace(projectId: string, name: string): Promise< createdAt: now, updatedAt: now, }; - await storageService.set(STORES.CHAT_SPACES, makeKey(id), space, { cache: false }); + await storageService.set(STORES.CHAT_SPACES, makeKey(projectId, id), space, { cache: false }); return space; } -export async function deleteChatSpace(spaceId: string): Promise { - await storageService.delete(STORES.CHAT_SPACES, makeKey(spaceId)); +export async function deleteChatSpace(projectId: string, spaceId: string): Promise { + await storageService.delete(STORES.CHAT_SPACES, makeKey(projectId, spaceId)); } -export async function renameChatSpace(spaceId: string, newName: string): Promise { - const key = makeKey(spaceId); +export async function renameChatSpace(projectId: string, spaceId: string, newName: string): Promise { + const key = makeKey(projectId, spaceId); const sp = await storageService.get(STORES.CHAT_SPACES, key); if (!sp) throw new Error('chat space not found'); const updated = { ...(sp as ChatSpace), name: newName, updatedAt: new Date() } as ChatSpace; await storageService.set(STORES.CHAT_SPACES, key, updated, { cache: false }); } -export async function addMessageToChatSpace(spaceId: string, message: ChatSpaceMessage): Promise { - const key = makeKey(spaceId); +export async function addMessageToChatSpace(projectId: string, spaceId: string, message: ChatSpaceMessage): Promise { + const key = makeKey(projectId, spaceId); const sp = await storageService.get(STORES.CHAT_SPACES, key); if (!sp) throw new Error('chat space not found'); const space = { ...(sp as ChatSpace) } as ChatSpace; @@ -62,8 +74,8 @@ export async function addMessageToChatSpace(spaceId: string, message: ChatSpaceM return msg; } -export async function updateChatSpaceMessage(spaceId: string, messageId: string, patch: Partial): Promise { - const key = makeKey(spaceId); +export async function updateChatSpaceMessage(projectId: string, spaceId: string, messageId: string, patch: Partial): Promise { + const key = makeKey(projectId, spaceId); const sp = await storageService.get(STORES.CHAT_SPACES, key); if (!sp) return null; const space = { ...(sp as ChatSpace) } as ChatSpace; @@ -76,8 +88,8 @@ export async function updateChatSpaceMessage(spaceId: string, messageId: string, return updated; } -export async function updateChatSpaceSelectedFiles(spaceId: string, selectedFiles: string[]): Promise { - const key = makeKey(spaceId); +export async function updateChatSpaceSelectedFiles(projectId: string, spaceId: string, selectedFiles: string[]): Promise { + const key = makeKey(projectId, spaceId); const sp = await storageService.get(STORES.CHAT_SPACES, key); if (!sp) return; const space = { ...(sp as ChatSpace) } as ChatSpace; @@ -87,6 +99,43 @@ export async function updateChatSpaceSelectedFiles(spaceId: string, selectedFile } export async function saveChatSpace(space: ChatSpace): Promise { - const key = makeKey(space.id); + if (!space.projectId || !space.id) { + throw new Error('ChatSpace must have projectId and id'); + } + const key = makeKey(space.projectId, space.id); + await storageService.set(STORES.CHAT_SPACES, key, space, { cache: false }); +} + +export async function getChatSpace(projectId: string, spaceId: string): Promise { + const key = makeKey(projectId, spaceId); + const sp = await storageService.get(STORES.CHAT_SPACES, key); + if (!sp) return null; + return sp as ChatSpace; +} + +/** + * Truncate messages in a chat space: delete the specified message and all messages after it. + * Returns the list of deleted messages (for potential rollback operations). + */ +export async function truncateMessagesFromMessage( + projectId: string, + spaceId: string, + messageId: string +): Promise { + const key = makeKey(projectId, spaceId); + const sp = await storageService.get(STORES.CHAT_SPACES, key); + if (!sp) return []; + + const space = { ...(sp as ChatSpace) } as ChatSpace; + const idx = space.messages.findIndex(m => m.id === messageId); + + if (idx === -1) return []; + + const deletedMessages = space.messages.slice(idx); + space.messages = space.messages.slice(0, idx); + space.updatedAt = new Date(); + await storageService.set(STORES.CHAT_SPACES, key, space, { cache: false }); + + return deletedMessages; } diff --git a/src/engine/storage/index.ts b/src/engine/storage/index.ts index 96cb11fc..800b17b7 100644 --- a/src/engine/storage/index.ts +++ b/src/engine/storage/index.ts @@ -18,7 +18,7 @@ */ const DB_NAME = 'pyxis-global'; -const DB_VERSION = 4; // add CHAT_SPACES and AI_REVIEWS stores +const DB_VERSION = 5; // Breaking change: ChatSpace key format changed to include projectId /** * ストアの定義 diff --git a/src/engine/tabs/builtins/DiffTabType.tsx b/src/engine/tabs/builtins/DiffTabType.tsx index b0daf7d2..67d88d6d 100644 --- a/src/engine/tabs/builtins/DiffTabType.tsx +++ b/src/engine/tabs/builtins/DiffTabType.tsx @@ -5,18 +5,32 @@ import { TabTypeDefinition, DiffTab, TabComponentProps } from '../types'; import { useGitContext } from '@/components/PaneContainer'; import DiffTabComponent from '@/components/Tab/DiffTab'; -import { useProject } from '@/engine/core/project'; -import { useTabStore } from '@/stores/tabStore'; +import { fileRepository } from '@/engine/core/fileRepository'; import { useKeyBinding } from '@/hooks/useKeyBindings'; +import { useSettings } from '@/hooks/useSettings'; +import { getCurrentProjectId, useProjectStore } from '@/stores/projectStore'; +import { useTabStore } from '@/stores/tabStore'; /** * Diffタブのコンポーネント + * + * NOTE: NEW-ARCHITECTURE.mdに従い、ファイル操作はfileRepositoryを直接使用。 + * useProject()フックは各コンポーネントで独立した状態を持つため、 + * currentProjectがnullになりファイルが保存されない問題があった。 + * 代わりにグローバルなprojectStoreからプロジェクトIDを取得する。 */ const DiffTabRenderer: React.FC = ({ tab }) => { const diffTab = tab as DiffTab; - const updateTab = useTabStore(state => state.updateTab); - const { saveFile, currentProject } = useProject(); // ← currentProject も取得 + const updateTabContent = useTabStore(state => state.updateTabContent); const { setGitRefreshTrigger } = useGitContext(); + + // グローバルストアからプロジェクト情報を取得 + const currentProject = useProjectStore(state => state.currentProject); + const projectId = currentProject?.id; + + // ユーザー設定からwordWrap設定を取得 + const { settings } = useSettings(projectId); + const wordWrapConfig = settings?.editor?.wordWrap ? 'on' : 'off'; // 保存タイマーの管理 const saveTimeoutRef = useRef(null); @@ -41,13 +55,10 @@ const DiffTabRenderer: React.FC = ({ tab }) => { return; } - if (!saveFile) { - console.error('[DiffTabType] saveFile is undefined'); - return; - } - - if (!currentProject) { - console.error('[DiffTabType] No current project'); + // グローバルストアからプロジェクトIDを取得 + const projectId = getCurrentProjectId(); + if (!projectId) { + console.error('[DiffTabType] No project ID available'); return; } @@ -66,14 +77,16 @@ const DiffTabRenderer: React.FC = ({ tab }) => { }); try { - await saveFile(diffTab.path, contentToSave); - updateTab(diffTab.paneId, diffTab.id, { isDirty: false } as Partial); + // fileRepositoryを直接使用してファイルを保存(NEW-ARCHITECTURE.mdに従う) + await fileRepository.saveFileByPath(projectId, diffTab.path, contentToSave); + // 保存後は全タブのisDirtyをクリア + updateTabContent(diffTab.id, contentToSave, false); setGitRefreshTrigger(prev => prev + 1); console.log('[DiffTabType] ✓ Immediate save completed'); } catch (error) { console.error('[DiffTabType] Immediate save failed:', error); } - }, [diffTab, saveFile, currentProject, updateTab, setGitRefreshTrigger]); + }, [diffTab.editable, diffTab.path, diffTab.diffs, diffTab.id, updateTabContent, setGitRefreshTrigger]); // Ctrl+S バインディング useKeyBinding( @@ -86,27 +99,18 @@ const DiffTabRenderer: React.FC = ({ tab }) => { // 最新のコンテンツを保存 latestContentRef.current = content; - // 即座にコンテンツを更新(isDirtyをtrue) - if (diffTab.diffs.length > 0) { - const updatedDiffs = [...diffTab.diffs]; - updatedDiffs[0] = { - ...updatedDiffs[0], - latterContent: content, - }; - updateTab(diffTab.paneId, diffTab.id, { - diffs: updatedDiffs, - isDirty: true, - } as Partial); - } - }, [diffTab, updateTab]); + // 即座に同じパスを持つ全タブのコンテンツを更新(isDirtyをtrue) + updateTabContent(diffTab.id, content, true); + }, [diffTab.id, updateTabContent]); const handleContentChange = useCallback(async (content: string) => { - if (!diffTab.editable || !diffTab.path || !saveFile || !currentProject) { + // グローバルストアからプロジェクトIDを取得 + const projectId = getCurrentProjectId(); + if (!diffTab.editable || !diffTab.path || !projectId) { console.log('[DiffTabType] Debounced save skipped:', { editable: diffTab.editable, path: diffTab.path, - hasSaveFile: !!saveFile, - hasProject: !!currentProject, + hasProjectId: !!projectId, }); return; } @@ -119,21 +123,31 @@ const DiffTabRenderer: React.FC = ({ tab }) => { saveTimeoutRef.current = setTimeout(async () => { console.log('[DiffTabType] Executing debounced save'); + // 保存時点で再度プロジェクトIDを取得(変更されている可能性があるため) + const currentProjectId = getCurrentProjectId(); + if (!currentProjectId) { + console.error('[DiffTabType] No project ID at save time'); + return; + } + try { - await saveFile(diffTab.path!, content); - updateTab(diffTab.paneId, diffTab.id, { isDirty: false } as Partial); + // fileRepositoryを直接使用してファイルを保存(NEW-ARCHITECTURE.mdに従う) + await fileRepository.saveFileByPath(currentProjectId, diffTab.path!, content); + // 保存後は全タブのisDirtyをクリア + updateTabContent(diffTab.id, content, false); setGitRefreshTrigger(prev => prev + 1); console.log('[DiffTabType] ✓ Debounced save completed'); } catch (error) { console.error('[DiffTabType] Debounced save failed:', error); } }, 5000); - }, [diffTab, saveFile, currentProject, updateTab, setGitRefreshTrigger]); + }, [diffTab.editable, diffTab.path, diffTab.id, updateTabContent, setGitRefreshTrigger]); return ( @@ -181,6 +195,27 @@ export const DiffTabType: TabTypeDefinition = { }, shouldReuseTab: (existingTab, newFile, options) => { - return existingTab.path === newFile.path && existingTab.kind === 'diff'; + const diffTab = existingTab as DiffTab; + + // 複数ファイルの場合はコミットIDで比較 + if (newFile.files && Array.isArray(newFile.files) && newFile.files.length > 1) { + const firstDiff = newFile.files[0]; + return ( + diffTab.kind === 'diff' && + diffTab.diffs.length > 1 && + diffTab.diffs[0]?.formerCommitId === firstDiff.formerCommitId && + diffTab.diffs[0]?.latterCommitId === firstDiff.latterCommitId + ); + } + + // 単一ファイルの場合はパスとコミットIDで比較 + const singleFileDiff = newFile.files ? newFile.files[0] : newFile; + return ( + diffTab.kind === 'diff' && + diffTab.diffs.length === 1 && + diffTab.diffs[0]?.formerFullPath === singleFileDiff.formerFullPath && + diffTab.diffs[0]?.formerCommitId === singleFileDiff.formerCommitId && + diffTab.diffs[0]?.latterCommitId === singleFileDiff.latterCommitId + ); }, }; diff --git a/src/engine/tabs/builtins/EditorTabType.tsx b/src/engine/tabs/builtins/EditorTabType.tsx index 78fb544d..197e568c 100644 --- a/src/engine/tabs/builtins/EditorTabType.tsx +++ b/src/engine/tabs/builtins/EditorTabType.tsx @@ -1,44 +1,61 @@ // src/engine/tabs/builtins/EditorTabType.tsx -import React from 'react'; +import React, { useCallback } from 'react'; import { TabTypeDefinition, EditorTab, TabComponentProps } from '../types'; import { useGitContext } from '@/components/PaneContainer'; import CodeEditor from '@/components/Tab/CodeEditor'; -import { useProject } from '@/engine/core/project'; +import { fileRepository } from '@/engine/core/fileRepository'; import { useSettings } from '@/hooks/useSettings'; +import { useProjectStore, getCurrentProjectId } from '@/stores/projectStore'; import { useTabStore } from '@/stores/tabStore'; /** * エディタタブのコンポーネント + * + * NOTE: NEW-ARCHITECTURE.mdに従い、ファイル操作はfileRepositoryを直接使用。 + * useProject()フックは各コンポーネントで独立した状態を持つため、 + * currentProjectがnullになりファイルが保存されない問題があった。 + * 代わりにグローバルなprojectStoreからプロジェクト情報を取得する。 */ const EditorTabComponent: React.FC = ({ tab, isActive }) => { const editorTab = tab as EditorTab; - const { saveFile, currentProject } = useProject(); - const { settings } = useSettings(currentProject?.id); + + // グローバルストアからプロジェクト情報を取得 + const currentProject = useProjectStore(state => state.currentProject); + const projectId = currentProject?.id; + + const { settings } = useSettings(projectId); const updateTabContent = useTabStore(state => state.updateTabContent); const { setGitRefreshTrigger } = useGitContext(); const wordWrapConfig = settings?.editor?.wordWrap ? 'on' : 'off'; - const handleContentChange = async (tabId: string, content: string) => { + const handleContentChange = useCallback(async (tabId: string, content: string) => { // 同一パスの全タブに対して即時フラグ(isDirty=true)を立てる updateTabContent(tabId, content, true); // ファイルを保存 - if (saveFile && editorTab.path) { - await saveFile(editorTab.path, content); - // 保存後は全タブの isDirty をクリア - updateTabContent(tabId, content, false); - // Git状態を更新 - setGitRefreshTrigger(prev => prev + 1); + // getCurrentProjectId()でその時点の最新のprojectIdを取得 + const currentProjectId = getCurrentProjectId(); + if (currentProjectId && editorTab.path) { + try { + // fileRepositoryを直接使用してファイルを保存(NEW-ARCHITECTURE.mdに従う) + await fileRepository.saveFileByPath(currentProjectId, editorTab.path, content); + // 保存後は全タブの isDirty をクリア + updateTabContent(tabId, content, false); + // Git状態を更新 + setGitRefreshTrigger(prev => prev + 1); + } catch (error) { + console.error('[EditorTabType] Failed to save file:', error); + } } - }; + }, [editorTab.path, updateTabContent, setGitRefreshTrigger]); - const handleImmediateContentChange = (tabId: string, content: string) => { + const handleImmediateContentChange = useCallback((tabId: string, content: string) => { // 即座に同一ファイルを開いている全タブの内容を更新し、isDirty を立てる updateTabContent(tabId, content, true); - }; + }, [updateTabContent]); return ( = ({ tab, isActive }) => { wordWrapConfig={wordWrapConfig} onContentChange={handleContentChange} onImmediateContentChange={handleImmediateContentChange} + isActive={isActive} /> ); }; diff --git a/src/engine/tabs/builtins/PreviewTabType.tsx b/src/engine/tabs/builtins/PreviewTabType.tsx index cf3eb101..0c3395d8 100644 --- a/src/engine/tabs/builtins/PreviewTabType.tsx +++ b/src/engine/tabs/builtins/PreviewTabType.tsx @@ -4,15 +4,18 @@ import React from 'react'; import { TabTypeDefinition, TabComponentProps, OpenTabOptions, PreviewTab } from '../types'; import MarkdownPreviewTab from '@/components/Tab/MarkdownPreviewTab'; -import { useProject } from '@/engine/core/project'; +import { useProjectStore } from '@/stores/projectStore'; import type { FileItem } from '@/types'; /** * プレビュータブのコンポーネント + * + * NOTE: useProject()は各コンポーネントで独立したステートを持つため、 + * グローバルなprojectStoreからプロジェクト情報を取得する。 */ const PreviewTabComponent: React.FC = ({ tab }) => { const previewTab = tab as PreviewTab; - const { currentProject } = useProject(); + const currentProject = useProjectStore(state => state.currentProject); return ( ([]); + const [streamingContent, setStreamingContent] = useState(''); // storage adapter for AI review metadata // import dynamically to avoid circular deps in some build setups @@ -89,7 +91,7 @@ export function useAI(props?: UseAIProps) { [props?.onAddMessage] ); - // メッセージを送信(Ask/Edit統合) + // メッセージを送信(Ask/Edit統合)- ストリーミング対応 const sendMessage = useCallback( async (content: string, mode: 'ask' | 'edit'): Promise => { const apiKey = localStorage.getItem(LOCALSTORAGE_KEY.GEMINI_API_KEY); @@ -107,31 +109,52 @@ export function useAI(props?: UseAIProps) { selectedFiles.map(f => f.path) ); - // 過去メッセージから type, content, mode のみ抽出 + // 過去メッセージから必要な情報のみ抽出(editResponseも含めてプロンプト最適化に使用) const previousMessages = props?.messages ?.filter(msg => typeof msg.content === 'string' && msg.content.trim().length > 0) ?.map(msg => ({ type: msg.type, content: msg.content, mode: msg.mode, + editResponse: msg.editResponse, // プロンプト最適化用 })); setIsProcessing(true); + setStreamingContent(''); + try { - if (mode === 'ask') { - // Ask モード - const prompt = ASK_PROMPT_TEMPLATE(selectedFiles, content, previousMessages); - const response = await generateChatResponse(prompt, [], apiKey); + // Get custom instructions if available + const customInstructions = getCustomInstructions(fileContexts); - await addMessage(response, 'assistant', 'ask'); + if (mode === 'ask') { + // Ask モード - ストリーミング + const prompt = ASK_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); + let fullResponse = ''; + + await streamChatResponse(prompt, [], apiKey, (chunk) => { + fullResponse += chunk; + setStreamingContent(fullResponse); + }); + + // ストリーミング完了後、最終メッセージを追加 + await addMessage(fullResponse, 'assistant', 'ask'); + setStreamingContent(''); return null; } else { - // Edit モード - const prompt = EDIT_PROMPT_TEMPLATE(selectedFiles, content, previousMessages); - const response = await generateCodeEdit(prompt, apiKey); + // Edit モード - ストリーミング + const prompt = EDIT_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); + let fullResponse = ''; + + await streamCodeEdit(prompt, apiKey, (chunk) => { + fullResponse += chunk; + setStreamingContent(fullResponse); + }); + + // ストリーミング完了後、レスポンスをパース + console.log('[useAI] Full streamed response:', fullResponse); // レスポンスのバリデーション - const validation = validateResponse(response); + const validation = validateResponse(fullResponse); if (!validation.isValid) { console.warn('[useAI] Response validation errors:', validation.errors); } @@ -140,7 +163,7 @@ export function useAI(props?: UseAIProps) { } // レスポンスをパース - const responsePaths = extractFilePathsFromResponse(response); + const responsePaths = extractFilePathsFromResponse(fullResponse); console.log( '[useAI] Selected files:', selectedFiles.map(f => ({ path: f.path, contentLength: f.content.length })) @@ -153,20 +176,47 @@ export function useAI(props?: UseAIProps) { console.log('[useAI] New paths (not in selected):', newPaths); - const allOriginalFiles = [ - ...selectedFiles, - ...newPaths.map((path: string) => ({ - path, - content: '', // 新規ファイルまたは未選択ファイルは空 - })), + // Fetch actual content for files not in selectedFiles from the repository + const newFilesWithContent = await Promise.all( + newPaths.map(async (path: string) => { + try { + if (props?.projectId) { + await fileRepository.init(); + const file = await fileRepository.getFileByPath(props.projectId, path); + if (file && file.content) { + console.log('[useAI] Fetched existing file content for:', path); + return { path, content: file.content, isNewFile: false }; + } + } + } catch (e) { + console.warn('[useAI] Could not fetch file content for:', path, e); + } + // This is a new file that will be created + return { path, content: '', isNewFile: true }; + }) + ); + + // Define proper type for file objects with isNewFile + interface OriginalFileWithMeta { + path: string; + content: string; + isNewFile: boolean; + } + + const allOriginalFiles: OriginalFileWithMeta[] = [ + ...selectedFiles.map(f => ({ path: f.path, content: f.content, isNewFile: false })), + ...newFilesWithContent, ]; + // Create a map of paths to isNewFile status + const newFileMap = new Map(allOriginalFiles.map(f => [f.path, f.isNewFile])); + console.log( '[useAI] All original files for parsing:', - allOriginalFiles.map(f => ({ path: f.path, contentLength: f.content.length })) + allOriginalFiles.map(f => ({ path: f.path, contentLength: f.content.length, isNewFile: f.isNewFile })) ); - const parseResult = parseEditResponse(response, allOriginalFiles); + const parseResult = parseEditResponse(fullResponse, allOriginalFiles); console.log( '[useAI] Parse result:', @@ -177,18 +227,24 @@ export function useAI(props?: UseAIProps) { })) ); - // AIEditResponse形式に変換 + // AIEditResponse形式に変換 (add isNewFile flag for each file) const editResponse: AIEditResponse = { - changedFiles: parseResult.changedFiles, + changedFiles: parseResult.changedFiles.map(f => ({ + ...f, + isNewFile: newFileMap.get(f.path) || false, + })), message: parseResult.message, }; // 詳細メッセージを生成 let detailedMessage = editResponse.message; if (editResponse.changedFiles.length > 0) { - detailedMessage = `編集が完了しました!\n\n**変更されたファイル:** ${editResponse.changedFiles.length}個\n\n`; + const usedPatch = parseResult.usedPatchFormat; + const formatNote = usedPatch ? ' (using patch format)' : ''; + detailedMessage = `Edit complete!${formatNote}\n\n**Changed files:** ${editResponse.changedFiles.length}\n\n`; editResponse.changedFiles.forEach((file, index) => { - detailedMessage += `${index + 1}. **${file.path}**\n`; + const newLabel = file.isNewFile ? ' (new)' : ''; + detailedMessage += `${index + 1}. **${file.path}**${newLabel}\n`; if (file.explanation) { detailedMessage += ` - ${file.explanation}\n`; } @@ -199,6 +255,7 @@ export function useAI(props?: UseAIProps) { // Append assistant edit message and capture returned message (so we know its id) const assistantMsg = await addMessage(detailedMessage, 'assistant', 'edit', [], editResponse); + setStreamingContent(''); // Persist AI review metadata / snapshots using storage adapter when projectId provided try { @@ -219,14 +276,15 @@ export function useAI(props?: UseAIProps) { return editResponse; } } catch (error) { - const errorMessage = `エラーが発生しました: ${(error as Error).message}`; + const errorMessage = `Error: ${(error as Error).message}`; await addMessage(errorMessage, 'assistant', mode); + setStreamingContent(''); throw error; } finally { setIsProcessing(false); } }, - [fileContexts, addMessage, props?.messages] + [fileContexts, addMessage, props?.messages, props?.projectId, aiStorage] ); // ファイルコンテキストを更新 @@ -261,6 +319,36 @@ export function useAI(props?: UseAIProps) { [props?.onUpdateSelectedFiles] ); + /** + * Generate the AI prompt text for debugging purposes without actually sending to the API. + * Useful for inspecting what prompt would be sent to the AI model. + * @param content - The user's input message + * @param mode - The current mode ('ask' for questions, 'edit' for code editing) + * @returns The full prompt text that would be sent to the AI + */ + const generatePromptText = useCallback( + (content: string, mode: 'ask' | 'edit'): string => { + const selectedFiles = getSelectedFileContexts(fileContexts); + const customInstructions = getCustomInstructions(fileContexts); + + const previousMessages = props?.messages + ?.filter(msg => typeof msg.content === 'string' && msg.content.trim().length > 0) + ?.map(msg => ({ + type: msg.type, + content: msg.content, + mode: msg.mode, + editResponse: msg.editResponse, + })); + + if (mode === 'ask') { + return ASK_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); + } else { + return EDIT_PROMPT_TEMPLATE(selectedFiles, content, previousMessages, customInstructions); + } + }, + [fileContexts, props?.messages] + ); + return { messages: props?.messages || [], isProcessing, @@ -268,5 +356,7 @@ export function useAI(props?: UseAIProps) { sendMessage, updateFileContexts, toggleFileSelection, + generatePromptText, + streamingContent, }; } diff --git a/src/hooks/ai/useChatSpace.ts b/src/hooks/ai/useChatSpace.ts index 98962051..092f0009 100644 --- a/src/hooks/ai/useChatSpace.ts +++ b/src/hooks/ai/useChatSpace.ts @@ -1,35 +1,56 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; -import { projectDB } from '@/engine/core/database'; import type { ChatSpace, ChatSpaceMessage, AIEditResponse } from '@/types'; -import * as chatStore from '@/engine/storage/chatStorageAdapter'; +import { + getChatSpaces, + createChatSpace, + deleteChatSpace, + renameChatSpace, + addMessageToChatSpace, + updateChatSpaceMessage, + updateChatSpaceSelectedFiles, + saveChatSpace, + truncateMessagesFromMessage, +} from '@/engine/storage/chatStorageAdapter'; export const useChatSpace = (projectId: string | null) => { const [chatSpaces, setChatSpaces] = useState([]); const [currentSpace, setCurrentSpace] = useState(null); const [loading, setLoading] = useState(false); + + const currentSpaceRef = useRef(null); + const projectIdRef = useRef(projectId); + + useEffect(() => { + projectIdRef.current = projectId; + }, [projectId]); + + useEffect(() => { + currentSpaceRef.current = currentSpace; + }, [currentSpace]); - // プロジェクトが変更されたときにチャットスペースを読み込み useEffect(() => { const loadChatSpaces = async () => { if (!projectId) { setChatSpaces([]); setCurrentSpace(null); + currentSpaceRef.current = null; return; } setLoading(true); try { - const spaces = await chatStore.getChatSpaces(projectId); + const spaces = await getChatSpaces(projectId); setChatSpaces(spaces); - // 最新のスペースを自動選択(存在する場合) if (spaces.length > 0) { setCurrentSpace(spaces[0]); + currentSpaceRef.current = spaces[0]; } else { setCurrentSpace(null); + currentSpaceRef.current = null; } } catch (error) { console.error('Failed to load chat spaces:', error); @@ -41,15 +62,16 @@ export const useChatSpace = (projectId: string | null) => { loadChatSpaces(); }, [projectId]); - // 新規チャットスペースがあればそれを開き、なければ新規作成 const createNewSpace = async (name?: string): Promise => { - if (!projectId) return null; + const pid = projectIdRef.current; + if (!pid) return null; try { - const spaces = await chatStore.getChatSpaces(projectId); + const spaces = await getChatSpaces(pid); const spaceName = name || `新規チャット`; const existingNewChat = spaces.find(s => s.name === spaceName); if (existingNewChat) { setCurrentSpace(existingNewChat); + currentSpaceRef.current = existingNewChat; setChatSpaces([existingNewChat, ...spaces.filter(s => s.id !== existingNewChat.id)]); return existingNewChat; } @@ -62,19 +84,20 @@ export const useChatSpace = (projectId: string | null) => { toDelete = sorted.slice(0, spaces.length - 9); for (const space of toDelete) { try { - await chatStore.deleteChatSpace(space.id); + await deleteChatSpace(pid, space.id); } catch (error) { console.error('Failed to delete old chat space:', error); } } } - const newSpace = await chatStore.createChatSpace(projectId, spaceName); + const newSpace = await createChatSpace(pid, spaceName); const updatedSpaces = [ newSpace, ...spaces.filter(s => !toDelete.some((d: ChatSpace) => d.id === s.id)), ]; setChatSpaces(updatedSpaces); setCurrentSpace(newSpace); + currentSpaceRef.current = newSpace; return newSpace; } catch (error) { console.error('Failed to create chat space:', error); @@ -82,20 +105,22 @@ export const useChatSpace = (projectId: string | null) => { } }; - // チャットスペースを選択 const selectSpace = (space: ChatSpace) => { setCurrentSpace(space); + currentSpaceRef.current = space; }; - // チャットスペースを削除 const deleteSpace = async (spaceId: string) => { + const pid = projectIdRef.current; + if (!pid) return; + if (chatSpaces.length <= 1) { console.log('最後のスペースは削除できません。'); return; } try { - await chatStore.deleteChatSpace(spaceId); + await deleteChatSpace(pid, spaceId); setChatSpaces(prev => { const filtered = prev.filter(s => s.id !== spaceId); @@ -103,8 +128,10 @@ export const useChatSpace = (projectId: string | null) => { if (currentSpace?.id === spaceId) { if (filtered.length > 0) { setCurrentSpace(filtered[0]); + currentSpaceRef.current = filtered[0]; } else { setCurrentSpace(null); + currentSpaceRef.current = null; } } @@ -115,7 +142,6 @@ export const useChatSpace = (projectId: string | null) => { } }; - // メッセージを追加 const addMessage = async ( content: string, type: 'user' | 'assistant', @@ -124,8 +150,13 @@ export const useChatSpace = (projectId: string | null) => { editResponse?: AIEditResponse, options?: { parentMessageId?: string; action?: 'apply' | 'revert' | 'note' } ): Promise => { - // Ensure we have an active space. If none exists, create one automatically. - let activeSpace = currentSpace; + const pid = projectIdRef.current; + if (!pid) { + console.error('[useChatSpace] No projectId available'); + return null; + } + + let activeSpace = currentSpaceRef.current; if (!activeSpace) { console.warn('[useChatSpace] No current space available - creating a new one'); try { @@ -135,8 +166,6 @@ export const useChatSpace = (projectId: string | null) => { return null; } activeSpace = created; - // ensure state reflects the new space - setCurrentSpace(created); } catch (e) { console.error('[useChatSpace] Error creating chat space:', e); return null; @@ -151,22 +180,11 @@ export const useChatSpace = (projectId: string | null) => { content.trim().length > 0 ) { const newName = content.length > 30 ? content.slice(0, 30) + '…' : content; - await chatStore.renameChatSpace(activeSpace.id, newName); + await renameChatSpace(pid, activeSpace.id, newName); setCurrentSpace(prev => (prev ? { ...prev, name: newName } : prev)); setChatSpaces(prev => prev.map(s => (s.id === activeSpace!.id ? { ...s, name: newName } : s))); } - // NOTE: Previously we attempted to merge assistant edit responses into an - // existing assistant edit message. That caused multiple edits to overwrite - // a single message and made only one message have an editResponse (thus - // only that message showed a Revert button). To ensure each AI edit is - // independently revertable, always append a new message here. - - // default: append a new message - // Deduplicate branch messages: if a message with same parentMessageId - // and action already exists in the current space, return it instead - // of appending a duplicate. This prevents duplicate 'Applied'/'Reverted' - // notifications when multiple UI flows record the same event. if (options?.parentMessageId && options?.action) { const dup = (activeSpace.messages || []).find( m => m.parentMessageId === options.parentMessageId && m.action === options.action && m.type === type && m.mode === mode @@ -174,7 +192,7 @@ export const useChatSpace = (projectId: string | null) => { if (dup) return dup; } - const newMessage = await chatStore.addMessageToChatSpace(activeSpace.id, { + const newMessage = await addMessageToChatSpace(pid, activeSpace.id, { type, content, timestamp: new Date(), @@ -193,15 +211,6 @@ export const useChatSpace = (projectId: string | null) => { }; }); - // Debug: log the newly appended message and current message counts - try { - console.log('[useChatSpace] Appended message:', { spaceId: activeSpace.id, messageId: newMessage.id, hasEditResponse: !!newMessage.editResponse }); - const after = (activeSpace.messages || []).length + 1; - console.log('[useChatSpace] messages count after append approx:', after); - } catch (e) { - console.warn('[useChatSpace] debug log failed', e); - } - setChatSpaces(prev => { const updated = prev.map(s => s.id === activeSpace!.id ? { ...s, messages: [...s.messages, newMessage], updatedAt: new Date() } : s @@ -216,10 +225,12 @@ export const useChatSpace = (projectId: string | null) => { } }; - // メッセージを更新(外部から編集された editResponse 等を保存して state を更新) const updateChatMessage = async (spaceId: string, messageId: string, patch: Partial) => { + const pid = projectIdRef.current; + if (!pid) return null; + try { - const updated = await chatStore.updateChatSpaceMessage(spaceId, messageId, patch); + const updated = await updateChatSpaceMessage(pid, spaceId, messageId, patch); if (!updated) return null; setCurrentSpace(prev => { @@ -247,12 +258,12 @@ export const useChatSpace = (projectId: string | null) => { } }; - // 選択ファイルを更新 const updateSelectedFiles = async (selectedFiles: string[]) => { - if (!currentSpace) return; + const pid = projectIdRef.current; + if (!pid || !currentSpace) return; try { - await chatStore.updateChatSpaceSelectedFiles(currentSpace.id, selectedFiles); + await updateChatSpaceSelectedFiles(pid, currentSpace.id, selectedFiles); setCurrentSpace(prev => { if (!prev) return null; @@ -267,14 +278,13 @@ export const useChatSpace = (projectId: string | null) => { } }; - // チャットスペース名を更新 const updateSpaceName = async (spaceId: string, newName: string) => { try { const space = chatSpaces.find(s => s.id === spaceId); if (!space) return; const updatedSpace = { ...space, name: newName }; - await chatStore.saveChatSpace(updatedSpace); + await saveChatSpace(updatedSpace); setChatSpaces(prev => prev.map(s => (s.id === spaceId ? updatedSpace : s))); @@ -286,6 +296,93 @@ export const useChatSpace = (projectId: string | null) => { } }; + /** + * Revert to a specific message: delete all messages from the specified message onwards + * and return the list of deleted messages for potential rollback of AI state changes. + * + * If the target message is an AI assistant response, also delete the corresponding + * user message that prompted it (user message and AI response are a pair). + */ + const revertToMessage = async (messageId: string): Promise => { + const pid = projectIdRef.current; + const activeSpace = currentSpaceRef.current; + + if (!pid || !activeSpace) { + console.warn('[useChatSpace] No project or space available for revert'); + return []; + } + + try { + // Find the target message index + const targetIdx = activeSpace.messages.findIndex(m => m.id === messageId); + if (targetIdx === -1) { + console.warn('[useChatSpace] Target message not found for revert'); + return []; + } + + const targetMessage = activeSpace.messages[targetIdx]; + + // Determine the actual start index for deletion + // If the target is an assistant message, also include the preceding user message + let deleteFromIdx = targetIdx; + let deleteFromMessageId = messageId; + + if (targetMessage.type === 'assistant' && targetIdx > 0) { + const prevMessage = activeSpace.messages[targetIdx - 1]; + // Include the user message if it's directly before the assistant message + if (prevMessage.type === 'user') { + deleteFromIdx = targetIdx - 1; + deleteFromMessageId = prevMessage.id; + console.log('[useChatSpace] Including user message in revert:', prevMessage.id); + } + } + + const deletedMessages = await truncateMessagesFromMessage(pid, activeSpace.id, deleteFromMessageId); + + if (deletedMessages.length === 0) { + console.warn('[useChatSpace] No messages were deleted during revert'); + return []; + } + + console.log('[useChatSpace] Reverted messages:', deletedMessages.length); + + setCurrentSpace(prev => { + if (!prev) return null; + return { + ...prev, + messages: prev.messages.slice(0, deleteFromIdx), + updatedAt: new Date(), + }; + }); + + setChatSpaces(prev => + prev + .map(space => { + if (space.id !== activeSpace.id) return space; + return { + ...space, + messages: space.messages.slice(0, deleteFromIdx), + updatedAt: new Date(), + }; + }) + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + ); + + if (currentSpaceRef.current) { + currentSpaceRef.current = { + ...currentSpaceRef.current, + messages: currentSpaceRef.current.messages.slice(0, deleteFromIdx), + updatedAt: new Date(), + }; + } + + return deletedMessages; + } catch (error) { + console.error('[useChatSpace] Failed to revert to message:', error); + return []; + } + }; + return { chatSpaces, currentSpace, @@ -297,5 +394,6 @@ export const useChatSpace = (projectId: string | null) => { updateSelectedFiles, updateSpaceName, updateChatMessage, + revertToMessage, }; }; diff --git a/src/hooks/defaultKeybindings.ts b/src/hooks/defaultKeybindings.ts index ee78a599..66b46e77 100644 --- a/src/hooks/defaultKeybindings.ts +++ b/src/hooks/defaultKeybindings.ts @@ -24,21 +24,31 @@ export const DEFAULT_BINDINGS: Binding[] = [ // Tab management { id: 'closeTab', name: 'Close Tab', combo: 'Ctrl+Shift+Q', category: 'tab' }, { id: 'nextTab', name: 'Next Tab', combo: 'Ctrl+E', category: 'tab' }, + { id: 'prevTab', name: 'Previous Tab', combo: 'Ctrl+Shift+E', category: 'tab' }, + { id: 'moveTabToNextPane', name: 'Move Tab to Next Pane', combo: 'Ctrl+K M', category: 'tab' }, // Git - { id: 'openGit', name: 'Open Git Panel', combo: 'Ctrl+Shift+G', category: 'git' }, + { id: 'openGit', name: 'Open Git Panel', combo: 'Ctrl+Shift+H', category: 'git' }, // Execution { id: 'runFile', name: 'Open Run Panel', combo: 'Ctrl+Shift+R', category: 'execution' }, { id: 'openTerminal', name: 'Open Terminal', combo: 'Ctrl+@', category: 'execution' }, { id: 'runSelection', name: 'Run Selection', combo: 'Ctrl+Alt+R', category: 'execution' }, // Additional Pyxis-specific / useful editor shortcuts - { id: 'togglePreview', name: 'Toggle Preview', combo: 'Ctrl+K V', category: 'view' }, + // { id: 'togglePreview', name: 'Toggle Preview', combo: 'Ctrl+K O', category: 'view' }, // Open markdown preview in another pane (split or random other pane) { id: 'openMdPreview', name: 'Open Markdown Preview in Other Pane', combo: 'Ctrl+K P', category: 'view' }, // Tabs - { id: 'removeAllTabs', name: 'Close All Tabs', combo: 'Ctrl+K A', category: 'tab' }, + { id: 'removeAllTabs', name: 'Close All Tabs', combo: 'Ctrl+K W', category: 'tab' }, + + // Pane management + { id: 'openPaneNavigator', name: 'Open Pane Navigator', combo: 'Ctrl+M', category: 'pane' }, + { id: 'splitPaneVertical', name: 'Split Pane Vertical', combo: 'Ctrl+K V', category: 'pane' }, + { id: 'splitPaneHorizontal', name: 'Split Pane Horizontal', combo: 'Ctrl+K S', category: 'pane' }, + { id: 'closePane', name: 'Close Current Pane', combo: 'Ctrl+K D', category: 'pane' }, + { id: 'focusNextPane', name: 'Focus Next Pane', combo: 'Ctrl+K L', category: 'pane' }, + { id: 'focusPrevPane', name: 'Focus Previous Pane', combo: 'Ctrl+K H', category: 'pane' }, // Project { id: 'openProject', name: 'Open Project', combo: 'Ctrl+Shift+O', category: 'project' }, diff --git a/src/hooks/useDiffTabHandlers.ts b/src/hooks/useDiffTabHandlers.ts index 6775f6ff..5f0eba68 100644 --- a/src/hooks/useDiffTabHandlers.ts +++ b/src/hooks/useDiffTabHandlers.ts @@ -85,7 +85,7 @@ export function useDiffTabHandlers(currentProject: any) { files: diffData, editable: editable ?? true, }, - { kind: 'diff' } + { kind: 'diff', searchAllPanesForReuse: true } ); return; } @@ -149,7 +149,7 @@ export function useDiffTabHandlers(currentProject: any) { files: diffData, editable: editable ?? false, }, - { kind: 'diff' } + { kind: 'diff', searchAllPanesForReuse: true } ); }, [currentProject, openTab] @@ -259,7 +259,7 @@ export function useDiffTabHandlers(currentProject: any) { editable: false, isMultiFile: true, }, - { kind: 'diff' } + { kind: 'diff', searchAllPanesForReuse: true } ); }, [currentProject, openTab] diff --git a/src/hooks/useGlobalScrollLock.ts b/src/hooks/useGlobalScrollLock.ts index 8ecf8311..20d5175c 100644 --- a/src/hooks/useGlobalScrollLock.ts +++ b/src/hooks/useGlobalScrollLock.ts @@ -41,6 +41,15 @@ export function useGlobalScrollLock() { useEffect(() => { if (typeof window === 'undefined') return; + // Helper to safely get className as string (handles SVG elements where className is SVGAnimatedString) + const getClassName = (el: Element): string => { + if (typeof el.className === 'string') { + return el.className; + } + // For SVG elements, className is SVGAnimatedString with baseVal property + return (el.className as unknown as { baseVal?: string })?.baseVal || ''; + }; + const isScrollable = (el: Element | null) => { let elCur: Element | null = el; while (elCur && elCur !== document.documentElement) { @@ -93,7 +102,7 @@ export function useGlobalScrollLock() { const isFromEditor = (el: Element | null) => { let cur = el; while (cur && cur !== document.documentElement) { - const cls = (cur.className || '') as string; + const cls = getClassName(cur); const id = (cur.id || '') as string; const role = cur.getAttribute && cur.getAttribute('role'); if ( @@ -123,7 +132,7 @@ export function useGlobalScrollLock() { const isFromEditor = (el: Element | null) => { let cur = el; while (cur && cur !== document.documentElement) { - const cls = (cur.className || '') as string; + const cls = getClassName(cur); const id = (cur.id || '') as string; const role = cur.getAttribute && cur.getAttribute('role'); if ( @@ -153,7 +162,7 @@ export function useGlobalScrollLock() { // allow editors/minimap to handle touch scrolls let cur = target; while (cur && cur !== document.documentElement) { - const cls = (cur.className || '') as string; + const cls = getClassName(cur); const id = (cur.id || '') as string; if (cls.includes('monaco') || cls.includes('minimap') || id.includes('monaco')) { return; diff --git a/src/hooks/usePaneResize.ts b/src/hooks/usePaneResize.ts new file mode 100644 index 00000000..c4e0df16 --- /dev/null +++ b/src/hooks/usePaneResize.ts @@ -0,0 +1,152 @@ +import { useCallback, useRef, useEffect } from 'react'; + +type Direction = 'horizontal' | 'vertical'; + +interface UsePaneResizeOptions { + direction: Direction; + leftSize: number; + minSize?: number; + onResize: (leftPercent: number, rightPercent: number) => void; + containerRef: React.RefObject; +} + +interface ResizeState { + isResizing: boolean; + containerStart: number; + containerSize: number; +} + +/** + * ペイン間リサイズ用フック + * パーセンテージベースで2つの隣接ペインのサイズを調整 + */ +export function usePaneResize(options: UsePaneResizeOptions) { + const { + direction, + leftSize, + minSize = 10, + onResize, + containerRef, + } = options; + + const stateRef = useRef({ + isResizing: false, + containerStart: 0, + containerSize: 0, + }); + + // Store handlers in refs for cleanup + const mouseMoveHandler = useRef<((e: MouseEvent) => void) | null>(null); + const mouseUpHandler = useRef<(() => void) | null>(null); + const touchMoveHandler = useRef<((e: TouchEvent) => void) | null>(null); + const touchEndHandler = useRef<(() => void) | null>(null); + + const handleStop = useCallback((setIsDragging?: (v: boolean) => void) => { + const state = stateRef.current; + if (!state.isResizing) return; + + state.isResizing = false; + setIsDragging?.(false); + + // Remove listeners + if (mouseMoveHandler.current) { + document.removeEventListener('mousemove', mouseMoveHandler.current); + } + if (mouseUpHandler.current) { + document.removeEventListener('mouseup', mouseUpHandler.current); + } + if (touchMoveHandler.current) { + document.removeEventListener('touchmove', touchMoveHandler.current); + } + if (touchEndHandler.current) { + document.removeEventListener('touchend', touchEndHandler.current); + } + + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + handleStop(); + }; + }, [handleStop]); + + const startResize = useCallback(( + e: React.MouseEvent | React.TouchEvent, + setIsDragging?: (v: boolean) => void + ) => { + e.preventDefault(); + e.stopPropagation(); + + // Find parent flex container (with max depth limit to prevent infinite loops) + const MAX_DEPTH = 20; + let parentContainer = containerRef.current?.parentElement; + let depth = 0; + while (parentContainer && !parentContainer.classList.contains('flex') && depth < MAX_DEPTH) { + parentContainer = parentContainer.parentElement; + depth++; + } + if (!parentContainer || depth >= MAX_DEPTH) return; + + const containerRect = parentContainer.getBoundingClientRect(); + const state = stateRef.current; + state.isResizing = true; + state.containerStart = direction === 'vertical' ? containerRect.left : containerRect.top; + state.containerSize = direction === 'vertical' ? containerRect.width : containerRect.height; + + setIsDragging?.(true); + + const handleMove = (clientX: number, clientY: number) => { + const currentPos = direction === 'vertical' ? clientX : clientY; + const relativePos = currentPos - state.containerStart; + + // Calculate min boundary in pixels (extracted for readability) + const minBoundaryPx = (minSize * state.containerSize) / 100; + + // Calculate new split position + const newSplitPos = Math.max( + minBoundaryPx, + Math.min(relativePos, state.containerSize - minBoundaryPx) + ); + + // Convert to percentage + const newLeftPercent = (newSplitPos / state.containerSize) * 100; + const newRightPercent = 100 - newLeftPercent; + + // Apply if within bounds + if (newLeftPercent >= minSize && newRightPercent >= minSize) { + onResize(newLeftPercent, newRightPercent); + } + }; + + // Create handlers + mouseMoveHandler.current = (e: MouseEvent) => { + e.preventDefault(); + handleMove(e.clientX, e.clientY); + }; + + mouseUpHandler.current = () => handleStop(setIsDragging); + + touchMoveHandler.current = (e: TouchEvent) => { + e.preventDefault(); + const touch = e.touches[0]; + handleMove(touch.clientX, touch.clientY); + }; + + touchEndHandler.current = () => handleStop(setIsDragging); + + // Add listeners + document.addEventListener('mousemove', mouseMoveHandler.current); + document.addEventListener('mouseup', mouseUpHandler.current); + document.addEventListener('touchmove', touchMoveHandler.current, { passive: false }); + document.addEventListener('touchend', touchEndHandler.current); + document.body.style.cursor = direction === 'vertical' ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + }, [direction, minSize, onResize, containerRef, handleStop]); + + return { startResize }; +} + +export default usePaneResize; diff --git a/src/hooks/useResize.ts b/src/hooks/useResize.ts new file mode 100644 index 00000000..e0b44c19 --- /dev/null +++ b/src/hooks/useResize.ts @@ -0,0 +1,187 @@ +import { useCallback, useRef, useEffect } from 'react'; + +type Direction = 'horizontal' | 'vertical'; + +interface UseResizeOptions { + direction: Direction; + initialSize: number; + minSize?: number; + maxSize?: number; + onResize: (newSize: number) => void; + /** Optional: selector to directly update DOM element during drag for better performance */ + targetSelector?: string; +} + +interface ResizeState { + isResizing: boolean; + startPos: number; + initialSize: number; + rafId: number | null; + currentSize: number; +} + +/** Calculate default max size based on direction and window dimensions */ +function getDefaultMaxSize(direction: Direction): number { + if (typeof window === 'undefined') return 1000; + const dimension = direction === 'horizontal' ? window.innerHeight : window.innerWidth; + return dimension * 0.7; +} + +/** + * 汎用リサイズフック - マウスとタッチの両方に対応 + * + * 従来の個別リサイズフック(useLeftSidebarResize, useRightSidebarResize, useBottomPanelResize)を + * 1つの汎用フックに統合し、コードの重複を排除 + */ +export function useResize(options: UseResizeOptions) { + const { + direction, + initialSize, + minSize = 100, + maxSize = getDefaultMaxSize(direction), + onResize, + targetSelector, + } = options; + + const stateRef = useRef({ + isResizing: false, + startPos: 0, + initialSize, + rafId: null, + currentSize: initialSize, + }); + + // Clean up any pending animation frame on unmount + useEffect(() => { + return () => { + if (stateRef.current.rafId !== null) { + cancelAnimationFrame(stateRef.current.rafId); + } + }; + }, []); + + const handleMove = useCallback((clientX: number, clientY: number, isInverted: boolean = false) => { + const state = stateRef.current; + if (!state.isResizing) return; + + const currentPos = direction === 'horizontal' ? clientY : clientX; + const delta = isInverted + ? state.startPos - currentPos + : currentPos - state.startPos; + + const newSize = state.initialSize + delta; + const clampedSize = Math.max(minSize, Math.min(maxSize, newSize)); + + state.currentSize = clampedSize; + + // Cancel previous frame and schedule new one + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + } + + state.rafId = requestAnimationFrame(() => { + onResize(state.currentSize); + + // Direct DOM update for better performance during drag + if (targetSelector) { + const element = document.querySelector(targetSelector) as HTMLElement; + if (element) { + if (direction === 'horizontal') { + element.style.height = `${state.currentSize}px`; + } else { + element.style.width = `${state.currentSize}px`; + } + } + } + }); + }, [direction, minSize, maxSize, onResize, targetSelector]); + + const handleEnd = useCallback(() => { + const state = stateRef.current; + if (!state.isResizing) return; + + state.isResizing = false; + + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + state.rafId = null; + } + + // Reset body styles + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.body.style.touchAction = ''; + }, []); + + // Create stable event handlers that will be registered/removed + const mouseMoveHandler = useRef<((e: MouseEvent) => void) | null>(null); + const mouseUpHandler = useRef<(() => void) | null>(null); + const touchMoveHandler = useRef<((e: TouchEvent) => void) | null>(null); + const touchEndHandler = useRef<(() => void) | null>(null); + + const startResize = useCallback(( + e: React.MouseEvent | React.TouchEvent, + isInverted: boolean = false + ) => { + e.preventDefault(); + + const isTouch = 'touches' in e; + const startPos = isTouch + ? (direction === 'horizontal' ? e.touches[0].clientY : e.touches[0].clientX) + : (direction === 'horizontal' ? e.clientY : e.clientX); + + const state = stateRef.current; + state.isResizing = true; + state.startPos = startPos; + state.initialSize = initialSize; + state.currentSize = initialSize; + + // Set body styles + document.body.style.cursor = direction === 'horizontal' ? 'row-resize' : 'col-resize'; + document.body.style.userSelect = 'none'; + document.body.style.touchAction = 'none'; + + // Create handlers with closure over isInverted + mouseMoveHandler.current = (e: MouseEvent) => { + e.preventDefault(); + handleMove(e.clientX, e.clientY, isInverted); + }; + + mouseUpHandler.current = () => { + handleEnd(); + // Remove listeners + document.removeEventListener('mousemove', mouseMoveHandler.current!); + document.removeEventListener('mouseup', mouseUpHandler.current!); + }; + + touchMoveHandler.current = (e: TouchEvent) => { + e.preventDefault(); + const touch = e.touches[0]; + handleMove(touch.clientX, touch.clientY, isInverted); + }; + + touchEndHandler.current = () => { + handleEnd(); + // Remove listeners + document.removeEventListener('touchmove', touchMoveHandler.current!); + document.removeEventListener('touchend', touchEndHandler.current!); + }; + + // Add event listeners + document.addEventListener('mousemove', mouseMoveHandler.current); + document.addEventListener('mouseup', mouseUpHandler.current); + document.addEventListener('touchmove', touchMoveHandler.current, { passive: false }); + document.addEventListener('touchend', touchEndHandler.current); + }, [direction, initialSize, handleMove, handleEnd]); + + return { + startResize, + /** For right sidebar where drag direction is inverted */ + startResizeInverted: useCallback( + (e: React.MouseEvent | React.TouchEvent) => startResize(e, true), + [startResize] + ), + }; +} + +export default useResize; diff --git a/src/hooks/useTabContentRestore.ts b/src/hooks/useTabContentRestore.ts index 74508da7..8d30d932 100644 --- a/src/hooks/useTabContentRestore.ts +++ b/src/hooks/useTabContentRestore.ts @@ -175,17 +175,16 @@ export function useTabContentRestore(projectFiles: FileItem[], isRestored: boole performContentRestoration(); }, [performContentRestoration]); - // 2. ファイル変更イベントのリスニング(devブランチと同じロジック) + // 2. ファイル変更イベントのリスニング useEffect(() => { if (!isRestored) { return; } const unsubscribe = fileRepository.addChangeListener(event => { - // console.log('[useTabContentRestore] File change event:', event); - - // 削除イベントはスキップ(TabBarで処理) + // 削除イベント: tabStoreに委譲 if (event.type === 'delete') { + store.handleFileDeleted(event.file.path); return; } diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts new file mode 100644 index 00000000..64a21ac2 --- /dev/null +++ b/src/stores/projectStore.ts @@ -0,0 +1,54 @@ +// src/stores/projectStore.ts +/** + * プロジェクト状態のグローバルストア + * + * NOTE: useProject()フックは各コンポーネントで独立したステートを持つため、 + * currentProjectがnullになりファイルが保存されない問題があった(PR130で発見)。 + * + * このストアは現在のプロジェクトIDとプロジェクト情報をグローバルに管理し、 + * 全てのコンポーネントが一貫したプロジェクト情報にアクセスできるようにする。 + * + * page.tsxでuseProject()を使用してプロジェクトをロードした際に、 + * このストアも同期的に更新される。 + */ + +import { create } from 'zustand'; + +import { Project } from '@/types'; + +interface ProjectStore { + // 現在のプロジェクト + currentProject: Project | null; + currentProjectId: string | null; + + // アクション + setCurrentProject: (project: Project | null) => void; +} + +export const useProjectStore = create((set) => ({ + currentProject: null, + currentProjectId: null, + + setCurrentProject: (project: Project | null) => { + set({ + currentProject: project, + currentProjectId: project?.id || null, + }); + }, +})); + +/** + * コンポーネント外からプロジェクトIDを取得するユーティリティ + * コールバック関数内など、フック外でプロジェクトIDが必要な場合に使用 + */ +export const getCurrentProjectId = (): string | null => { + return useProjectStore.getState().currentProjectId; +}; + +/** + * コンポーネント外から現在のプロジェクトを取得するユーティリティ + * コールバック関数内など、フック外でプロジェクト情報が必要な場合に使用 + */ +export const getCurrentProject = (): Project | null => { + return useProjectStore.getState().currentProject; +}; diff --git a/src/stores/tabStore.ts b/src/stores/tabStore.ts index 89b1d579..7f82560f 100644 --- a/src/stores/tabStore.ts +++ b/src/stores/tabStore.ts @@ -2,7 +2,19 @@ import { create } from 'zustand'; import { tabRegistry } from '@/engine/tabs/TabRegistry'; -import { EditorPane, Tab, OpenTabOptions } from '@/engine/tabs/types'; +import { EditorPane, Tab, OpenTabOptions, DiffTab } from '@/engine/tabs/types'; + +// Helper function to flatten all leaf panes (preserving order for pane index priority) +function flattenLeafPanes(panes: EditorPane[], result: EditorPane[] = []): EditorPane[] { + for (const p of panes) { + if (!p.children || p.children.length === 0) { + result.push(p); + } else { + flattenLeafPanes(p.children, result); + } + } + return result; +} interface TabStore { // ペイン管理 @@ -30,6 +42,12 @@ interface TabStore { tabId: string, side: 'before' | 'after' ) => void; + splitPaneAndOpenFile: ( + paneId: string, + direction: 'horizontal' | 'vertical', + file: any, + side: 'before' | 'after' + ) => void; resizePane: (paneId: string, newSize: number) => void; // タブ操作 @@ -46,6 +64,9 @@ interface TabStore { getTab: (paneId: string, tabId: string) => Tab | null; getAllTabs: () => Tab[]; findTabByPath: (path: string, kind?: string) => { paneId: string; tab: Tab } | null; + + // ファイル削除時のタブ処理 + handleFileDeleted: (deletedPath: string) => void; // セッション管理 saveSession: () => Promise; @@ -262,9 +283,6 @@ export const useTabStore = create((set, get) => ({ return; } - // タブIDの生成 - const tabId = kind !== 'editor' ? `${kind}:${file.path || file.name}` : file.path || file.name; - // 既存タブの検索 const pane = state.getPane(targetPaneId); if (!pane) { @@ -272,44 +290,94 @@ export const useTabStore = create((set, get) => ({ return; } - const existingTab = pane.tabs.find(t => { - // 同じkindとpathのタブを検索 - return t.kind === kind && (t.path === file.path || t.id === tabId); - }); - - if (existingTab) { - // 既存タブをアクティブ化 - if (options.makeActive !== false) { - get().activateTab(targetPaneId, existingTab.id); + // shouldReuseTabがある場合の検索 + if (tabDef.shouldReuseTab) { + // searchAllPanesForReuseがtrueの場合、全ペインを検索(paneIndexが小さいペインを優先) + if (options.searchAllPanesForReuse) { + const allLeafPanes = flattenLeafPanes(state.panes); + + for (const searchPane of allLeafPanes) { + for (const tab of searchPane.tabs) { + if (tab.kind === kind && tabDef.shouldReuseTab(tab, file, options)) { + // 既存タブをアクティブ化 + if (options.makeActive !== false) { + get().activateTab(searchPane.id, tab.id); + } + console.log('[TabStore] Reusing existing tab via shouldReuseTab (all panes):', tab.id, 'in pane:', searchPane.id); + return; + } + } + } + // 全ペインで見つからなかった場合は新規タブを作成 + } else { + // 従来の動作:targetPane内でのみカスタム検索を行う + for (const tab of pane.tabs) { + if (tab.kind === kind && tabDef.shouldReuseTab(tab, file, options)) { + // 既存タブをアクティブ化 + if (options.makeActive !== false) { + get().activateTab(targetPaneId, tab.id); + } + console.log('[TabStore] Reusing existing tab via shouldReuseTab:', tab.id); + return; + } + } + // shouldReuseTabで見つからなかった場合は新規タブを作成(通常検索はスキップ) } + } else { + // shouldReuseTabがない場合は、通常の検索(パス/IDベース) + const tabId = kind !== 'editor' ? `${kind}:${file.path || file.name}` : file.path || file.name; + const existingTab = pane.tabs.find(t => { + // 同じkindとpathのタブを検索 + return t.kind === kind && (t.path === file.path || t.id === tabId); + }); - // jumpToLine/jumpToColumnがある場合は更新 - if (options.jumpToLine !== undefined || options.jumpToColumn !== undefined) { - get().updateTab(targetPaneId, existingTab.id, { - jumpToLine: options.jumpToLine, - jumpToColumn: options.jumpToColumn, - } as Partial); - } + if (existingTab) { + // 既存タブをアクティブ化 + if (options.makeActive !== false) { + get().activateTab(targetPaneId, existingTab.id); + } - return; + // jumpToLine/jumpToColumnがある場合は更新 + if (options.jumpToLine !== undefined || options.jumpToColumn !== undefined) { + get().updateTab(targetPaneId, existingTab.id, { + jumpToLine: options.jumpToLine, + jumpToColumn: options.jumpToColumn, + } as Partial); + } + + return; + } } // 新規タブの作成 const newTab = tabDef.createTab(file, { ...options, paneId: targetPaneId }); - // ペインにタブを追加 - get().updatePane(targetPaneId, { - tabs: [...pane.tabs, newTab], - activeTabId: options.makeActive !== false ? newTab.id : pane.activeTabId, - }); + // ペインにタブを追加し、グローバルアクティブタブも同時に更新 + // 別々のset呼び出しではなく、1つの更新で原子的に行うことで + // 状態の不整合を防ぐ + const updatePaneRecursive = (panes: EditorPane[]): EditorPane[] => { + return panes.map(p => { + if (p.id === targetPaneId) { + return { + ...p, + tabs: [...p.tabs, newTab], + activeTabId: options.makeActive !== false ? newTab.id : p.activeTabId, + }; + } + if (p.children) { + return { ...p, children: updatePaneRecursive(p.children) }; + } + return p; + }); + }; - // グローバルアクティブタブを更新 - if (options.makeActive !== false) { - set({ + set(state => ({ + panes: updatePaneRecursive(state.panes), + ...(options.makeActive !== false ? { globalActiveTab: newTab.id, activePane: targetPaneId, - }); - } + } : {}), + })); }, closeTab: (paneId, tabId) => { @@ -349,12 +417,25 @@ export const useTabStore = create((set, get) => ({ }, activateTab: (paneId, tabId) => { - const state = get(); - get().updatePane(paneId, { activeTabId: tabId }); - set({ + // ペインのactiveTabIdとグローバル状態を同時に更新 + // 別々のset呼び出しだと状態の不整合が発生し、フォーカスが正しく当たらない + const updatePaneRecursive = (panes: EditorPane[]): EditorPane[] => { + return panes.map(p => { + if (p.id === paneId) { + return { ...p, activeTabId: tabId }; + } + if (p.children) { + return { ...p, children: updatePaneRecursive(p.children) }; + } + return p; + }); + }; + + set(state => ({ + panes: updatePaneRecursive(state.panes), globalActiveTab: tabId, activePane: paneId, - }); + })); }, updateTab: (paneId: string, tabId: string, updates: Partial) => { @@ -498,6 +579,72 @@ export const useTabStore = create((set, get) => ({ return findInPanes(state.panes); }, + // ファイル削除時のタブ処理: editor/previewを閉じ、diffはコンテンツを空にする + handleFileDeleted: (deletedPath: string) => { + const state = get(); + + // パスを正規化 + const normalizePath = (p?: string): string => { + if (!p) return ''; + const withoutKindPrefix = p.includes(':') ? p.replace(/^[^:]+:/, '') : p; + const cleaned = withoutKindPrefix.replace(/(-preview|-diff|-ai)$/, ''); + return cleaned.startsWith('/') ? cleaned : `/${cleaned}`; + }; + + const normalizedDeletedPath = normalizePath(deletedPath); + console.log('[TabStore] handleFileDeleted:', normalizedDeletedPath); + + // 閉じるタブを収集 + const tabsToClose: Array<{ paneId: string; tabId: string }> = []; + + // ペインを再帰的に更新 + const updatePaneRecursive = (panes: EditorPane[]): EditorPane[] => { + return panes.map(pane => { + if (pane.children && pane.children.length > 0) { + return { ...pane, children: updatePaneRecursive(pane.children) }; + } + + // リーフペイン + const newTabs = pane.tabs.map((tab: Tab) => { + const tabPath = normalizePath(tab.path); + + // editor/previewは閉じる対象として記録 + if ((tab.kind === 'editor' || tab.kind === 'preview') && tabPath === normalizedDeletedPath) { + tabsToClose.push({ paneId: pane.id, tabId: tab.id }); + return tab; + } + + // 編集可能なdiffタブ(ワーキングディレクトリとの差分)のみコンテンツを空にする + // readonlyのdiffタブ(過去のcommit間の差分)は変更不要 + if (tab.kind === 'diff' && tabPath === normalizedDeletedPath) { + const diffTab = tab as DiffTab; + if (diffTab.editable) { + return { + ...diffTab, + diffs: diffTab.diffs.map(diff => ({ + ...diff, + latterContent: '', + })), + }; + } + } + + return tab; + }); + + return { ...pane, tabs: newTabs }; + }); + }; + + // diffタブのコンテンツを更新 + set({ panes: updatePaneRecursive(state.panes) }); + + // editor/previewタブを閉じる + for (const { paneId, tabId } of tabsToClose) { + get().closeTab(paneId, tabId); + } + }, + splitPane: (paneId, direction) => { const state = get(); const targetPane = state.getPane(paneId); @@ -691,41 +838,163 @@ export const useTabStore = create((set, get) => ({ }); }, + splitPaneAndOpenFile: (paneId, direction, file, side) => { + const state = get(); + const targetPane = state.getPane(paneId); + if (!targetPane) return; + + // 既存のペインIDを収集 + const getAllPaneIds = (panes: EditorPane[]): string[] => { + const ids: string[] = []; + const traverse = (panes: EditorPane[]) => { + panes.forEach(pane => { + ids.push(pane.id); + if (pane.children) traverse(pane.children); + }); + }; + traverse(panes); + return ids; + }; + + const existingIds = getAllPaneIds(state.panes); + let nextNum = 1; + while (existingIds.includes(`pane-${nextNum}`)) { + nextNum++; + } + const newPaneId = `pane-${nextNum}`; + const existingPaneId = `pane-${nextNum + 1}`; + + // ファイル用の新しいタブを作成 + const defaultEditor = typeof window !== 'undefined' ? localStorage.getItem('pyxis-defaultEditor') : 'monaco'; + const kind = file.isBufferArray ? 'binary' : 'editor'; + const newTabId = `${file.path || file.name}-${Date.now()}`; + const newTab: Tab = { + id: newTabId, + name: file.name, + path: file.path, + kind, + paneId: newPaneId, + content: file.content || '', + isDirty: false, + isCodeMirror: defaultEditor === 'codemirror', + }; + + // 再帰的にペインを更新 + const updatePaneRecursive = (panes: EditorPane[]): EditorPane[] => { + return panes.map(pane => { + if (pane.id === paneId) { + // 既存のタブのpaneIdを更新 + const existingTabs = pane.tabs.map(tab => ({ + ...tab, + paneId: existingPaneId + })); + + const pane1 = { + id: existingPaneId, + tabs: existingTabs, + activeTabId: pane.activeTabId, + parentId: paneId, + size: 50, + }; + + const pane2 = { + id: newPaneId, + tabs: [newTab], + activeTabId: newTabId, + parentId: paneId, + size: 50, + }; + + return { + ...pane, + layout: direction, + children: side === 'before' ? [pane2, pane1] : [pane1, pane2], + tabs: [], + activeTabId: '', + }; + } + + if (pane.children) { + return { ...pane, children: updatePaneRecursive(pane.children) }; + } + return pane; + }); + }; + + const newPanes = updatePaneRecursive(state.panes); + set({ + panes: newPanes, + activePane: newPaneId, + globalActiveTab: newTabId + }); + }, + resizePane: (paneId, newSize) => { get().updatePane(paneId, { size: newSize }); }, + // タブのコンテンツを同期更新(同じパスを持つ全てのEditorTab + DiffTabを更新) updateTabContent: (tabId: string, content: string, immediate = false) => { const allTabs = get().getAllTabs(); const tabInfo = allTabs.find(t => t.id === tabId); if (!tabInfo) return; - // editor/preview 系のみ操作対象 - if (!(tabInfo.kind === 'editor' || tabInfo.kind === 'preview')) return; + // editor または diff 系のみ操作対象 + if (tabInfo.kind !== 'editor' && tabInfo.kind !== 'diff') return; const targetPath = tabInfo.path || ''; - const targetKind = tabInfo.kind; + if (!targetPath) return; + + // 変更が必要なタブがあるかチェック + let hasChanges = false; - // 全てのペインを巡回して、path と kind が一致するタブを更新 + // 全てのペインを巡回して、path が一致する editor/diff タブを更新 const updatePanesRecursive = (panes: any[]): any[] => { return panes.map((pane: any) => { + let paneChanged = false; const newTabs = pane.tabs.map((t: any) => { - if (t.path === targetPath && t.kind === targetKind) { - return { ...t, content, isDirty: immediate ? true : false }; + // editor タブの更新 + if (t.kind === 'editor' && t.path === targetPath) { + if (t.content === content && t.isDirty === immediate) { + return t; // コンテンツが同じ場合はスキップ + } + paneChanged = true; + hasChanges = true; + return { ...t, content, isDirty: immediate }; + } + // DiffTabの更新(リアルタイム同期) + if (t.kind === 'diff' && t.path === targetPath && t.diffs && t.diffs.length > 0) { + if (t.diffs[0].latterContent === content && t.isDirty === immediate) { + return t; // コンテンツが同じ場合はスキップ + } + const updatedDiffs = [...t.diffs]; + updatedDiffs[0] = { + ...updatedDiffs[0], + latterContent: content, + }; + paneChanged = true; + hasChanges = true; + return { ...t, diffs: updatedDiffs, isDirty: immediate }; } return t; }); if (pane.children) { - return { ...pane, tabs: newTabs, children: updatePanesRecursive(pane.children) }; + const newChildren = updatePanesRecursive(pane.children); + if (paneChanged || newChildren !== pane.children) { + return { ...pane, tabs: newTabs, children: newChildren }; + } } - return { ...pane, tabs: newTabs }; + return paneChanged ? { ...pane, tabs: newTabs } : pane; }); }; - set(state => ({ panes: updatePanesRecursive(state.panes) })); + const newPanes = updatePanesRecursive(get().panes); + if (hasChanges) { + set({ panes: newPanes }); + } }, saveSession: async () => { diff --git a/src/tests/gitignore.integration.test.ts b/src/tests/gitignore.integration.test.ts new file mode 100644 index 00000000..7cbb300d --- /dev/null +++ b/src/tests/gitignore.integration.test.ts @@ -0,0 +1,236 @@ +/** + * Integration test for .gitignore functionality + * + * This test verifies that: + * 1. All files (including ignored ones) are stored in IndexedDB + * 2. Only non-ignored files are synced to lightning-fs + * 3. .gitignore rules are correctly parsed and applied + */ + +import { parseGitignore, isPathIgnored } from '../engine/core/gitignore'; + +describe('Gitignore Integration', () => { + describe('parseGitignore', () => { + test('parses basic ignore patterns', () => { + const content = ` +# Node modules +node_modules/ +dist/ +*.log + `.trim(); + + const rules = parseGitignore(content); + + expect(rules).toHaveLength(3); + expect(rules[0].pattern).toBe('node_modules'); + expect(rules[0].directoryOnly).toBe(true); + expect(rules[1].pattern).toBe('dist'); + expect(rules[1].directoryOnly).toBe(true); + expect(rules[2].pattern).toBe('*.log'); + }); + + test('handles negation patterns', () => { + const content = ` +*.log +!important.log + `.trim(); + + const rules = parseGitignore(content); + + expect(rules).toHaveLength(2); + expect(rules[0].negation).toBe(false); + expect(rules[1].negation).toBe(true); + expect(rules[1].pattern).toBe('important.log'); + }); + + test('handles anchored patterns', () => { + const content = ` +/build +src/temp/ + `.trim(); + + const rules = parseGitignore(content); + + expect(rules).toHaveLength(2); + expect(rules[0].anchored).toBe(true); + expect(rules[0].pattern).toBe('build'); + expect(rules[1].anchored).toBe(false); + expect(rules[1].hasSlash).toBe(true); + }); + + test('ignores comments and empty lines', () => { + const content = ` +# This is a comment + +node_modules/ + +# Another comment +*.log + `.trim(); + + const rules = parseGitignore(content); + + expect(rules).toHaveLength(2); + }); + }); + + describe('isPathIgnored', () => { + test('matches directory-only patterns', () => { + const rules = parseGitignore('node_modules/'); + + expect(isPathIgnored(rules, 'node_modules', true)).toBe(true); + expect(isPathIgnored(rules, 'node_modules/react/index.js', false)).toBe(true); + expect(isPathIgnored(rules, 'src/node_modules/test.js', false)).toBe(true); + }); + + test('matches wildcard patterns', () => { + const rules = parseGitignore('*.log'); + + expect(isPathIgnored(rules, 'error.log', false)).toBe(true); + expect(isPathIgnored(rules, 'src/debug.log', false)).toBe(true); + expect(isPathIgnored(rules, 'test.txt', false)).toBe(false); + }); + + test('matches anchored patterns', () => { + const rules = parseGitignore('/build'); + + expect(isPathIgnored(rules, 'build', false)).toBe(true); + expect(isPathIgnored(rules, 'build/index.html', false)).toBe(true); + expect(isPathIgnored(rules, 'src/build', false)).toBe(false); + }); + + test('matches patterns with slashes', () => { + const rules = parseGitignore('src/temp/'); + + expect(isPathIgnored(rules, 'src/temp', true)).toBe(true); + expect(isPathIgnored(rules, 'src/temp/cache.dat', false)).toBe(true); + expect(isPathIgnored(rules, 'temp', false)).toBe(false); + }); + + test('handles negation patterns', () => { + const content = ` +*.log +!important.log + `.trim(); + const rules = parseGitignore(content); + + expect(isPathIgnored(rules, 'error.log', false)).toBe(true); + expect(isPathIgnored(rules, 'important.log', false)).toBe(false); + }); + + test('matches double-asterisk patterns', () => { + const rules = parseGitignore('**/dist'); + + expect(isPathIgnored(rules, 'dist', false)).toBe(true); + expect(isPathIgnored(rules, 'packages/app/dist', false)).toBe(true); + expect(isPathIgnored(rules, 'packages/app/dist/index.js', false)).toBe(true); + }); + + test('complex real-world example', () => { + const content = ` +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +/coverage + +# Production +/build +/dist + +# Misc +.DS_Store +*.log +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + `.trim(); + + const rules = parseGitignore(content); + + // Dependencies should be ignored + expect(isPathIgnored(rules, 'node_modules/react/index.js', false)).toBe(true); + expect(isPathIgnored(rules, '.pnp.js', false)).toBe(true); + + // Coverage directory (anchored) + expect(isPathIgnored(rules, 'coverage/index.html', false)).toBe(true); + expect(isPathIgnored(rules, 'src/coverage/test.js', false)).toBe(false); + + // Build directories + expect(isPathIgnored(rules, 'build/app.js', false)).toBe(true); + expect(isPathIgnored(rules, 'dist/bundle.js', false)).toBe(true); + + // Misc files + expect(isPathIgnored(rules, '.DS_Store', false)).toBe(true); + expect(isPathIgnored(rules, 'error.log', false)).toBe(true); + expect(isPathIgnored(rules, '.env.local', false)).toBe(true); + + // IDE files + expect(isPathIgnored(rules, '.vscode/settings.json', false)).toBe(true); + expect(isPathIgnored(rules, 'temp.swp', false)).toBe(true); + + // Should NOT be ignored + expect(isPathIgnored(rules, 'src/index.ts', false)).toBe(false); + expect(isPathIgnored(rules, 'package.json', false)).toBe(false); + expect(isPathIgnored(rules, 'README.md', false)).toBe(false); + }); + }); + + describe('Architecture Verification', () => { + test('documents expected behavior of two-layer architecture', () => { + // This test serves as documentation of the intended architecture + + const gitignoreContent = 'node_modules/'; + const rules = parseGitignore(gitignoreContent); + + // In the two-layer architecture: + + // 1. IndexedDB stores ALL files (including node_modules) + // - This is necessary for Node.js Runtime module resolution + // - This is necessary for file tree display + // - This is necessary for search functionality + const allFilesInIndexedDB = [ + '/package.json', + '/src/index.ts', + '/node_modules/react/index.js', // ✅ Stored in IndexedDB + '/node_modules/react/package.json', // ✅ Stored in IndexedDB + ]; + + // 2. lightning-fs only receives files NOT ignored by .gitignore + // - This keeps Git operations fast + // - This prevents bloating the Git working directory + // + // Note: The path normalization (removing leading slashes) matches the behavior + // in fileRepository.ts shouldIgnorePathForGit() which uses the same normalization + // before calling isPathIgnored() + const filesInLightningFS = allFilesInIndexedDB.filter(path => { + // Same normalization as in fileRepository.ts:721 + const normalizedPath = path.replace(/^\/+/, ''); + return !isPathIgnored(rules, normalizedPath, false); + }); + + expect(filesInLightningFS).toEqual([ + '/package.json', + '/src/index.ts', + // node_modules files are NOT synced to lightning-fs + ]); + + // 3. This is the CORRECT and INTENDED behavior + // - NOT a bug + // - NOT unnecessary duplication + // - Both layers serve different purposes + + expect(allFilesInIndexedDB.length).toBe(4); // All files in IndexedDB + expect(filesInLightningFS.length).toBe(2); // Only non-ignored in lightning-fs + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 5e3ad5fd..6e88b5ab 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -100,6 +100,8 @@ export interface AIEditResponse { originalContent: string; suggestedContent: string; explanation: string; + applied?: boolean; // Track if this change has been applied to file + isNewFile?: boolean; // Track if this is a new file created by AI (for revert: delete instead of restore empty) }>; message: string; } diff --git a/tests/aiMultiPatch.test.ts b/tests/aiMultiPatch.test.ts new file mode 100644 index 00000000..b3368213 --- /dev/null +++ b/tests/aiMultiPatch.test.ts @@ -0,0 +1,573 @@ +/** + * Tests for AI Response Parser with Multi-Patch Support + * + * Tests both the new SEARCH/REPLACE format and legacy format compatibility. + */ + +import { + parseEditResponse, + extractFilePathsFromResponse, + extractFileBlocks, + extractReasons, + cleanupMessage, + validateResponse, + normalizePath, +} from '@/engine/ai/responseParser'; + +describe('normalizePath', () => { + it('should remove leading and trailing slashes', () => { + expect(normalizePath('/src/test.ts')).toBe('src/test.ts'); + expect(normalizePath('src/test.ts/')).toBe('src/test.ts'); + expect(normalizePath('/src/test.ts/')).toBe('src/test.ts'); + }); + + it('should convert to lowercase', () => { + expect(normalizePath('Src/Test.TS')).toBe('src/test.ts'); + }); +}); + +describe('extractFilePathsFromResponse', () => { + it('should extract single file path from patch format', () => { + const response = `### File: src/test.ts +**Reason**: Test change + +\`\`\` +<<<<<<< SEARCH +old +======= +new +>>>>>>> REPLACE +\`\`\``; + expect(extractFilePathsFromResponse(response)).toContain('src/test.ts'); + }); + + it('should extract multiple file paths from patch format', () => { + const response = `### File: src/a.ts +**Reason**: Change A + +<<<<<<< SEARCH +old a +======= +new a +>>>>>>> REPLACE + +### File: src/b.ts +**Reason**: Change B + +<<<<<<< SEARCH +old b +======= +new b +>>>>>>> REPLACE`; + const paths = extractFilePathsFromResponse(response); + expect(paths).toContain('src/a.ts'); + expect(paths).toContain('src/b.ts'); + }); + + it('should extract single file path from legacy format', () => { + const response = ` +content +`; + expect(extractFilePathsFromResponse(response)).toContain('src/test.ts'); + }); + + it('should extract multiple file paths from legacy format', () => { + const response = ` +content + + +content +`; + const paths = extractFilePathsFromResponse(response); + expect(paths).toContain('src/a.ts'); + expect(paths).toContain('src/b.ts'); + }); + + it('should handle duplicate paths', () => { + const response = `### File: src/test.ts +<<<<<<< SEARCH +a +======= +b +>>>>>>> REPLACE + +### File: src/test.ts +<<<<<<< SEARCH +c +======= +d +>>>>>>> REPLACE`; + const paths = extractFilePathsFromResponse(response); + expect(paths.filter(p => p === 'src/test.ts').length).toBe(1); + }); + + it('should handle empty response', () => { + expect(extractFilePathsFromResponse('')).toEqual([]); + }); +}); + +describe('extractFileBlocks (legacy)', () => { + it('should extract complete block', () => { + const response = ` +const x = 1; +`; + const blocks = extractFileBlocks(response); + expect(blocks.length).toBe(1); + expect(blocks[0].path).toBe('src/test.ts'); + expect(blocks[0].content).toBe('const x = 1;'); + }); + + it('should handle multiple blocks', () => { + const response = ` +content a + + +content b +`; + const blocks = extractFileBlocks(response); + expect(blocks.length).toBe(2); + expect(blocks[0].content).toBe('content a'); + expect(blocks[1].content).toBe('content b'); + }); + + it('should handle multiline content', () => { + const response = ` +function test() { + return 42; +} +`; + const blocks = extractFileBlocks(response); + expect(blocks[0].content).toContain('function test()'); + expect(blocks[0].content).toContain('return 42;'); + }); + + it('should handle empty content', () => { + const response = ` + +`; + const blocks = extractFileBlocks(response); + expect(blocks.length).toBe(1); + expect(blocks[0].content).toBe(''); + }); +}); + +describe('extractReasons', () => { + it('should extract reason from patch format', () => { + const response = `### File: src/test.ts +**Reason**: Test change + +<<<<<<< SEARCH`; + const reasons = extractReasons(response); + expect(reasons.get('src/test.ts')).toBe('Test change'); + }); + + it('should extract reason from legacy format', () => { + const response = `## Changed File: src/test.ts + +**Reason**: Legacy test change + +`; + const reasons = extractReasons(response); + expect(reasons.get('src/test.ts')).toBe('Legacy test change'); + }); + + it('should extract multiple reasons', () => { + const response = `### File: src/a.ts +**Reason**: Feature A + +<<<<<<< SEARCH +### File: src/b.ts +**Reason**: Feature B + +<<<<<<< SEARCH`; + const reasons = extractReasons(response); + expect(reasons.get('src/a.ts')).toBe('Feature A'); + expect(reasons.get('src/b.ts')).toBe('Feature B'); + }); + + it('should handle Japanese format', () => { + const response = `## 変更ファイル: src/test.ts + +**変更理由**: テスト変更 + +`; + const reasons = extractReasons(response); + expect(reasons.get('src/test.ts')).toBe('テスト変更'); + }); +}); + +describe('cleanupMessage', () => { + it('should remove SEARCH/REPLACE blocks', () => { + const response = `Message +<<<<<<< SEARCH +old +======= +new +>>>>>>> REPLACE +More message`; + expect(cleanupMessage(response)).toBe('Message\n\nMore message'); + }); + + it('should remove NEW_FILE blocks', () => { + const response = `Message +<<<<<<< NEW_FILE +content +>>>>>>> NEW_FILE +More`; + expect(cleanupMessage(response)).toBe('Message\n\nMore'); + }); + + it('should remove legacy file blocks', () => { + const response = `Message + +content + +Continue`; + expect(cleanupMessage(response)).toBe('Message\n\nContinue'); + }); + + it('should remove file headers', () => { + const response = `### File: src/test.ts +**Reason**: Test +Message`; + expect(cleanupMessage(response)).toBe('Message'); + }); + + it('should normalize multiple newlines', () => { + const response = `Message + + + +Continue`; + expect(cleanupMessage(response)).toBe('Message\n\nContinue'); + }); +}); + +describe('parseEditResponse with patch format', () => { + it('should parse single file with single patch block', () => { + const response = `### File: src/test.ts +**Reason**: Added feature + +\`\`\` +<<<<<<< SEARCH +const x = 1; +======= +const x = 2; +>>>>>>> REPLACE +\`\`\``; + + const originalFiles = [{ path: 'src/test.ts', content: 'const x = 1;\nconst y = 2;' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.usedPatchFormat).toBe(true); + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].path).toBe('src/test.ts'); + expect(result.changedFiles[0].suggestedContent).toContain('const x = 2;'); + expect(result.changedFiles[0].explanation).toBe('Added feature'); + }); + + it('should parse single file with multiple patch blocks', () => { + const response = `### File: src/test.ts +**Reason**: Multiple changes + +<<<<<<< SEARCH +const a = 1; +======= +const a = 10; +>>>>>>> REPLACE + +<<<<<<< SEARCH +const b = 2; +======= +const b = 20; +>>>>>>> REPLACE`; + + const originalFiles = [{ path: 'src/test.ts', content: 'const a = 1;\nconst b = 2;\nconst c = 3;' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.usedPatchFormat).toBe(true); + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].patchBlocks?.length).toBe(2); + expect(result.changedFiles[0].suggestedContent).toContain('const a = 10;'); + expect(result.changedFiles[0].suggestedContent).toContain('const b = 20;'); + expect(result.changedFiles[0].suggestedContent).toContain('const c = 3;'); // unchanged + }); + + it('should parse multiple files with patches', () => { + const response = `### File: src/a.ts +**Reason**: Change A + +<<<<<<< SEARCH +export const a = 1; +======= +export const a = 10; +>>>>>>> REPLACE + +### File: src/b.ts +**Reason**: Change B + +<<<<<<< SEARCH +export const b = 2; +======= +export const b = 20; +>>>>>>> REPLACE`; + + const originalFiles = [ + { path: 'src/a.ts', content: 'export const a = 1;' }, + { path: 'src/b.ts', content: 'export const b = 2;' }, + ]; + const result = parseEditResponse(response, originalFiles); + + expect(result.usedPatchFormat).toBe(true); + expect(result.changedFiles.length).toBe(2); + expect(result.changedFiles[0].path).toBe('src/a.ts'); + expect(result.changedFiles[1].path).toBe('src/b.ts'); + expect(result.changedFiles[0].suggestedContent).toContain('a = 10'); + expect(result.changedFiles[1].suggestedContent).toContain('b = 20'); + }); + + it('should handle new file creation with patch format', () => { + const response = `### File: src/new.ts +**Reason**: New file + +<<<<<<< NEW_FILE +export const newConst = 42; +>>>>>>> NEW_FILE`; + + const originalFiles: Array<{ path: string; content: string }> = []; + const result = parseEditResponse(response, originalFiles); + + expect(result.usedPatchFormat).toBe(true); + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].isNewFile).toBe(true); + expect(result.changedFiles[0].suggestedContent).toBe('export const newConst = 42;'); + }); +}); + +describe('parseEditResponse with legacy format', () => { + it('should parse single file edit', () => { + const response = `## Changed File: src/test.ts + +**Reason**: Test added + + +const x = 1; +`; + + const originalFiles = [{ path: 'src/test.ts', content: 'const x = 0;' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.usedPatchFormat).toBe(false); + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].path).toBe('src/test.ts'); + expect(result.changedFiles[0].suggestedContent).toBe('const x = 1;'); + expect(result.changedFiles[0].explanation).toBe('Test added'); + }); + + it('should parse multiple file edits', () => { + const response = `## Changed File: src/a.ts + +**Reason**: Feature A + + +content a + + +## Changed File: src/b.ts + +**Reason**: Feature B + + +content b +`; + + const originalFiles = [ + { path: 'src/a.ts', content: 'old a' }, + { path: 'src/b.ts', content: 'old b' }, + ]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles.length).toBe(2); + expect(result.changedFiles[0].path).toBe('src/a.ts'); + expect(result.changedFiles[1].path).toBe('src/b.ts'); + }); + + it('should handle case-insensitive path matching', () => { + const response = ` +content +`; + + const originalFiles = [{ path: 'src/test.ts', content: 'old' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].path).toBe('src/test.ts'); + }); + + it('should treat unknown files as new files', () => { + const response = ` +content +`; + + const originalFiles = [{ path: 'src/test.ts', content: 'old' }]; + const result = parseEditResponse(response, originalFiles); + + // Unknown files in legacy format are treated as new files + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].isNewFile).toBe(true); + }); +}); + +describe('validateResponse', () => { + it('should validate correct patch format response', () => { + const response = `### File: src/test.ts +**Reason**: Test + +<<<<<<< SEARCH +old +======= +new +>>>>>>> REPLACE`; + const validation = validateResponse(response); + + expect(validation.isValid).toBe(true); + expect(validation.errors.length).toBe(0); + }); + + it('should validate correct legacy format response', () => { + const response = ` +content +`; + const validation = validateResponse(response); + + expect(validation.isValid).toBe(true); + expect(validation.errors.length).toBe(0); + }); + + it('should detect empty response', () => { + const validation = validateResponse(''); + + expect(validation.isValid).toBe(false); + expect(validation.errors).toContain('Empty response'); + }); + + it('should detect mismatched SEARCH/REPLACE tags', () => { + const response = `<<<<<<< SEARCH +old +======= +new`; + const validation = validateResponse(response); + + expect(validation.isValid).toBe(false); + expect(validation.errors[0]).toContain('Mismatched'); + }); + + it('should detect mismatched legacy tags', () => { + const response = ` +content`; + const validation = validateResponse(response); + + expect(validation.isValid).toBe(false); + expect(validation.errors[0]).toContain('Mismatched tags'); + }); + + it('should warn when no blocks found', () => { + const response = 'Just a message with no file blocks'; + const validation = validateResponse(response); + + // Can be either "No patch blocks found" or "No file blocks found" + expect(validation.warnings.some(w => w.includes('No') && w.includes('blocks found'))).toBe(true); + }); +}); + +describe('edge cases', () => { + it('should handle very long file paths', () => { + const longPath = 'src/' + 'a/'.repeat(50) + 'test.ts'; + const response = `### File: ${longPath} +**Reason**: Test + +<<<<<<< SEARCH +old +======= +new +>>>>>>> REPLACE`; + + const originalFiles = [{ path: longPath, content: 'old content' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles.length).toBe(1); + }); + + it('should handle special characters in paths', () => { + const specialPath = 'src/test-file_v2.spec.ts'; + const response = `### File: ${specialPath} +**Reason**: Test + +<<<<<<< SEARCH +old +======= +new +>>>>>>> REPLACE`; + + const originalFiles = [{ path: specialPath, content: 'old content' }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles.length).toBe(1); + }); + + it('should handle unicode in content', () => { + const response = `### File: src/test.ts +**Reason**: Unicode test + +<<<<<<< SEARCH +const emoji = '🎉'; +======= +const emoji = '🚀🔥'; +>>>>>>> REPLACE`; + + const originalFiles = [{ path: 'src/test.ts', content: "const emoji = '🎉';" }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles[0].suggestedContent).toContain('🚀'); + }); + + it('should handle backticks in content', () => { + const response = `### File: src/test.ts +**Reason**: Template literal + +<<<<<<< SEARCH +const str = 'hello'; +======= +const str = \`hello \${name}\`; +>>>>>>> REPLACE`; + + const originalFiles = [{ path: 'src/test.ts', content: "const str = 'hello';" }]; + const result = parseEditResponse(response, originalFiles); + + expect(result.changedFiles[0].suggestedContent).toContain('${name}'); + }); +}); + +describe('mixed format handling', () => { + it('should prefer patch format when both markers present', () => { + const response = `### File: src/test.ts +**Reason**: Using patch + +<<<<<<< SEARCH +const x = 1; +======= +const x = 2; +>>>>>>> REPLACE + + +const x = 3; +`; + + const originalFiles = [{ path: 'src/test.ts', content: 'const x = 1;' }]; + const result = parseEditResponse(response, originalFiles); + + // Should use patch format + expect(result.usedPatchFormat).toBe(true); + expect(result.changedFiles[0].suggestedContent).toContain('const x = 2;'); + }); +}); diff --git a/tests/aiResponseParser.test.ts b/tests/aiResponseParser.test.ts index e3beaeb0..129748bf 100644 --- a/tests/aiResponseParser.test.ts +++ b/tests/aiResponseParser.test.ts @@ -246,7 +246,7 @@ content expect(result.changedFiles[0].path).toBe('src/test.ts'); }); - it('should ignore unknown files', () => { + it('should treat unknown files as new files', () => { const response = ` content `; @@ -254,7 +254,10 @@ content const originalFiles = [{ path: 'src/test.ts', content: 'old' }]; const result = parseEditResponse(response, originalFiles); - expect(result.changedFiles.length).toBe(0); + // Unknown files are treated as new files + expect(result.changedFiles.length).toBe(1); + expect(result.changedFiles[0].path).toBe('src/unknown.ts'); + expect(result.changedFiles[0].isNewFile).toBe(true); }); it('should provide default message when files changed', () => { @@ -265,7 +268,7 @@ content const originalFiles = [{ path: 'src/test.ts', content: 'old' }]; const result = parseEditResponse(response, originalFiles); - expect(result.message).toBe('1個のファイルの編集を提案しました。'); + expect(result.message).toBe('Suggested edits for 1 file(s).'); }); it('should preserve custom message', () => { @@ -287,7 +290,8 @@ content const result = parseEditResponse(response, originalFiles); expect(result.changedFiles.length).toBe(0); - expect(result.message).toContain('解析に失敗しました'); + expect(result.message).toContain('Failed to parse response'); + expect(result.message).toContain('SEARCH/REPLACE'); expect(result.message).toContain('Raw response:'); }); diff --git a/tests/normalizeCjsEsm.test.ts b/tests/normalizeCjsEsm.test.ts index ea1b0b59..31a7d73c 100644 --- a/tests/normalizeCjsEsm.test.ts +++ b/tests/normalizeCjsEsm.test.ts @@ -6,91 +6,91 @@ describe('normalizeCjsEsm', () => { it('import default', () => { const input = "import foo from 'bar'"; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('bar')"); - expect(out).toContain('const foo'); + expect(out.code).toContain("await __require__('bar')"); + expect(out.code).toContain('const foo'); }); it('import named', () => { const input = "import {foo, bar} from 'baz'"; - expect(normalizeCjsEsm(input)).toBe("const {foo, bar} = await __require__('baz')"); + expect(normalizeCjsEsm(input).code).toBe("const {foo, bar} = await __require__('baz')"); }); it('import * as ns', () => { const input = "import * as MathModule from './math'"; - expect(normalizeCjsEsm(input)).toBe("const MathModule = await __require__('./math')"); + expect(normalizeCjsEsm(input).code).toBe("const MathModule = await __require__('./math')"); }); it('import side effect', () => { const input = "import 'side-effect'"; - expect(normalizeCjsEsm(input)).toBe("await __require__('side-effect')"); + expect(normalizeCjsEsm(input).code).toBe("await __require__('side-effect')"); }); it('export default', () => { const input = "export default foo"; - expect(normalizeCjsEsm(input)).toBe("module.exports.default = foo"); + expect(normalizeCjsEsm(input).code).toBe("module.exports.default = foo"); }); it('export const', () => { const input = "export const foo = 1;"; - expect(normalizeCjsEsm(input)).toContain("const foo = 1;"); - expect(normalizeCjsEsm(input)).toContain("module.exports.foo = foo;"); + expect(normalizeCjsEsm(input).code).toContain("const foo = 1;"); + expect(normalizeCjsEsm(input).code).toContain("module.exports.foo = foo;"); }); it('require', () => { const input = "const x = require('y')"; - expect(normalizeCjsEsm(input)).toBe("const x = await __require__('y')"); + expect(normalizeCjsEsm(input).code).toBe("const x = await __require__('y')"); }); it('import default + named', () => { const input = "import foo, {bar, baz} from 'lib'"; // 本来は default/named両方対応だが、現状は正規表現の都合で全部一括になる const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('lib')"); - expect(out).toContain('const'); - expect(out).toContain('bar'); - expect(out).toContain('baz'); + expect(out.code).toContain("await __require__('lib')"); + expect(out.code).toContain('const'); + expect(out.code).toContain('bar'); + expect(out.code).toContain('baz'); }); it('multiple imports and exports', () => { const input = `import foo from 'a';\nimport * as ns from 'b';\nimport {x, y} from 'c';\nexport default foo;\nexport const bar = 1;`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('a')"); - expect(out).toContain("const ns = await __require__('b')"); - expect(out).toContain("const {x, y} = await __require__('c')"); - expect(out).toContain("module.exports.default = foo"); - expect(out).toContain("const bar = 1;"); - expect(out).toContain("module.exports.bar = bar;"); + expect(out.code).toContain("await __require__('a')"); + expect(out.code).toContain("const ns = await __require__('b')"); + expect(out.code).toContain("const {x, y} = await __require__('c')"); + expect(out.code).toContain("module.exports.default = foo"); + expect(out.code).toContain("const bar = 1;"); + expect(out.code).toContain("module.exports.bar = bar;"); }); it('import/require/export in one file', () => { const input = `import foo from 'a';\nconst x = require('b');\nexport default foo;`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('a')"); - expect(out).toContain("await __require__('b')"); - expect(out).toContain("module.exports.default = foo"); + expect(out.code).toContain("await __require__('a')"); + expect(out.code).toContain("await __require__('b')"); + expect(out.code).toContain("module.exports.default = foo"); }); it('export default function', () => { const input = `export default function test() {}`; - expect(normalizeCjsEsm(input)).toBe("module.exports.default = function test() {}"); + expect(normalizeCjsEsm(input).code).toBe("module.exports.default = function test() {}"); }); it('export default class', () => { const input = `export default class Test {}`; - expect(normalizeCjsEsm(input)).toBe("module.exports.default = class Test {}"); + expect(normalizeCjsEsm(input).code).toBe("module.exports.default = class Test {}"); }); it('export named function', () => { const input = `export const foo = () => {}`; const out = normalizeCjsEsm(input); - expect(out).toContain("const foo = () => {}"); - expect(out).toContain("module.exports.foo = foo;"); + expect(out.code).toContain("const foo = () => {}"); + expect(out.code).toContain("module.exports.foo = foo;"); }); it('export named class', () => { const input = `export const Foo = class {}`; const out = normalizeCjsEsm(input); - expect(out).toContain("const Foo = class {}"); - expect(out).toContain("module.exports.Foo = Foo;"); + expect(out.code).toContain("const Foo = class {}"); + expect(out.code).toContain("module.exports.Foo = Foo;"); }); it('export named function declaration', () => { const input = `export function greet() { return 'hi'; }`; const out = normalizeCjsEsm(input); - expect(out).toContain("function greet() { return 'hi'; }"); - expect(out).toContain("module.exports.greet = greet;"); + expect(out.code).toContain("function greet() { return 'hi'; }"); + expect(out.code).toContain("module.exports.greet = greet;"); }); it('export named class declaration', () => { const input = `export class Person { constructor(name){ this.name = name } }`; const out = normalizeCjsEsm(input); - expect(out).toContain("class Person { constructor(name){ this.name = name } }"); - expect(out).toContain("module.exports.Person = Person;"); + expect(out.code).toContain("class Person { constructor(name){ this.name = name } }"); + expect(out.code).toContain("module.exports.Person = Person;"); }); it('export default anonymous function/class', () => { const inputFn = `export default function() {}`; @@ -108,35 +108,35 @@ describe('normalizeCjsEsm', () => { const input = `export function outer(){ function inner(){} return inner }`; const out = normalizeCjsEsm(input); // only outer should be exported - expect(out).toContain('module.exports.outer = outer;'); - expect(out).not.toContain('module.exports.inner'); + expect(out.code).toContain('module.exports.outer = outer;'); + expect(out.code).not.toContain('module.exports.inner'); }); it('import with semicolons and whitespace', () => { const input = ` import foo from 'a' ; \n import { bar } from 'b';`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('a')"); - expect(out).toContain("await __require__('b')"); + expect(out.code).toContain("await __require__('a')"); + expect(out.code).toContain("await __require__('b')"); }); it('export list with aliases', () => { const input = `export { a as b, c }`; const out = normalizeCjsEsm(input); - expect(out).toContain('module.exports.b = a;'); - expect(out).toContain('module.exports.c = c;'); + expect(out.code).toContain('module.exports.b = a;'); + expect(out.code).toContain('module.exports.c = c;'); }); it('export from other module', () => { const input = `export { x } from 'm'`; const out = normalizeCjsEsm(input); // new behavior: module is required and named export is assigned from the imported module - expect(out).toContain("await __require__('m')"); - expect(out).toContain('module.exports.x ='); + expect(out.code).toContain("await __require__('m')"); + expect(out.code).toContain('module.exports.x ='); }); it('export const with destructuring should keep destructure and not export members', () => { const input = `export const {a, b} = obj;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const {a, b} = obj;'); + expect(out.code).toContain('const {a, b} = obj;'); // new behavior: extract identifiers from destructuring and export them - expect(out).toContain('module.exports.a = a;'); - expect(out).toContain('module.exports.b = b;'); + expect(out.code).toContain('module.exports.a = a;'); + expect(out.code).toContain('module.exports.b = b;'); }); it('export default arrow/async functions', () => { const input1 = `export default () => {}`; @@ -147,124 +147,124 @@ describe('normalizeCjsEsm', () => { it('require followed by method chain should not auto-export', () => { const input = `const x = require('y')\n .chain()`; const out = normalizeCjsEsm(input); - expect(out).toContain("const x = await __require__('y')\n .chain()"); - expect(out).not.toContain('module.exports.x = x;'); + expect(out.code).toContain("const x = await __require__('y')\n .chain()"); + expect(out.code).not.toContain('module.exports.x = x;'); }); it('import with comments and trailing comments', () => { const input = `// header\nimport foo from 'a' // trailing`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('a')"); + expect(out.code).toContain("await __require__('a')"); }); it('export multiple let declarations', () => { const input = `export let x=1, y=2;`; const out = normalizeCjsEsm(input); - expect(out).toContain('let x=1, y=2;'); - expect(out).toContain('module.exports.x = x;'); - expect(out).toContain('module.exports.y = y;'); + expect(out.code).toContain('let x=1, y=2;'); + expect(out.code).toContain('module.exports.x = x;'); + expect(out.code).toContain('module.exports.y = y;'); }); it('named import with alias', () => { const input = `import { foo as bar } from 'm'`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('m')"); - expect(out).toContain('foo as bar'); + expect(out.code).toContain("await __require__('m')"); + expect(out.code).toContain('foo as bar'); }); it('export async function remains (not handled)', () => { const input = `export async function fetchData() {}`; // current regex doesn't handle `export async function` so it remains - expect(normalizeCjsEsm(input)).toContain('export async function fetchData()'); + expect(normalizeCjsEsm(input).code).toContain('export async function fetchData()'); }); it('export star from other module is transformed to copy exports', () => { const input = `export * from 'mod'`; const out = normalizeCjsEsm(input); // should await the module and copy non-default keys to module.exports - expect(out).toContain("await __require__('mod')"); - expect(out).toMatch(/for \(const k in __rexp_[a-z0-9]+\)/); - expect(out).toContain('module.exports[k] ='); + expect(out.code).toContain("await __require__('mod')"); + expect(out.code).toMatch(/for \(const k in __rexp_[a-z0-9]+\)/); + expect(out.code).toContain('module.exports[k] ='); }); it('export interface/type remains (TS-only)', () => { const input = `export interface I { a: number }`; const out = normalizeCjsEsm(input); // runtime transform does not strip types; they remain - expect(out).toContain('export interface I { a: number }'); + expect(out.code).toContain('export interface I { a: number }'); }); it('export default as re-export from module', () => { const input = `export { default as Main } from 'lib'`; const out = normalizeCjsEsm(input); // current logic will produce assignment for alias - expect(out).toContain("await __require__('lib')"); - expect(out).toContain('module.exports.Main ='); + expect(out.code).toContain("await __require__('lib')"); + expect(out.code).toContain('module.exports.Main ='); }); it('multiline destructured export const should not export members', () => { const input = `export const {\n a,\n b\n} = obj;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const {\n a,\n b\n} = obj;'); - expect(out).toContain('module.exports.a = a;'); - expect(out).toContain('module.exports.b = b;'); + expect(out.code).toContain('const {\n a,\n b\n} = obj;'); + expect(out.code).toContain('module.exports.a = a;'); + expect(out.code).toContain('module.exports.b = b;'); }); it('template literal containing export text will be changed (regex limitation)', () => { const input = "const s = `export default foo`"; const out = normalizeCjsEsm(input); // regex-based replace does not respect strings, so export default inside template is replaced - expect(out).toContain('`module.exports.default = foo`'); + expect(out.code).toContain('`module.exports.default = foo`'); }); it('dynamic import remains untouched', () => { const input = `const mod = import('dyn')`; const out = normalizeCjsEsm(input); - expect(out).toBe(input); + expect(out.code).toBe(input); }); it('multiline import with newlines inside braces', () => { const input = `import {\n a,\n b\n} from 'm'`; const out = normalizeCjsEsm(input); - expect(out).toContain("await __require__('m')"); - expect(out).toContain('const {'); + expect(out.code).toContain("await __require__('m')"); + expect(out.code).toContain('const {'); }); it('export default wrapped in parentheses', () => { const input = `export default (function(){})`; const out = normalizeCjsEsm(input); - expect(out).toBe("module.exports.default = (function(){})"); + expect(out.code).toBe("module.exports.default = (function(){})"); }); it('typescript enum remains (not touched)', () => { const input = `export enum E { A, B }`; const out = normalizeCjsEsm(input); - expect(out).toContain('export enum E { A, B }'); + expect(out.code).toContain('export enum E { A, B }'); }); it('export with comments inside braces', () => { const input = `export { /*a*/ a as b /*c*/, d }`; const out = normalizeCjsEsm(input); // comment content is preserved/ignored by regex capture; assignments should exist - expect(out).toContain('module.exports.b = a;'); - expect(out).toContain('module.exports.d = d;'); + expect(out.code).toContain('module.exports.b = a;'); + expect(out.code).toContain('module.exports.d = d;'); }); it('require followed by property access should not export', () => { const input = `const x = require('y').prop`; const out = normalizeCjsEsm(input); // require(...) is replaced but then const auto-export logic skips because value begins with await __require__ - expect(out).toContain("const x = await __require__('y').prop"); - expect(out).not.toContain('module.exports.x'); + expect(out.code).toContain("const x = await __require__('y').prop"); + expect(out.code).not.toContain('module.exports.x'); }); it('export default class extends', () => { const input = `export default class A extends B {}`; const out = normalizeCjsEsm(input); - expect(out).toBe("module.exports.default = class A extends B {}"); + expect(out.code).toBe("module.exports.default = class A extends B {}"); }); it('export default generator function', () => { const input = `export default function* gen(){}`; const out = normalizeCjsEsm(input); - expect(out).toBe("module.exports.default = function* gen(){}"); + expect(out.code).toBe("module.exports.default = function* gen(){}"); }); it('export var with trailing comma and comments', () => { const input = `export var x = 1, /*c*/ y = 2,`; const out = normalizeCjsEsm(input); - expect(out).toContain('var x = 1, /*c*/ y = 2,'); - expect(out).toContain('module.exports.x = x;'); - expect(out).toContain('module.exports.y = y;'); + expect(out.code).toContain('var x = 1, /*c*/ y = 2,'); + expect(out.code).toContain('module.exports.x = x;'); + expect(out.code).toContain('module.exports.y = y;'); }); it('template nested export-like text will be transformed', () => { const input = "const t = `prefix export const x = 1; suffix`"; const out = normalizeCjsEsm(input); // regex-based replace will touch text inside template literals and may also // trigger auto-export for the surrounding const `t` and the inner const `x`. - expect(out).toContain('const x = 1;'); + expect(out.code).toContain('const x = 1;'); // auto-export has been disabled for non-explicit top-level declarations, // so we only assert that the inner snippet was transformed; no automatic // module.exports for `t` or `x` should be expected. @@ -273,18 +273,18 @@ describe('normalizeCjsEsm', () => { const input = "const r = /export default/;"; const out = normalizeCjsEsm(input); // regex literals are not protected by the replacer; outer const may be auto-exported - expect(out).toContain('/export default/'); + expect(out.code).toContain('/export default/'); // auto-export disabled: do not expect module.exports for `r` anymore }); it('commented export default is also transformed', () => { const input = '/* export default foo */'; const out = normalizeCjsEsm(input); - expect(out).toContain('/* module.exports.default = foo */'); + expect(out.code).toContain('/* module.exports.default = foo */'); }); it('export with trailing comma in braces', () => { const input = `export { a, }`; const out = normalizeCjsEsm(input); - expect(out).toContain('module.exports.a = a;'); + expect(out.code).toContain('module.exports.a = a;'); }); it('multi-line chain auto-export', () => { const input = `const x = maker()\n .one()\n .two()`; @@ -299,50 +299,50 @@ describe('normalizeCjsEsm', () => { it('ts export equals remains', () => { const input = `export = something`; const out = normalizeCjsEsm(input); - expect(out).toContain('export = something'); + expect(out.code).toContain('export = something'); }); it('export default object literal preserved', () => { const input = `export default { a: function(){}, b: 2 }`; const out = normalizeCjsEsm(input); - expect(out).toContain('module.exports.default = { a: function(){}, b: 2 }'); + expect(out.code).toContain('module.exports.default = { a: function(){}, b: 2 }'); }); it('complex nested destructuring with arrays/objects/rest/defaults', () => { const input = `export const { a: [{ b, c: [d ] }], e: { f = 3, g: { h } }, ...rest } = src;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const { a: [{ b, c: [d ] }], e: { f = 3, g: { h } }, ...rest } = src;'); - expect(out).toContain('module.exports.b = b;'); - expect(out).toContain('module.exports.d = d;'); - expect(out).toContain('module.exports.f = f;'); - expect(out).toContain('module.exports.h = h;'); - expect(out).toContain('module.exports.rest = rest;'); + expect(out.code).toContain('const { a: [{ b, c: [d ] }], e: { f = 3, g: { h } }, ...rest } = src;'); + expect(out.code).toContain('module.exports.b = b;'); + expect(out.code).toContain('module.exports.d = d;'); + expect(out.code).toContain('module.exports.f = f;'); + expect(out.code).toContain('module.exports.h = h;'); + expect(out.code).toContain('module.exports.rest = rest;'); }); it('computed property key and rest object', () => { const input = `export const { [key]: k, ...r } = obj;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const { [key]: k, ...r } = obj;'); - expect(out).toContain('module.exports.k = k;'); - expect(out).toContain('module.exports.r = r;'); + expect(out.code).toContain('const { [key]: k, ...r } = obj;'); + expect(out.code).toContain('module.exports.k = k;'); + expect(out.code).toContain('module.exports.r = r;'); }); it('nested array destructuring with defaults and rest', () => { const input = `export const [ , , third = fn(), [x = 1, ...y] ] = arr;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const [ , , third = fn(), [x = 1, ...y] ] = arr;'); - expect(out).toContain('module.exports.third = third;'); - expect(out).toContain('module.exports.x = x;'); - expect(out).toContain('module.exports.y = y;'); + expect(out.code).toContain('const [ , , third = fn(), [x = 1, ...y] ] = arr;'); + expect(out.code).toContain('module.exports.third = third;'); + expect(out.code).toContain('module.exports.x = x;'); + expect(out.code).toContain('module.exports.y = y;'); }); it('destructuring with default objects and arrays containing commas/braces', () => { const input = `export const { x = { y: 1, z: 2 }, w = [1,2,3] } = obj;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const { x = { y: 1, z: 2 }, w = [1,2,3] } = obj;'); - expect(out).toContain('module.exports.x = x;'); - expect(out).toContain('module.exports.w = w;'); + expect(out.code).toContain('const { x = { y: 1, z: 2 }, w = [1,2,3] } = obj;'); + expect(out.code).toContain('module.exports.x = x;'); + expect(out.code).toContain('module.exports.w = w;'); }); it('alias with default and nested object', () => { const input = `export const { a: aa = defaultVal, b: { c: cc = 2 } } = obj;`; const out = normalizeCjsEsm(input); - expect(out).toContain('const { a: aa = defaultVal, b: { c: cc = 2 } } = obj;'); - expect(out).toContain('module.exports.aa = aa;'); - expect(out).toContain('module.exports.cc = cc;'); + expect(out.code).toContain('const { a: aa = defaultVal, b: { c: cc = 2 } } = obj;'); + expect(out.code).toContain('module.exports.aa = aa;'); + expect(out.code).toContain('module.exports.cc = cc;'); }); }); diff --git a/tests/patchApplier.test.ts b/tests/patchApplier.test.ts new file mode 100644 index 00000000..fb4e9b11 --- /dev/null +++ b/tests/patchApplier.test.ts @@ -0,0 +1,714 @@ +/** + * Comprehensive tests for the multi-patch AI editing system + * + * Tests cover: + * - Single SEARCH/REPLACE block application + * - Multiple SEARCH/REPLACE blocks in one file + * - Fuzzy matching for whitespace differences + * - New file creation + * - Edge cases and error handling + * - Legacy format compatibility + */ + +import { + applySearchReplaceBlock, + applyMultipleBlocks, + applyPatchBlock, + applyMultiplePatches, + parseSearchReplaceBlocks, + validateSearchExists, + formatPatchBlock, + createSimplePatch, + createNewFilePatch, + type SearchReplaceBlock, + type PatchBlock, +} from '@/engine/ai/patchApplier'; + +describe('applySearchReplaceBlock', () => { + describe('exact matching', () => { + it('should apply a simple single-line replacement', () => { + const content = 'const x = 1;\nconst y = 2;\nconst z = 3;'; + const block: SearchReplaceBlock = { + search: 'const y = 2;', + replace: 'const y = 42;', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toBe('const x = 1;\nconst y = 42;\nconst z = 3;'); + }); + + it('should apply a multi-line replacement', () => { + const content = `function greet(name) { + console.log("Hello, " + name); +} + +function farewell() { + console.log("Goodbye"); +}`; + + const block: SearchReplaceBlock = { + search: `function greet(name) { + console.log("Hello, " + name); +}`, + replace: `function greet(name, greeting = "Hello") { + console.log(greeting + ", " + name); +}`, + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('greeting = "Hello"'); + expect(result.content).toContain('function farewell()'); + }); + + it('should handle deletion (empty replace)', () => { + const content = 'line1\nline2\nline3\nline4'; + const block: SearchReplaceBlock = { + search: 'line2\n', + replace: '', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toBe('line1\nline3\nline4'); + }); + + it('should handle insertion (adding new content)', () => { + const content = 'import React from "react";\n\nfunction App() {}'; + const block: SearchReplaceBlock = { + search: 'import React from "react";', + replace: 'import React from "react";\nimport { useState } from "react";', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('useState'); + }); + }); + + describe('fuzzy matching', () => { + it('should match despite trailing whitespace differences', () => { + const content = 'const x = 1; \nconst y = 2;'; + const block: SearchReplaceBlock = { + search: 'const x = 1;', + replace: 'const x = 100;', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('const x = 100;'); + }); + + it('should handle CRLF vs LF line endings', () => { + const content = 'line1\r\nline2\r\nline3'; + const block: SearchReplaceBlock = { + search: 'line2', + replace: 'modified line2', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('modified line2'); + }); + }); + + describe('error handling', () => { + it('should fail when search text not found', () => { + const content = 'const x = 1;'; + const block: SearchReplaceBlock = { + search: 'const y = 2;', + replace: 'const y = 42;', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('should fail for empty search without line hint', () => { + const content = 'some content'; + const block: SearchReplaceBlock = { + search: '', + replace: 'new content', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(false); + }); + + it('should succeed for empty search with line number hint', () => { + const content = 'line1\nline2\nline3'; + const block: SearchReplaceBlock = { + search: '', + replace: 'inserted', + lineNumber: 2, + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('inserted'); + }); + }); +}); + +describe('applyMultipleBlocks', () => { + it('should apply multiple non-overlapping blocks', () => { + const content = `const a = 1; +const b = 2; +const c = 3; +const d = 4;`; + + const blocks: SearchReplaceBlock[] = [ + { search: 'const a = 1;', replace: 'const a = 10;' }, + { search: 'const c = 3;', replace: 'const c = 30;' }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.appliedCount).toBe(2); + expect(result.content).toContain('const a = 10;'); + expect(result.content).toContain('const b = 2;'); + expect(result.content).toContain('const c = 30;'); + expect(result.content).toContain('const d = 4;'); + }); + + it('should apply blocks in sequence', () => { + const content = 'x = 1'; + const blocks: SearchReplaceBlock[] = [ + { search: 'x = 1', replace: 'x = 2' }, + { search: 'x = 2', replace: 'x = 3' }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toBe('x = 3'); + }); + + it('should track failed blocks', () => { + const content = 'const x = 1;'; + const blocks: SearchReplaceBlock[] = [ + { search: 'const x = 1;', replace: 'const x = 2;' }, + { search: 'nonexistent', replace: 'something' }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(false); + expect(result.appliedCount).toBe(1); + expect(result.failedBlocks.length).toBe(1); + expect(result.errors.length).toBe(1); + }); + + it('should handle complex multi-function changes', () => { + const content = `function add(a, b) { + return a + b; +} + +function subtract(a, b) { + return a - b; +} + +function multiply(a, b) { + return a * b; +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `function add(a, b) { + return a + b; +}`, + replace: `function add(a: number, b: number): number { + return a + b; +}`, + }, + { + search: `function multiply(a, b) { + return a * b; +}`, + replace: `function multiply(a: number, b: number): number { + return a * b; +}`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.appliedCount).toBe(2); + expect(result.content).toContain('a: number'); + expect(result.content).toContain('function subtract(a, b)'); // unchanged + }); +}); + +describe('applyPatchBlock', () => { + it('should apply patch to existing file', () => { + const originalContent = 'const x = 1;'; + const patch: PatchBlock = { + filePath: 'test.ts', + blocks: [{ search: 'const x = 1;', replace: 'const x = 2;' }], + }; + + const result = applyPatchBlock(originalContent, patch); + + expect(result.success).toBe(true); + expect(result.patchedContent).toBe('const x = 2;'); + expect(result.originalContent).toBe(originalContent); + }); + + it('should handle new file creation', () => { + const patch: PatchBlock = { + filePath: 'new-file.ts', + blocks: [], + fullContent: 'export const newConstant = 42;', + isNewFile: true, + }; + + const result = applyPatchBlock('', patch); + + expect(result.success).toBe(true); + expect(result.isNewFile).toBe(true); + expect(result.patchedContent).toBe('export const newConstant = 42;'); + }); + + it('should handle full file replacement (legacy)', () => { + const originalContent = 'old content'; + const patch: PatchBlock = { + filePath: 'file.ts', + blocks: [], + fullContent: 'completely new content', + }; + + const result = applyPatchBlock(originalContent, patch); + + expect(result.success).toBe(true); + expect(result.patchedContent).toBe('completely new content'); + }); +}); + +describe('applyMultiplePatches', () => { + it('should apply patches to multiple files', () => { + const patches: PatchBlock[] = [ + { + filePath: 'file1.ts', + blocks: [{ search: 'old1', replace: 'new1' }], + }, + { + filePath: 'file2.ts', + blocks: [{ search: 'old2', replace: 'new2' }], + }, + ]; + + const fileContents = new Map([ + ['file1.ts', 'content with old1 here'], + ['file2.ts', 'content with old2 here'], + ]); + + const result = applyMultiplePatches(patches, fileContents); + + expect(result.overallSuccess).toBe(true); + expect(result.totalSuccess).toBe(2); + expect(result.results[0].patchedContent).toContain('new1'); + expect(result.results[1].patchedContent).toContain('new2'); + }); + + it('should handle partial failures gracefully', () => { + const patches: PatchBlock[] = [ + { + filePath: 'file1.ts', + blocks: [{ search: 'exists', replace: 'modified' }], + }, + { + filePath: 'file2.ts', + blocks: [{ search: 'nonexistent', replace: 'something' }], + }, + ]; + + const fileContents = new Map([ + ['file1.ts', 'this exists'], + ['file2.ts', 'different content'], + ]); + + const result = applyMultiplePatches(patches, fileContents); + + expect(result.overallSuccess).toBe(false); + expect(result.totalSuccess).toBe(1); + expect(result.totalFailed).toBe(1); + }); +}); + +describe('parseSearchReplaceBlocks', () => { + it('should parse single block', () => { + const text = `Some explanation + +\`\`\` +<<<<<<< SEARCH +old code +======= +new code +>>>>>>> REPLACE +\`\`\``; + + const blocks = parseSearchReplaceBlocks(text); + + expect(blocks.length).toBe(1); + expect(blocks[0].search).toBe('old code'); + expect(blocks[0].replace).toBe('new code'); + }); + + it('should parse multiple blocks', () => { + const text = `<<<<<<< SEARCH +block1 old +======= +block1 new +>>>>>>> REPLACE + +<<<<<<< SEARCH +block2 old +======= +block2 new +>>>>>>> REPLACE`; + + const blocks = parseSearchReplaceBlocks(text); + + expect(blocks.length).toBe(2); + expect(blocks[0].search).toBe('block1 old'); + expect(blocks[1].search).toBe('block2 old'); + }); + + it('should handle multi-line content in blocks', () => { + const text = `<<<<<<< SEARCH +function old() { + return 1; +} +======= +function new() { + return 2; +} +>>>>>>> REPLACE`; + + const blocks = parseSearchReplaceBlocks(text); + + expect(blocks.length).toBe(1); + expect(blocks[0].search).toContain('function old()'); + expect(blocks[0].replace).toContain('function new()'); + }); +}); + +describe('validateSearchExists', () => { + it('should return true for exact match', () => { + const content = 'const x = 1;\nconst y = 2;'; + expect(validateSearchExists(content, 'const x = 1;')).toBe(true); + }); + + it('should return true for fuzzy match', () => { + const content = 'const x = 1; \n const y = 2;'; + expect(validateSearchExists(content, 'const x = 1;')).toBe(true); + }); + + it('should return false for non-existent text', () => { + const content = 'const x = 1;'; + expect(validateSearchExists(content, 'const z = 3;')).toBe(false); + }); +}); + +describe('utility functions', () => { + describe('formatPatchBlock', () => { + it('should format a patch block correctly', () => { + const block: SearchReplaceBlock = { + search: 'old', + replace: 'new', + }; + + const formatted = formatPatchBlock(block); + + expect(formatted).toContain('<<<<<<< SEARCH'); + expect(formatted).toContain('old'); + expect(formatted).toContain('======='); + expect(formatted).toContain('new'); + expect(formatted).toContain('>>>>>>> REPLACE'); + }); + }); + + describe('createSimplePatch', () => { + it('should create a simple patch block', () => { + const patch = createSimplePatch('file.ts', 'old', 'new', 'Test change'); + + expect(patch.filePath).toBe('file.ts'); + expect(patch.blocks.length).toBe(1); + expect(patch.blocks[0].search).toBe('old'); + expect(patch.blocks[0].replace).toBe('new'); + expect(patch.explanation).toBe('Test change'); + }); + }); + + describe('createNewFilePatch', () => { + it('should create a new file patch', () => { + const patch = createNewFilePatch('new.ts', 'content', 'New file'); + + expect(patch.filePath).toBe('new.ts'); + expect(patch.isNewFile).toBe(true); + expect(patch.fullContent).toBe('content'); + expect(patch.explanation).toBe('New file'); + }); + }); +}); + +describe('real-world scenarios', () => { + it('should handle React component modification', () => { + const content = `import React from 'react'; + +interface Props { + name: string; +} + +export function Greeting({ name }: Props) { + return
    Hello, {name}!
    ; +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `interface Props { + name: string; +}`, + replace: `interface Props { + name: string; + greeting?: string; +}`, + }, + { + search: `export function Greeting({ name }: Props) { + return
    Hello, {name}!
    ; +}`, + replace: `export function Greeting({ name, greeting = 'Hello' }: Props) { + return
    {greeting}, {name}!
    ; +}`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toContain('greeting?: string'); + expect(result.content).toContain("greeting = 'Hello'"); + }); + + it('should handle adding imports', () => { + const content = `import React from 'react'; + +function App() { + return
    App
    ; +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `import React from 'react';`, + replace: `import React, { useState, useEffect } from 'react';`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toContain('useState'); + expect(result.content).toContain('useEffect'); + }); + + it('should handle TypeScript type annotations', () => { + const content = `function calculate(a, b) { + return a + b; +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `function calculate(a, b) { + return a + b; +}`, + replace: `function calculate(a: number, b: number): number { + return a + b; +}`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toContain('a: number'); + expect(result.content).toContain('b: number'); + expect(result.content).toContain('): number'); + }); + + it('should handle JSON configuration changes', () => { + const content = `{ + "name": "my-app", + "version": "1.0.0", + "scripts": { + "start": "node index.js" + } +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `"version": "1.0.0",`, + replace: `"version": "1.1.0",`, + }, + { + search: `"scripts": { + "start": "node index.js" + }`, + replace: `"scripts": { + "start": "node index.js", + "test": "jest" + }`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toContain('"version": "1.1.0"'); + expect(result.content).toContain('"test": "jest"'); + }); + + it('should handle CSS modifications', () => { + const content = `.container { + display: flex; + padding: 10px; +} + +.button { + background: blue; + color: white; +}`; + + const blocks: SearchReplaceBlock[] = [ + { + search: `.container { + display: flex; + padding: 10px; +}`, + replace: `.container { + display: flex; + flex-direction: column; + padding: 20px; + gap: 10px; +}`, + }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + expect(result.content).toContain('flex-direction: column'); + expect(result.content).toContain('gap: 10px'); + expect(result.content).toContain('.button'); // unchanged + }); +}); + +describe('edge cases', () => { + it('should handle empty file', () => { + const content = ''; + const patch: PatchBlock = { + filePath: 'empty.ts', + blocks: [], + fullContent: 'new content', + }; + + const result = applyPatchBlock(content, patch); + + expect(result.success).toBe(true); + expect(result.patchedContent).toBe('new content'); + }); + + it('should handle file with only whitespace', () => { + const content = ' \n\n \n'; + const patch: PatchBlock = { + filePath: 'whitespace.ts', + blocks: [], + fullContent: 'actual content', + }; + + const result = applyPatchBlock(content, patch); + + expect(result.success).toBe(true); + expect(result.patchedContent).toBe('actual content'); + }); + + it('should handle unicode content', () => { + const content = '// 日本語コメント\nconst greeting = "こんにちは";'; + const block: SearchReplaceBlock = { + search: 'const greeting = "こんにちは";', + replace: 'const greeting = "Hello";', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('日本語コメント'); + expect(result.content).toContain('"Hello"'); + }); + + it('should handle special regex characters in search', () => { + const content = 'const regex = /test\\.value\\[0\\]/;'; + const block: SearchReplaceBlock = { + search: 'const regex = /test\\.value\\[0\\]/;', + replace: 'const regex = /test\\.value\\[\\d+\\]/;', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('\\d+'); + }); + + it('should handle very long lines', () => { + const longString = 'a'.repeat(10000); + const content = `const x = "${longString}";`; + const block: SearchReplaceBlock = { + search: `const x = "${longString}";`, + replace: `const x = "short";`, + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toBe('const x = "short";'); + }); + + it('should handle repeated identical blocks', () => { + const content = 'x = 1;\nx = 1;\nx = 1;'; + const blocks: SearchReplaceBlock[] = [ + { search: 'x = 1;', replace: 'x = 2;' }, + ]; + + const result = applyMultipleBlocks(content, blocks); + + expect(result.success).toBe(true); + // Should only replace the first occurrence + expect(result.content).toBe('x = 2;\nx = 1;\nx = 1;'); + }); + + it('should handle template literals', () => { + const content = 'const msg = `Hello ${name}!`;'; + const block: SearchReplaceBlock = { + search: 'const msg = `Hello ${name}!`;', + replace: 'const msg = `Hi ${name}, welcome!`;', + }; + + const result = applySearchReplaceBlock(content, block); + + expect(result.success).toBe(true); + expect(result.content).toContain('Hi ${name}'); + }); +}); diff --git a/tests/runtimeRegistry.test.ts b/tests/runtimeRegistry.test.ts new file mode 100644 index 00000000..eef2e13e --- /dev/null +++ b/tests/runtimeRegistry.test.ts @@ -0,0 +1,173 @@ +/** + * RuntimeRegistry Tests + * + * RuntimeRegistryの基本機能をテスト + */ + +import { RuntimeRegistry } from '@/engine/runtime/RuntimeRegistry'; + +import type { RuntimeProvider, TranspilerProvider } from '@/engine/runtime/RuntimeProvider'; + +describe('RuntimeRegistry', () => { + let registry: RuntimeRegistry; + + beforeEach(() => { + // 各テスト前にレジストリをクリア + registry = RuntimeRegistry.getInstance(); + registry.clear(); + }); + + describe('Runtime Provider Registration', () => { + test('should register a runtime provider', () => { + const mockProvider: RuntimeProvider = { + id: 'test-runtime', + name: 'Test Runtime', + supportedExtensions: ['.test'], + canExecute: (filePath: string) => filePath.endsWith('.test'), + execute: async () => ({ exitCode: 0 }), + }; + + registry.registerRuntime(mockProvider); + + const retrieved = registry.getRuntime('test-runtime'); + expect(retrieved).toBe(mockProvider); + }); + + test('should get runtime provider by file extension', () => { + const mockProvider: RuntimeProvider = { + id: 'test-runtime', + name: 'Test Runtime', + supportedExtensions: ['.test'], + canExecute: (filePath: string) => filePath.endsWith('.test'), + execute: async () => ({ exitCode: 0 }), + }; + + registry.registerRuntime(mockProvider); + + const retrieved = registry.getRuntimeForFile('example.test'); + expect(retrieved).toBe(mockProvider); + }); + + test('should return null for unknown file extension', () => { + const retrieved = registry.getRuntimeForFile('example.unknown'); + expect(retrieved).toBeNull(); + }); + + test('should unregister a runtime provider', () => { + const mockProvider: RuntimeProvider = { + id: 'test-runtime', + name: 'Test Runtime', + supportedExtensions: ['.test'], + canExecute: (filePath: string) => filePath.endsWith('.test'), + execute: async () => ({ exitCode: 0 }), + }; + + registry.registerRuntime(mockProvider); + registry.unregisterRuntime('test-runtime'); + + const retrieved = registry.getRuntime('test-runtime'); + expect(retrieved).toBeNull(); + }); + }); + + describe('Transpiler Provider Registration', () => { + test('should register a transpiler provider', () => { + const mockProvider: TranspilerProvider = { + id: 'test-transpiler', + supportedExtensions: ['.ts'], + needsTranspile: (filePath: string) => filePath.endsWith('.ts'), + transpile: async (code: string) => ({ code, dependencies: [] }), + }; + + registry.registerTranspiler(mockProvider); + + const retrieved = registry.getTranspiler('test-transpiler'); + expect(retrieved).toBe(mockProvider); + }); + + test('should get transpiler provider by file extension', () => { + const mockProvider: TranspilerProvider = { + id: 'test-transpiler', + supportedExtensions: ['.ts'], + needsTranspile: (filePath: string) => filePath.endsWith('.ts'), + transpile: async (code: string) => ({ code, dependencies: [] }), + }; + + registry.registerTranspiler(mockProvider); + + const retrieved = registry.getTranspilerForFile('example.ts'); + expect(retrieved).toBe(mockProvider); + }); + + test('should return null for unknown file extension', () => { + const retrieved = registry.getTranspilerForFile('example.unknown'); + expect(retrieved).toBeNull(); + }); + + test('should unregister a transpiler provider', () => { + const mockProvider: TranspilerProvider = { + id: 'test-transpiler', + supportedExtensions: ['.ts'], + needsTranspile: (filePath: string) => filePath.endsWith('.ts'), + transpile: async (code: string) => ({ code, dependencies: [] }), + }; + + registry.registerTranspiler(mockProvider); + registry.unregisterTranspiler('test-transpiler'); + + const retrieved = registry.getTranspiler('test-transpiler'); + expect(retrieved).toBeNull(); + }); + }); + + describe('Multiple Providers', () => { + test('should handle multiple runtime providers', () => { + const provider1: RuntimeProvider = { + id: 'runtime1', + name: 'Runtime 1', + supportedExtensions: ['.r1'], + canExecute: (filePath: string) => filePath.endsWith('.r1'), + execute: async () => ({ exitCode: 0 }), + }; + + const provider2: RuntimeProvider = { + id: 'runtime2', + name: 'Runtime 2', + supportedExtensions: ['.r2'], + canExecute: (filePath: string) => filePath.endsWith('.r2'), + execute: async () => ({ exitCode: 0 }), + }; + + registry.registerRuntime(provider1); + registry.registerRuntime(provider2); + + const allRuntimes = registry.getAllRuntimes(); + expect(allRuntimes.length).toBe(2); + expect(allRuntimes).toContain(provider1); + expect(allRuntimes).toContain(provider2); + }); + + test('should handle multiple transpiler providers for same extension', () => { + const provider1: TranspilerProvider = { + id: 'transpiler1', + supportedExtensions: ['.ts'], + needsTranspile: (filePath: string) => filePath.endsWith('.ts'), + transpile: async (code: string) => ({ code, dependencies: [] }), + }; + + const provider2: TranspilerProvider = { + id: 'transpiler2', + supportedExtensions: ['.ts'], + needsTranspile: (filePath: string) => filePath.endsWith('.ts'), + transpile: async (code: string) => ({ code, dependencies: [] }), + }; + + registry.registerTranspiler(provider1); + registry.registerTranspiler(provider2); + + // Should return the first registered transpiler + const retrieved = registry.getTranspilerForFile('example.ts'); + expect(retrieved).toBe(provider1); + }); + }); +}); diff --git a/tree.txt b/tree.txt index 4f7602e5..8a7ced1e 100644 --- a/tree.txt +++ b/tree.txt @@ -11,6 +11,7 @@ Root │   ├── MULTI-FILE-EXTENSION-SUPPORT.md │   ├── NEW-ARCHITECTURE.md │   ├── NodeJSRuntime-new-arc.md +│   ├── PROJECT-ID-BEST-PRACTICES.md │   ├── SHELL-PARSER-PLAN.md │   ├── TAB-MANAGEMENT-ARCHITECTURE.md │   ├── TODO.md @@ -77,6 +78,7 @@ Root │   └── use-math.ts ├── jest.config.cjs ├── jest.setup.js +├── next-env.d.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml @@ -146,7 +148,6 @@ Root │   │   │   ├── AIPanel.tsx │   │   │   ├── AIReview │   │   │   │   └── AIReviewTab.tsx -│   │   │   ├── ChangedFilesList.tsx │   │   │   ├── FileSelector.tsx │   │   │   ├── chat │   │   │   │   ├── ChatContainer.tsx @@ -165,6 +166,8 @@ Root │   │   │   └── Terminal.tsx │   │   ├── BottomStatusBar.tsx │   │   ├── Confirmation.tsx +│   │   ├── DnD +│   │   │   └── CustomDragLayer.tsx │   │   ├── ExtensionInitializer.tsx │   │   ├── KeyComboClient.tsx │   │   ├── Left @@ -180,6 +183,7 @@ Root │   │   ├── MenuBar.tsx │   │   ├── OperationWindow.tsx │   │   ├── PaneContainer.tsx +│   │   ├── PaneNavigator.tsx │   │   ├── PaneResizer.tsx │   │   ├── ProjectModal.tsx │   │   ├── Right @@ -192,6 +196,12 @@ Root │   │   │   ├── DiffTab.tsx │   │   │   ├── InlineHighlightedCode.tsx │   │   │   ├── LocalImage.tsx +│   │   │   ├── MarkdownPreview +│   │   │   │   ├── CodeBlock.tsx +│   │   │   │   ├── LocalImage.tsx +│   │   │   │   ├── Mermaid.tsx +│   │   │   │   ├── index.ts +│   │   │   │   └── useIntersectionObserver.ts │   │   │   ├── MarkdownPreviewTab.tsx │   │   │   ├── ShortcutKeysTab.tsx │   │   │   ├── TabBar.tsx @@ -205,6 +215,7 @@ Root │   │   │   │   │   ├── MonacoEditor.tsx │   │   │   │   │   ├── codemirror-utils.ts │   │   │   │   │   ├── editor-utils.ts +│   │   │   │   │   ├── monaco-language-defaults.ts │   │   │   │   │   ├── monaco-themes.ts │   │   │   │   │   └── monarch-jsx-language.ts │   │   │   │   ├── hooks @@ -219,6 +230,8 @@ Root │   │   ├── TabInitializer.tsx │   │   ├── Toast.tsx │   │   └── TopBar.tsx +│   ├── constants +│   │   └── dndTypes.ts │   ├── context │   │   ├── FileSelectorContext.tsx │   │   ├── GitHubUserContext.tsx @@ -231,6 +244,7 @@ Root │   │   │   ├── contextBuilder.ts │   │   │   ├── diffProcessor.ts │   │   │   ├── fetchAI.ts +│   │   │   ├── patchApplier.ts │   │   │   ├── prompts.ts │   │   │   └── responseParser.ts │   │   ├── cmd @@ -291,6 +305,7 @@ Root │   │   │   │   ├── parser.ts │   │   │   │   └── streamShell.ts │   │   │   ├── terminalRegistry.ts +│   │   │   ├── terminalUI.ts │   │   │   └── vim.ts │   │   ├── commitMsgAI.ts │   │   ├── core @@ -323,6 +338,7 @@ Root │   │   │   └── types.ts │   │   ├── import │   │   │   └── importSingleFile.ts +│   │   ├── initialFileContents.ts │   │   ├── node │   │   │   ├── builtInModule.ts │   │   │   └── modules @@ -386,10 +402,13 @@ Root │   │   ├── useGlobalScrollLock.ts │   │   ├── useKeyBindings.ts │   │   ├── useOptimizedUIStateSave.ts +│   │   ├── usePaneResize.ts │   │   ├── useProjectWelcome.ts +│   │   ├── useResize.ts │   │   ├── useSettings.ts │   │   └── useTabContentRestore.ts │   ├── stores +│   │   ├── projectStore.ts │   │   ├── sessionStorage.ts │   │   └── tabStore.ts │   ├── tests @@ -406,6 +425,7 @@ Root │   └── searchWorker.ts ├── tailwind.config.ts ├── tests +│   ├── aiMultiPatch.test.ts │   ├── aiResponseParser.test.ts │   ├── all-sh.test.ts │   ├── extensionLoader.multifile.test.ts @@ -418,6 +438,7 @@ Root │   ├── parser.commandsub.test.ts │   ├── parser.complex.test.ts │   ├── parser.unit.test.ts +│   ├── patchApplier.test.ts │   ├── pathResolver.test.ts │   ├── reactImportTransform.test.ts │   ├── redirection.test.ts @@ -436,7 +457,7 @@ Root └── types └── esbuild-cdn.d.ts -66 directories, 370 files +69 directories, 388 files ================================= @@ -448,14 +469,30 @@ extensions/ 以下のディレクトリ構造: │   ├── systemModuleTypes.ts │   └── types.ts ├── calc +│   ├── PNPM-GUIDE.md │   ├── README.md │   ├── index.tsx │   ├── manifest.json +│   ├── node_modules +│   │   └── latexium -> ../../../node_modules/.pnpm/latexium@0.1.1/node_modules/latexium │   └── package.json ├── chart-extension │   ├── README.md │   ├── index.tsx │   ├── manifest.json +│   ├── node_modules +│   │   ├── @kurkle +│   │   │   └── color +│   │   │   ├── LICENSE.md +│   │   │   ├── README.md +│   │   │   ├── dist +│   │   │   │   ├── color.cjs +│   │   │   │   ├── color.d.ts +│   │   │   │   ├── color.esm.js +│   │   │   │   ├── color.min.js +│   │   │   │   └── color.min.js.map +│   │   │   └── package.json +│   │   └── chart.js -> ../../../node_modules/.pnpm/chart.js@4.5.1/node_modules/chart.js │   └── package.json ├── lang-packs │   ├── ar @@ -522,11 +559,15 @@ extensions/ 以下のディレクトリ構造: │   ├── index.tsx │   └── manifest.json ├── react-preview +│   ├── PNPM-GUIDE.md │   ├── README.md │   ├── _build.js │   ├── index.tsx │   ├── manifest.json +│   ├── node_modules +│   │   └── esbuild-wasm -> ../../../node_modules/.pnpm/esbuild-wasm@0.27.0/node_modules/esbuild-wasm │   └── package.json +├── registry.json ├── sample-command │   ├── README.md │   ├── index.ts @@ -544,14 +585,18 @@ extensions/ 以下のディレクトリ構造: ├── manifest.json └── transpile.worker.ts -31 directories, 71 files +40 directories, 82 files ================================= src/*.ts, *.tsx の統計: -合計行数: 57338 -合計文字数: 1811748 -合計ファイルサイズ(bytes): 1934052 +合計行数: 62017 +合計文字数: 2038902 +合計ファイルサイズ(bytes): 2170439 -開発時のdev container全体のサイズ(bytes): 31M . +開発時のdev container全体のサイズ(bytes): 2.7G . +========== +ビルト済みファイルの行数: 126818 +合計文字数: 26996325 +合計ファイルサイズ(bytes): 29974034