Skip to content

Commit a412fb3

Browse files
authored
Merge pull request #130 from Stasshe/copilot/fix-file-deletion-issue
Fix AI assistant file save issue by using fileRepository directly
2 parents c4fec9e + 5f0d6a3 commit a412fb3

File tree

3 files changed

+130
-48
lines changed

3 files changed

+130
-48
lines changed

src/components/AI/AIPanel.tsx

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { LOCALSTORAGE_KEY } from '@/context/config';
1717
import { useTranslation } from '@/context/I18nContext';
1818
import { useTheme } from '@/context/ThemeContext';
1919
import { buildAIFileContextList } from '@/engine/ai/contextBuilder';
20-
import { useProject } from '@/engine/core/project';
20+
import { fileRepository } from '@/engine/core/fileRepository';
2121
import { useAI } from '@/hooks/ai/useAI';
2222
import { useChatSpace } from '@/hooks/ai/useChatSpace';
2323
import { useAIReview } from '@/hooks/useAIReview';
@@ -98,9 +98,6 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId
9898

9999
// レビュー機能
100100
const { openAIReviewTab, closeAIReviewTab } = useAIReview();
101-
102-
// プロジェクト操作
103-
const { saveFile, clearAIReview } = useProject();
104101

105102

106103
// プロジェクトファイルが変更されたときにコンテキストを更新
@@ -206,43 +203,55 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId
206203
};
207204

