@hapico/cli 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,48 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { checkbox, input, editor, confirm } from "@inquirer/prompts";
5
- import { exec } from "node:child_process";
6
- import util from "node:util";
4
+ import { diff_match_patch } from "diff-match-patch";
7
5
  import express from "express";
8
6
  import open from "open";
9
- // import net
10
- import os from "os";
11
-
12
- const execAsync = util.promisify(exec);
13
7
  import net from "node:net";
14
8
 
9
+ const scanRecursively = async (
10
+ dir: string,
11
+ baseDir: string
12
+ ): Promise<FileNode[]> => {
13
+ let results: FileNode[] = [];
14
+ try {
15
+ const list = await fs.readdir(dir, { withFileTypes: true });
16
+
17
+ for (const entry of list) {
18
+ const fullPath = path.join(dir, entry.name);
19
+ // Chuẩn hóa đường dẫn về forward slash để nhất quán giữa các hệ điều hành
20
+ const relativePath = path.relative(baseDir, fullPath).split(path.sep).join('/');
15
21
 
22
+ if (shouldIgnorePath(relativePath)) continue;
23
+
24
+ if (entry.isDirectory()) {
25
+ const subResults = await scanRecursively(fullPath, baseDir);
26
+ results = results.concat(subResults);
27
+ } else if (entry.isFile()) {
28
+ try {
29
+ const content = await fs.readFile(fullPath, "utf-8");
30
+ results.push({
31
+ path: relativePath,
32
+ fullPath: fullPath,
33
+ content: content,
34
+ extension: entry.name.split(".").pop() || "txt",
35
+ });
36
+ } catch (err) {
37
+ // Bỏ qua file nhị phân hoặc không đọc được
38
+ }
39
+ }
40
+ }
41
+ } catch (error) {
42
+ // Bỏ qua nếu lỗi permission hoặc path không tồn tại
43
+ }
44
+ return results;
45
+ };
16
46
 
17
47
  // random port from 3000-3999
18
48
  // Check if port is available would be better in real-world usage
@@ -94,118 +124,261 @@ const shouldIgnorePath = (filePath: string): boolean => {
94
124
  return false;
95
125
  };
96
126
 
