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