208205
// レビューを開く(ストレージから履歴を取得してタブに渡す)
206+
// NOTE: NEW-ARCHITECTURE.mdに従い、aiEntryにはprojectIdを必ず含める
209207
const handleOpenReview = async (
210208
filePath: string,
211209
originalContent: string,
212210
suggestedContent: string
213211
) => {
212+
const projectId = currentProject?.id;
213+
214214
try {
215-
if (currentProject?.id) {
215+
if (projectId) {
216216
const { getAIReviewEntry } = await import('@/engine/storage/aiStorageAdapter');
217-
const entry = await getAIReviewEntry(currentProject.id, filePath);
218-
openAIReviewTab(filePath, originalContent, suggestedContent, entry || undefined);
217+
const entry = await getAIReviewEntry(projectId, filePath);
218+
219+
// 既存エントリがない場合でも、projectIdを含む最小限のaiEntryを作成
220+
const aiEntry = entry || { projectId, filePath };
221+
openAIReviewTab(filePath, originalContent, suggestedContent, aiEntry);
219222
return;
220223
}
221224
} catch (e) {
222225
console.warn('[AIPanel] Failed to load AI review entry:', e);
223226
}
224227

228+
// currentProjectがない場合はprojectIdなしで開く(fallback)
225229
openAIReviewTab(filePath, originalContent, suggestedContent);
226230
};
227231

228232
// 変更を適用(suggestedContent -> contentへコピー)
229-
// Terminalと同じアプローチ:fileRepositoryに保存し、イベントシステムに任せる
233+
// NOTE: NEW-ARCHITECTURE.mdに従い、fileRepositoryを直接使用
230234
const handleApplyChanges = async (filePath: string, newContent: string) => {
231-
if (!currentProject || !saveFile) return;
235+
const projectId = currentProject?.id;
236+
237+
if (!projectId) {
238+
console.error('[AIPanel] No projectId available, cannot apply changes');
239+
// TODO: alertの代わりにトースト通知を使用する
240+
alert('プロジェクトが選択されていません');
241+
return;
242+
}
232243

233244
try {
234245
console.log('[AIPanel] Applying changes to:', filePath);
235246

236-
// Use the saveFile from useProject() (consistent with Terminal/project flow).
237-
// This delegates to the project layer which in turn calls fileRepository and
238-
// ensures any side-effects (sync, indexing) happen consistently.
239-
await saveFile(filePath, newContent);
247+
// fileRepositoryを直接使用してファイルを保存(NEW-ARCHITECTURE.mdに従う)
248+
await fileRepository.saveFileByPath(projectId, filePath, newContent);
240249

241250
// Clear AI review metadata for this file (non-blocking)
242-
if (clearAIReview) {
243-
clearAIReview(filePath).catch((e: Error) => {
244-
console.warn('[AIPanel] clearAIReview failed (non-critical):', e);
245-
});
251+
try {
252+
await fileRepository.clearAIReview(projectId, filePath);
253+
} catch (e) {
254+
console.warn('[AIPanel] clearAIReview failed (non-critical):', e);
246255
}
247256

248257
// Remove this file from the assistant editResponse in the current chat space
@@ -279,13 +288,16 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId
279288

280289
// 変更を破棄
281290
const handleDiscardChanges = async (filePath: string) => {
291+
const projectId = currentProject?.id;
292+
282293
try {
283294
// Close the review tab immediately so UI updates.
284295
closeAIReviewTab(filePath);
285296
// Finally clear ai review metadata for this file
286-
if (clearAIReview) {
297+
if (projectId) {
287298
try {
288-
await clearAIReview(filePath);
299+
await fileRepository.init();
300+
await fileRepository.clearAIReview(projectId, filePath);
289301
} catch (e) {
290302
console.warn('[AIPanel] clearAIReview failed after discard:', e);
291303
}
@@ -460,24 +472,26 @@ export default function AIPanel({ projectFiles, currentProject, currentProjectId
460472
isProcessing={isProcessing}
461473
emptyMessage={mode === 'ask' ? t('AI.ask') : t('AI.edit')}
462474
onRevert={async (message: ChatSpaceMessage) => {
475+
const projectId = currentProject?.id;
463476
try {
464-
if (!currentProject?.id || !saveFile) return;
477+
if (!projectId) return;
465478
if (message.type !== 'assistant' || message.mode !== 'edit' || !message.editResponse) return;
466479

467480
const { getAIReviewEntry, updateAIReviewEntry } = await import('@/engine/storage/aiStorageAdapter');
468481

469482
const files = message.editResponse.changedFiles || [];
470483
for (const f of files) {
471484
try {
472-
const entry = await getAIReviewEntry(currentProject.id, f.path);
485+
const entry = await getAIReviewEntry(projectId, f.path);
473486
if (entry && entry.originalSnapshot) {
474-
await saveFile(f.path, entry.originalSnapshot);
487+
// fileRepositoryを直接使用してファイルを保存
488+
await fileRepository.saveFileByPath(projectId, f.path, entry.originalSnapshot);
475489

476490
// mark entry reverted and add history
477491
const hist = Array.isArray(entry.history) ? entry.history : [];
478492
const historyEntry = { id: `revert-${Date.now()}`, timestamp: new Date(), content: entry.originalSnapshot, note: `reverted via chat ${message.id}` };
479493
try {
480-
await updateAIReviewEntry(currentProject.id, f.path, {
494+
await updateAIReviewEntry(projectId, f.path, {
481495
status: 'reverted',
482496
history: [historyEntry, ...hist],
483497
});

src/engine/core/fileRepository.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ export class FileRepository {
473473
* ファイル作成(既存の場合は更新)
474474
* 自動的にGitFileSystemに非同期同期される
475475
* 親ディレクトリが存在しない場合は自動的に作成される
476+
* NOTE: pathは自動的にAppPath形式(先頭スラッシュ付き)に正規化される
476477
*/
477478
async createFile(
478479
projectId: string,
@@ -484,9 +485,12 @@ export class FileRepository {
484485
): Promise<ProjectFile> {
485486
await this.init();
486487

488+
// パスをAppPath形式に正規化
489+
const normalizedPath = toAppPath(path);
490+
487491
// 既存ファイルをチェック
488492
const existingFiles = await this.getProjectFiles(projectId);
489-
const existingFile = existingFiles.find(f => f.path === path);
493+
const existingFile = existingFiles.find(f => f.path === normalizedPath);
490494

491495
if (existingFile) {
492496
// 既存ファイルを更新
@@ -505,17 +509,17 @@ export class FileRepository {
505509
}
506510

507511
// 親ディレクトリの自動作成(再帰的)
508-
await this.ensureParentDirectories(projectId, path, existingFiles);
512+
await this.ensureParentDirectories(projectId, normalizedPath, existingFiles);
509513

510514
// 新規ファイル作成
511515
const file: ProjectFile = {
512516
id: generateUniqueId('file'),
513517
projectId,
514-
path,
515-
name: path.split('/').pop() || '',
518+
path: normalizedPath,
519+
name: normalizedPath.split('/').pop() || '',
516520
content: isBufferArray ? '' : content,
517521
type,
518-
parentPath: path.substring(0, path.lastIndexOf('/')) || '/',
522+
parentPath: normalizedPath.substring(0, normalizedPath.lastIndexOf('/')) || '/',
519523
createdAt: new Date(),
520524
updatedAt: new Date(),
521525
isBufferArray: !!isBufferArray,
@@ -648,6 +652,40 @@ export class FileRepository {
648652
});
649653
}
650654

655+
/**
656+
* パスベースでファイルを保存または作成する便利メソッド
657+
* 既存ファイルがあれば更新し、なければ新規作成する
658+
* AI機能など、ファイルの存在を事前に確認せずに保存したい場合に使用
659+
*
660+
* NOTE: パスは自動的にAppPath形式(先頭スラッシュ付き)に正規化される
661+
*/
662+
async saveFileByPath(projectId: string, path: string, content: string): Promise<void> {
663+
await this.init();
664+
665+
// パスをAppPath形式に正規化(例: "src/main.rs" -> "/src/main.rs")
666+
const normalizedPath = toAppPath(path);
667+
coreInfo(`[FileRepository] saveFileByPath: original="${path}", normalized="${normalizedPath}"`);
668+
669+
const existingFile = await this.getFileByPath(projectId, normalizedPath);
670+
671+
if (existingFile) {
672+
// 既存ファイルを更新
673+
const updatedFile = {
674+
...existingFile,
675+
content,
676+
isBufferArray: false,
677+
bufferContent: undefined,
678+
updatedAt: new Date(),
679+
};
680+
await this.saveFile(updatedFile);
681+
coreInfo(`[FileRepository] File updated by path: ${normalizedPath}`);
682+
} else {
683+
// 新規ファイルを作成
684+
await this.createFile(projectId, normalizedPath, content, 'file');
685+
coreInfo(`[FileRepository] File created by path: ${normalizedPath}`);
686+
}
687+
}
688+
651689
/**
652690
* .gitignoreルールに基づいてパスを無視すべきかチェック
653691
*/
@@ -1004,11 +1042,14 @@ export class FileRepository {
10041042
/**
10051043
* プロジェクト内のパスでファイルを取得(path はプロジェクトルート相対パス)
10061044
* 可能な限りインデックスを使って効率的に取得する。
1045+
* NOTE: pathは自動的にAppPath形式に正規化される
10071046
*/
10081047
async getFileByPath(projectId: string, path: string): Promise<ProjectFile | null> {
10091048
if (!this.db) throw new Error('Database not initialized');
10101049

1011-
// 正規化: leading slash を許容しているのでそのまま使う
1050+
// パスをAppPath形式に正規化
1051+
const normalizedPath = toAppPath(path);
1052+
10121053
return new Promise((resolve, reject) => {
10131054
const transaction = this.db!.transaction(['files'], 'readonly');
10141055
const store = transaction.objectStore('files');
@@ -1017,7 +1058,7 @@ export class FileRepository {
10171058
if (store.indexNames.contains('projectId_path')) {
10181059
try {
10191060
const idx = store.index('projectId_path');
1020-
const req = idx.get([projectId, path]);
1061+
const req = idx.get([projectId, normalizedPath]);
10211062
req.onerror = () => reject(req.error);
10221063
req.onsuccess = () => resolve(req.result || null);
10231064
return;
@@ -1033,7 +1074,7 @@ export class FileRepository {
10331074
req.onerror = () => reject(req.error);
10341075
req.onsuccess = () => {
10351076
const files = req.result as ProjectFile[];
1036-
const found = files.find(f => f.path === path) || null;
1077+
const found = files.find(f => f.path === normalizedPath) || null;
10371078
resolve(found);
10381079
};
10391080
return;
@@ -1044,7 +1085,7 @@ export class FileRepository {
10441085
allReq.onerror = () => reject(allReq.error);
10451086
allReq.onsuccess = () => {
10461087
const files = allReq.result as ProjectFile[];
1047-
const found = files.find(f => f.projectId === projectId && f.path === path) || null;
1088+
const found = files.find(f => f.projectId === projectId && f.path === normalizedPath) || null;
10481089
resolve(found);
10491090
};
10501091
});
@@ -1266,9 +1307,13 @@ export class FileRepository {
12661307

12671308
/**
12681309
* AIレビュー状態をクリア
1310+
* NOTE: filePathは自動的にAppPath形式に正規化される
12691311
*/
12701312
async clearAIReview(projectId: string, filePath: string): Promise<void> {
12711313
if (!this.db) throw new Error('Database not initialized');
1314+
1315+
// パスをAppPath形式に正規化
1316+
const normalizedPath = toAppPath(filePath);
12721317

12731318
return new Promise((resolve, reject) => {
12741319
const transaction = this.db!.transaction(['files'], 'readwrite');
@@ -1279,7 +1324,7 @@ export class FileRepository {
12791324
request.onerror = () => reject(request.error);
12801325
request.onsuccess = () => {
12811326
const files = request.result;
1282-
const file = files.find((f: ProjectFile) => f.path === filePath);
1327+
const file = files.find((f: ProjectFile) => f.path === normalizedPath);
12831328

12841329
if (file) {
12851330
file.aiReviewStatus = undefined;

src/engine/tabs/builtins/AIReviewTabType.tsx

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,33 +5,50 @@ import { TabTypeDefinition, AIReviewTab, TabComponentProps } from '../types';
55

66
import AIReviewTabComponent from '@/components/AI/AIReview/AIReviewTab';
77
import { useGitContext } from '@/components/PaneContainer';
8-
import { useProject } from '@/engine/core/project';
8+
import { fileRepository } from '@/engine/core/fileRepository';
99
import { useChatSpace } from '@/hooks/ai/useChatSpace';
1010
import { useTabStore } from '@/stores/tabStore';
1111

1212
/**
1313
* AIレビュータブのコンポーネント
14+
*
15+
* NOTE: NEW-ARCHITECTURE.mdに従い、ファイル操作はfileRepositoryを直接使用。
16+
* useProjectフックは各コンポーネントで独立した状態を持つため、
17+
* currentProjectがnullになりファイルが保存されない問題があった。
1418
*/
1519
const AIReviewTabRenderer: React.FC<TabComponentProps> = ({ tab }) => {
1620
const aiTab = tab as AIReviewTab;
1721
const closeTab = useTabStore(state => state.closeTab);
1822
const updateTab = useTabStore(state => state.updateTab);
19-
const { saveFile, clearAIReview, refreshProjectFiles } = useProject();
2023
const { setGitRefreshTrigger } = useGitContext();
2124
const { addMessage } = useChatSpace(aiTab.aiEntry?.projectId || null);
2225

2326
const handleApplyChanges = async (filePath: string, content: string) => {
24-
if (saveFile) {
25-
await saveFile(filePath, content);
27+
const projectId = aiTab.aiEntry?.projectId;
28+
29+
if (!projectId) {
30+
console.error('[AIReviewTabRenderer] No projectId available, cannot save file');
31+
return;
32+
}
33+
34+
try {
35+
// fileRepositoryを直接使用してファイルを保存(NEW-ARCHITECTURE.mdに従う)
36+
await fileRepository.saveFileByPath(projectId, filePath, content);
37+
2638
// Git状態を更新
2739
setGitRefreshTrigger(prev => prev + 1);
40+
41+
// AIレビュー状態をクリア
42+
try {
43+
await fileRepository.clearAIReview(projectId, filePath);
44+
} catch (e) {
45+
console.warn('[AIReviewTabRenderer] clearAIReview failed (non-critical):', e);
46+
}
47+
} catch (error) {
48+
console.error('[AIReviewTabRenderer] Failed to save file:', error);
49+
return;
2850
}
29-
if (clearAIReview) {
30-
await clearAIReview(filePath);
31-
}
32-
if (refreshProjectFiles) {
33-
await refreshProjectFiles();
34-
}
51+
3552
// Add a chat message indicating the apply action, branching from parent if available
3653
if (addMessage) {
3754
try {
@@ -48,12 +65,18 @@ const AIReviewTabRenderer: React.FC<TabComponentProps> = ({ tab }) => {
4865
};
4966

5067
const handleDiscardChanges = async (filePath: string) => {
51-
if (clearAIReview) {
52-
await clearAIReview(filePath);
53-
}
54-
if (refreshProjectFiles) {
55-
await refreshProjectFiles();
68+
const projectId = aiTab.aiEntry?.projectId;
69+
70+
// AIレビュー状態をクリア(projectIdがある場合のみ)
71+
if (projectId) {
72+
try {
73+
await fileRepository.init();
74+
await fileRepository.clearAIReview(projectId, filePath);
75+
} catch (e) {
76+
console.warn('[AIReviewTabRenderer] clearAIReview failed (non-critical):', e);
77+
}
5678
}
79+
5780
// record revert/discard in chat
5881
if (addMessage) {
5982
try {

0 commit comments

Comments
 (0)