97
- const scanRecursively = async (
98
- dir: string,
99
- baseDir: string
100
- ): Promise<FileNode[]> => {
101
- let results: FileNode[] = [];
102
- try {
103
- const list = await fs.readdir(dir, { withFileTypes: true });
127
+ /**
128
+ * ------------------------------------------------------------------
129
+ * TYPES
130
+ * ------------------------------------------------------------------
131
+ */
132
+ interface PatchBlock {
133
+ search: string;
134
+ replace: string;
135
+ }
104
136
 
105
- for (const entry of list) {
106
- const fullPath = path.join(dir, entry.name);
107
- const relativePath = path.relative(baseDir, fullPath);
137
+ /**
138
+ * ------------------------------------------------------------------
139
+ * HELPER: STRING SIMILARITY (Levenshtein based)
140
+ * Tính độ giống nhau giữa 2 chuỗi (0 -> 1)
141
+ * ------------------------------------------------------------------
142
+ */
143
+ const calculateSimilarity = (s1: string, s2: string): number => {
144
+ const longer = s1.length > s2.length ? s1 : s2;
145
+ const shorter = s1.length > s2.length ? s2 : s1;
146
+ const longerLength = longer.length;
147
+ if (longerLength === 0) {
148
+ return 1.0;
149
+ }
150
+
151
+ // Sử dụng dmp để tính Levenshtein distance cho chính xác và nhanh
152
+ const dmp = new diff_match_patch();
153
+ const diffs = dmp.diff_main(longer, shorter);
154
+ const levenshtein = dmp.diff_levenshtein(diffs);
155
+
156
+ return (longerLength - levenshtein) / longerLength;
157
+ };
108
158
 
109
- if (shouldIgnorePath(relativePath)) continue;
159
+ /**
160
+ * ------------------------------------------------------------------
161
+ * HELPER: FUZZY LINE MATCHING
162
+ * Tìm đoạn code khớp nhất trong file gốc dựa trên điểm số (Confidence)
163
+ * ------------------------------------------------------------------
164
+ */
165
+ const applyFuzzyLineMatch = (
166
+ original: string,
167
+ search: string,
168
+ replace: string,
169
+ threshold: number = 0.85 // Ngưỡng chấp nhận (85% giống nhau)
170
+ ): { result: string | null; confidence: number } => {
171
+ const originalLines = original.split(/\r?\n/);
172
+ const searchLines = search.split(/\r?\n/);
173
+
174
+ // Lọc bỏ dòng trống ở đầu/cuối search block để match chính xác hơn
175
+ while (searchLines.length > 0 && searchLines[0].trim() === "") searchLines.shift();
176
+ while (searchLines.length > 0 && searchLines[searchLines.length - 1].trim() === "") searchLines.pop();
177
+
178
+ if (searchLines.length === 0) return { result: null, confidence: 0 };
179
+
180
+ let bestMatchIndex = -1;
181
+ let bestMatchScore = 0;
182
+
183
+ // Duyệt qua file gốc (Sliding Window)
184
+ // Chỉ cần duyệt đến vị trí mà số dòng còn lại đủ để chứa searchLines
185
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
186
+ let currentScoreTotal = 0;
187
+ let possible = true;
110
188
 
111
- if (entry.isDirectory()) {
112
- const subResults = await scanRecursively(fullPath, baseDir);
113
- results = results.concat(subResults);
114
- } else if (entry.isFile()) {
115
- try {
116
- const content = await fs.readFile(fullPath, "utf-8");
117
- results.push({
118
- path: relativePath,
119
- fullPath: fullPath,
120
- content: content,
121
- extension: entry.name.split(".").pop() || "txt",
122
- });
123
- } catch (err) {
124
- // Bỏ qua file nhị phân hoặc không đọc được
125
- }
189
+ for (let j = 0; j < searchLines.length; j++) {
190
+ const originalLine = originalLines[i + j].trim();
191
+ const searchLine = searchLines[j].trim();
192
+
193
+ // So sánh nhanh: Nếu giống hệt nhau
194
+ if (originalLine === searchLine) {
195
+ currentScoreTotal += 1;
196
+ continue;
126
197
  }
198
+
199
+ // So sánh chậm: Tính độ tương đồng
200
+ const similarity = calculateSimilarity(originalLine, searchLine);
201
+
202
+ // Nếu một dòng quá khác biệt (< 60%), coi như block này không khớp
203
+ // (Giúp tối ưu hiệu năng, cắt nhánh sớm)
204
+ if (similarity < 0.6) {
205
+ possible = false;
206
+ break;
207
+ }
208
+ currentScoreTotal += similarity;
127
209
  }
128
- } catch (error) {
129
- // Bỏ qua nếu lỗi permission hoặc path không tồn tại
130
- }
131
- return results;
132
- };
133
210
 
134
- const areLinesFuzzyEqual = (line1: string, line2: string): boolean => {
135
- return line1.trim() === line2.trim();
136
- };
211
+ if (possible) {
212
+ const avgScore = currentScoreTotal / searchLines.length;
213
+ if (avgScore > bestMatchScore) {
214
+ bestMatchScore = avgScore;
215
+ bestMatchIndex = i;
216
+ }
217
+ }
218
+ }
137
219
 
138
- const performFuzzyReplace = (
139
- originalContent: string,
140
- searchBlock: string,
141
- replaceBlock: string
142
- ): string | null => {
143
- const originalLines = originalContent.split(/\r?\n/);
144
- const searchLines = searchBlock.split(/\r?\n/);
145
-
146
- while (searchLines.length > 0 && searchLines[0].trim() === "") {
147
- searchLines.shift();
220
+ // LOG CONFIDENCE ĐỂ DEBUG
221
+ if (bestMatchScore > 0) {
222
+ console.log(`[FuzzyMatch] Best match score: ${(bestMatchScore * 100).toFixed(2)}% at line ${bestMatchIndex + 1}`);
148
223
  }
149
- while (
150
- searchLines.length > 0 &&
151
- searchLines[searchLines.length - 1].trim() === ""
152
- ) {
153
- searchLines.pop();
224
+
225
+ // Nếu tìm thấy vị trí có độ tin cậy cao hơn ngưỡng
226
+ if (bestMatchIndex !== -1 && bestMatchScore >= threshold) {
227
+ const before = originalLines.slice(0, bestMatchIndex);
228
+ // Lưu ý: Đoạn replace thay thế đúng số lượng dòng của searchLines
229
+ // (Giả định search và match trong original có cùng số dòng hiển thị)
230
+ const after = originalLines.slice(bestMatchIndex + searchLines.length);
231
+
232
+ return {
233
+ result: [...before, replace, ...after].join("\n"),
234
+ confidence: bestMatchScore
235
+ };
154
236
  }
155
237
 
156
- if (searchLines.length === 0) return null;
238
+ return { result: null, confidence: bestMatchScore };
239
+ };
240
+
241
+ /**
242
+ * ------------------------------------------------------------------
243
+ * 1. STATE MACHINE PARSER (FILE-AWARE)
244
+ * ------------------------------------------------------------------
245
+ */
246
+ const parseLLMPatch = (patchContent: string): Record<string, PatchBlock[]> => {
247
+ const lines = patchContent.split(/\r?\n/);
248
+ const filePatches: Record<string, PatchBlock[]> = {};
249
+
250
+ let currentFile = "unknown";
251
+ let inSearch = false;
252
+ let inReplace = false;
253
+ let searchLines: string[] = [];
254
+ let replaceLines: string[] = [];
255
+
256
+ const isFileMarker = (line: string) => {
257
+ const match = line.match(/^### File:\s*[`']?([^`'\s]+)[`']?/i);
258
+ return match ? match[1] : null;
259
+ };
260
+ const isSearchMarker = (line: string) => /^\s*<{7,}\s*SEARCH\s*$/.test(line);
261
+ const isDividerMarker = (line: string) => /^\s*={7,}\s*$/.test(line);
262
+ const isEndMarker = (line: string) => /^\s*>{7,}\s*REPLACE\s*$/.test(line);
263
+
264
+ for (const line of lines) {
265
+ const fileName = isFileMarker(line);
266
+ if (fileName) {
267
+ currentFile = fileName;
268
+ continue;
269
+ }
157
270
 
158
- for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
159
- let match = true;
160
- for (let j = 0; j < searchLines.length; j++) {
161
- if (!areLinesFuzzyEqual(originalLines[i + j], searchLines[j])) {
162
- match = false;
163
- break;
164
- }
271
+ if (isSearchMarker(line)) {
272
+ inSearch = true;
273
+ inReplace = false;
274
+ searchLines = [];
275
+ replaceLines = [];
276
+ continue;
165
277
  }
166
- if (match) {
167
- const before = originalLines.slice(0, i).join("\n");
168
- const after = originalLines.slice(i + searchLines.length).join("\n");
169
- return `${before}\n${replaceBlock}\n${after}`;
278
+ if (isDividerMarker(line)) {
279
+ if (inSearch) { inSearch = false; inReplace = true; }
280
+ continue;
170
281
  }
282
+ if (isEndMarker(line)) {
283
+ if (inReplace || inSearch) {
284
+ if (searchLines.length > 0 || replaceLines.length > 0) {
285
+ if (!filePatches[currentFile]) filePatches[currentFile] = [];
286
+ filePatches[currentFile].push({
287
+ search: searchLines.join("\n"),
288
+ replace: replaceLines.join("\n")
289
+ });
290
+ }
291
+ }
292
+ inSearch = false; inReplace = false; searchLines = []; replaceLines = [];
293
+ continue;
294
+ }
295
+ if (inSearch) searchLines.push(line);
296
+ else if (inReplace) replaceLines.push(line);
171
297
  }
172
- return null;
298
+ return filePatches;
173
299
  };
174
300
 
175
- const applyPatch = (originalContent: string, patchContent: string): string => {
301
+ /**
302
+ * ------------------------------------------------------------------
303
+ * 2. APPLY PATCH (UPDATED)
304
+ * ------------------------------------------------------------------
305
+ */
306
+ export const applyPatch = (
307
+ originalContent: string,
308
+ patches: PatchBlock[]
309
+ ): string => {
310
+ if (!patches || patches.length === 0) return originalContent;
311
+
176
312
  let result = originalContent;
177
- const patchRegex =
178
- /<<<<<<< SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n?>>>>>>> REPLACE/g;
179
- const matches = [...patchContent.matchAll(patchRegex)];
180
313
 
181
- if (matches.length === 0) {
182
- return originalContent;
183
- }
314
+ for (const patch of patches) {
315
+ const { search, replace } = patch;
184
316
 
185
- for (const match of matches) {
186
- const [_, searchBlock, replaceBlock] = match;
317
+ // NẾU SEARCH RỖNG (INSERT/APPEND)
318
+ if (search.trim() === "") {
319
+ console.log(`[ApplyPatch] Inserting/appending block.`);
320
+ result += `\n${replace}`;
321
+ continue;
322
+ }
187
323
 
188
- if (result.includes(searchBlock)) {
189
- result = result.replace(searchBlock, replaceBlock);
324
+ // --- STRATEGY 1: EXACT MATCH ---
325
+ if (result.includes(search)) {
326
+ console.log(`[ApplyPatch] Exact match found.`);
327
+ result = result.replace(search, replace);
190
328
  continue;
191
329
  }
192
330
 
193
- const trimmedSearch = searchBlock.trim();
194
- const trimmedReplace = replaceBlock.trim();
331
+ // --- STRATEGY 2: NORMALIZED MATCH (Trim) ---
332
+ const trimmedSearch = search.trim();
195
333
  if (result.includes(trimmedSearch)) {
196
- result = result.replace(trimmedSearch, trimmedReplace);
334
+ console.log(`[ApplyPatch] Trimmed match found.`);
335
+ result = result.replace(trimmedSearch, replace.trim());
197
336
  continue;
198
337
  }
338
+
339
+ // --- STRATEGY 3: FUZZY LINE MATCH (BEST FOR CODE) ---
340
+ // Tìm vị trí tốt nhất dựa trên similarity score của từng dòng
341
+ const fuzzyMatch = applyFuzzyLineMatch(result, search, replace, 0.80); // 80% confidence
342
+ if (fuzzyMatch.result !== null) {
343
+ console.log(`[ApplyPatch] Fuzzy line match applied (Confidence: ${(fuzzyMatch.confidence * 100).toFixed(0)}%)`);
344
+ result = fuzzyMatch.result;
345
+ continue;
346
+ } else {
347
+ console.warn(`[ApplyPatch] Failed to match block. Max confidence was: ${(fuzzyMatch.confidence * 100).toFixed(0)}%`);
348
+ }
199
349
 
200
- const fuzzyResult = performFuzzyReplace(result, searchBlock, replaceBlock);
201
- if (fuzzyResult) {
202
- result = fuzzyResult;
203
- continue;
350
+ // --- STRATEGY 4: DMP FALLBACK (LAST RESORT) ---
351
+ // Nếu cả fuzzy line match cũng tạch (ví dụ do search block quá ngắn hoặc cấu trúc quá nát)
352
+ // thì mới dùng DMP patch_apply.
353
+ try {
354
+ const dmp = new diff_match_patch();
355
+ dmp.Match_Threshold = 0.5;
356
+ dmp.Match_Distance = 1000;
357
+
358
+ const dmpPatches = dmp.patch_make(search, replace);
359
+ const [newText, applyResults] = dmp.patch_apply(dmpPatches, result);
360
+
361
+ // Kiểm tra xem có apply được hết các hunk không
362
+ const successCount = applyResults.filter(Boolean).length;
363
+ const successRate = successCount / applyResults.length;
364
+
365
+ if (successRate === 1 && newText !== result) {
366
+ console.log(`[ApplyPatch] DMP Patch applied via fuzzy logic.`);
367
+ result = newText;
368
+ } else {
369
+ console.error(`[ApplyPatch] All strategies failed for block starting with: ${search.substring(0, 30)}...`);
370
+ // Tuỳ chọn: Throw error để báo cho LLM biết là apply thất bại
371
+ // throw new Error("Could not apply patch block");
372
+ }
373
+ } catch (e) {
374
+ console.error(`[ApplyPatch] DMP error:`, e);
204
375
  }
205
376
  }
377
+
206
378
  return result;
207
379
  };
208
380
 
381
+
209
382
  // ==========================================
210
383
  // 3. WEB UI SERVER (UPDATED UI)
211
384
  // ==========================================
@@ -238,18 +411,50 @@ const startServer = async (rootDir: string) => {
238
411
 
239
412
  // API 3: Apply Patch
240
413
  app.post("/api/apply", async (req, res) => {
241
- const { llmResponse, filePaths } = req.body;
414
+ const { llmResponse, filePaths: selectedPaths } = req.body;
415
+
416
+ // 1. Parse all patches from LLM response
417
+ const filePatches = parseLLMPatch(llmResponse);
418
+
419
+ // 2. Scan project files
242
420
  const allFiles = await scanRecursively(rootDir, rootDir);
243
- const selectedFiles = allFiles.filter((f) => filePaths.includes(f.path));
244
-
245
421
  const modifiedFiles: string[] = [];
246
- for (const file of selectedFiles) {
247
- const newContent = applyPatch(file.content, llmResponse);
248
- if (newContent !== file.content) {
249
- await fs.writeFile(file.fullPath, newContent, "utf-8");
250
- modifiedFiles.push(file.path);
422
+
423
+ // 3. Match patches to files and apply
424
+ for (const file of allFiles) {
425
+ // Find patches meant for this specific file path
426
+ // Note: LLM might use different path separators, so we normalize
427
+ const normalizedPath = file.path.replace(/\\/g, '/');
428
+ const patchesForFile = Object.entries(filePatches).find(([path]) => {
429
+ const normalizedKey = path.replace(/\\/g, '/');
430
+ return normalizedKey === normalizedPath || normalizedPath.endsWith('/' + normalizedKey);
431
+ })?.[1];
432
+
433
+ // Only apply if the file was selected in UI AND has patches in the response
434
+ if (selectedPaths.includes(file.path) && patchesForFile) {
435
+ console.log(`[Apply] Processing ${patchesForFile.length} blocks for: ${file.path}`);
436
+ const newContent = applyPatch(file.content, patchesForFile);
437
+
438
+ if (newContent !== file.content) {
439
+ await fs.writeFile(file.fullPath, newContent, "utf-8");
440
+ modifiedFiles.push(file.path);
441
+ }
251
442
  }
252
443
  }
444
+
445
+ // Fallback: If no matched files but only one file was selected, try applying "unknown" patches
446
+ if (modifiedFiles.length === 0 && selectedPaths.length === 1 && filePatches["unknown"]) {
447
+ const file = allFiles.find(f => f.path === selectedPaths[0]);
448
+ if (file) {
449
+ console.log(`[Apply] Fallback: Applying unknown blocks to single selected file: ${file.path}`);
450
+ const newContent = applyPatch(file.content, filePatches["unknown"]);
451
+ if (newContent !== file.content) {
452
+ await fs.writeFile(file.fullPath, newContent, "utf-8");
453
+ modifiedFiles.push(file.path);
454
+ }
455
+ }
456
+ }
457
+
253
458
  res.json({ modified: modifiedFiles });
254
459
  });
255
460
 
@@ -376,7 +581,8 @@ const startServer = async (rootDir: string) => {
376
581
 
377
582
  <div class="flex-1 flex flex-col bg-gray-850 min-w-0">
378
583
 
379
- <div class="flex-shrink-0 p-6 border-b border-apple-border">
584
+ <!-- 1. Instruction (Takes more space and resizable) -->
585
+ <div class="flex-1 p-6 flex flex-col min-h-0 border-b border-apple-border">
380
586
  <div class="flex justify-between items-end mb-3">
381
587
  <div>
382
588
  <h2 class="text-sm font-semibold text-gray-200">1. Instruction</h2>
@@ -392,12 +598,12 @@ const startServer = async (rootDir: string) => {
392
598
  </button>
393
599
  </div>
394
600
  <textarea x-model="instruction"
395
- class="w-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-none font-mono"
396
- rows="3"
601
+ class="w-full flex-1 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-apple-blue/50 focus:ring-1 focus:ring-apple-blue/50 transition resize-y font-mono min-h-[120px]"
397
602
  placeholder="e.g. Refactor the Button component to use TypeScript interfaces..."></textarea>
398
603
  </div>
399
604
 
400
- <div class="flex-1 p-6 flex flex-col min-h-0">
605
+ <!-- 2. LLM Patch (Fixed smaller size) -->
606
+ <div class="h-64 p-6 flex flex-col flex-shrink-0">
401
607
  <div class="flex justify-between items-end mb-3">
402
608
  <div>
403
609
  <h2 class="text-sm font-semibold text-gray-200">2. LLM Patch</h2>
@@ -410,11 +616,9 @@ const startServer = async (rootDir: string) => {
410
616
  <span x-show="applying">Applying...</span>
411
617
  </button>
412
618
  </div>
413
- <div class="flex-1 relative">
414
- <textarea x-model="llmResponse"
415
- class="absolute inset-0 w-full h-full bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
416
- placeholder="<<<<<<< SEARCH..."></textarea>
417
- </div>
619
+ <textarea x-model="llmResponse"
620
+ class="w-full h-40 bg-apple-panel border border-apple-border rounded p-3 text-sm text-gray-300 font-mono placeholder-gray-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition resize-none leading-relaxed"
621
+ placeholder="<<<<<<< SEARCH..."></textarea>
418
622
  </div>
419
623
 
420
624
  </div>
@@ -459,7 +663,8 @@ const startServer = async (rootDir: string) => {
459
663
 
460
664
  async init() {
461
665
  try {
462
- const res = await fetch('http://localhost:${PORT}/api/files');
666
+ // Sử dụng relative URL để tránh lỗi phân giải localhost/127.0.0.1 trên Windows
667
+ const res = await fetch('/api/files');
463
668
  const data = await res.json();
464
669
  this.rawFiles = data;
465
670
  this.buildTree();
@@ -469,98 +674,10 @@ const startServer = async (rootDir: string) => {
469
674
  this.loading = false;
470
675
  }
471
676
 
472
- // Search watcher
473
677
  this.$watch('search', () => this.buildTree());
474
-
475
- // Auto-generate prompt
476
678
  this.$watch('instruction', () => this.debouncedFetchPrompt());
477
679
  this.$watch('selectedFiles', () => this.debouncedFetchPrompt());
478
680
  },
479
-
480
- // ===========================
481
- // Tree Logic
482
- // ===========================
483
-
484
- buildTree() {
485
- // 1. Convert paths to object structure
486
- const root = {};
487
- const searchLower = this.search.toLowerCase();
488
-
489
- this.rawFiles.forEach(file => {
490
- if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
491
-
492
- const parts = file.path.split(/[\\\\/]/);
493
- let current = root;
494
-
495
- parts.forEach((part, index) => {
496
- if (!current[part]) {
497
- current[part] = {
498
- name: part,
499
- path: parts.slice(0, index + 1).join('/'),
500
- children: {},
501
- type: index === parts.length - 1 ? 'file' : 'folder',
502
- fullPath: file.path // Only meaningful for file
503
- };
504
- }
505
- current = current[part].children;
506
- });
507
- });
508
-
509
- // 2. Flatten object structure into renderable list with depth
510
- this.treeNodes = [];
511
- const traverse = (nodeMap, depth) => {
512
- // Sort: Folders first, then Files. Alphabetical.
513
- const keys = Object.keys(nodeMap).sort((a, b) => {
514
- const nodeA = nodeMap[a];
515
- const nodeB = nodeMap[b];
516
- if (nodeA.type !== nodeB.type) return nodeA.type === 'folder' ? -1 : 1;
517
- return a.localeCompare(b);
518
- });
519
-
520
- keys.forEach(key => {
521
- const node = nodeMap[key];
522
- const flatNode = {
523
- id: node.path,
524
- name: node.name,
525
- path: node.path, // Relative path for selection
526
- type: node.type,
527
- depth: depth,
528
- children: Object.keys(node.children).length > 0, // boolean for UI
529
- expanded: this.search.length > 0 || depth < 1, // Auto expand on search or root
530
- rawChildren: node.children // Keep ref for recursion
531
- };
532
-
533
- this.treeNodes.push(flatNode);
534
- if (flatNode.children && flatNode.expanded) {
535
- traverse(node.children, depth + 1);
536
- }
537
- });
538
- };
539
-
540
- traverse(root, 0);
541
- },
542
-
543
- // Re-calculate visible nodes (folding logic) without rebuilding everything
544
- // But for simplicity in this SFC, we just rebuild the list when expanded changes.
545
- toggleExpand(node) {
546
- if (node.type !== 'folder') return;
547
-
548
- // Find node in treeNodes and toggle
549
- // Since treeNodes is flat and generated, we need to persist state or smarter logic.
550
- // Simpler: Just modify the expanded state in the current flat list is tricky because children are dynamic.
551
- // Better approach for this lightweight UI:
552
- // We actually need a persistent "expanded" Set to survive re-renders if we want to be fancy.
553
- // For now, let's just cheat: Update the boolean in the flat list works for collapsing,
554
- // but expanding requires knowing the children.
555
-
556
- // Let's reload the tree logic but using a \`expandedPaths\` set.
557
- if (this.expandedPaths.has(node.path)) {
558
- this.expandedPaths.delete(node.path);
559
- } else {
560
- this.expandedPaths.add(node.path);
561
- }
562
- this.refreshTreeVisibility();
563
- },
564
681
 
565
682
  // Alternative: Just use a computed property for \`visibleNodes\`.
566
683
  // Since Alpine isn't React, we do this manually.
@@ -588,7 +705,8 @@ const startServer = async (rootDir: string) => {
588
705
 
589
706
  this.rawFiles.forEach(file => {
590
707
  if (this.search && !file.path.toLowerCase().includes(searchLower)) return;
591
- const parts = file.path.split(/[\\\\/]/);
708
+ // File path đã được chuẩn hóa '/' từ backend
709
+ const parts = file.path.split('/');
592
710
  let current = this.rootObject;
593
711
  parts.forEach((part, index) => {
594
712
  if (!current[part]) {
@@ -689,9 +807,8 @@ const startServer = async (rootDir: string) => {
689
807
  },
690
808
 
691
809
  getAllDescendants(folderPath) {
692
- // Simple filter from rawFiles
693
810
  return this.rawFiles
694
- .filter(f => f.path.startsWith(folderPath + '/') || f.path.startsWith(folderPath + '\\\\'))
811
+ .filter(f => f.path === folderPath || f.path.startsWith(folderPath + '/'))
695
812
  .map(f => f.path);
696
813
  },
697
814
 
@@ -724,7 +841,7 @@ const startServer = async (rootDir: string) => {
724
841
  }
725
842
  this.generating = true;
726
843
  try {
727
- const res = await fetch('http://localhost:${PORT}/api/prompt', {
844
+ const res = await fetch('/api/prompt', {
728
845
  method: 'POST',
729
846
  headers: {'Content-Type': 'application/json'},
730
847
  body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), instruction: this.instruction })
@@ -751,7 +868,7 @@ const startServer = async (rootDir: string) => {
751
868
  async applyPatch() {
752
869
  this.applying = true;
753
870
  try {
754
- const res = await fetch('http://localhost:${PORT}/api/apply', {
871
+ const res = await fetch('/api/apply', {
755
872
  method: 'POST',
756
873
  headers: {'Content-Type': 'application/json'},
757
874
  body: JSON.stringify({ filePaths: Array.from(this.selectedFiles), llmResponse: this.llmResponse })