@taj-special/dravix-code 1.1.28 → 1.2.0

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,8 +3,8 @@ import * as path from 'path';
3
3
  import * as os from 'os';
4
4
  const CONFIG_DIR = path.join(os.homedir(), '.dravix-code');
5
5
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
6
- const AUTH_URL = 'https://dravix.app/cli-auth';
7
- const VERIFY_URL = 'https://dravix.app/cli-auth?action=verify&token=';
6
+ const AUTH_URL = 'https://dravix.app/cli-auth.php';
7
+ const VERIFY_URL = 'https://dravix.app/cli-auth.php?action=verify&token=';
8
8
  function readConfig() {
9
9
  try {
10
10
  return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
@@ -3,14 +3,26 @@ import * as path from 'path';
3
3
  import { execSync } from 'child_process';
4
4
  const IGNORE = new Set([
5
5
  'node_modules', '.git', 'dist', 'build', '.next', 'out',
6
- '__pycache__', '.cache', 'coverage', '.turbo',
6
+ '__pycache__', '.cache', 'coverage', '.turbo', 'vendor',
7
+ '.tox', '.eggs', '.venv', 'venv', 'env', '.env',
7
8
  ]);
8
9
  const CODE_EXTS = new Set([
9
10
  '.ts', '.tsx', '.js', '.jsx', '.py', '.html', '.css', '.scss',
10
11
  '.json', '.md', '.txt', '.sh', '.php', '.sql', '.yaml', '.yml',
11
12
  '.java', '.go', '.rs', '.c', '.cpp', '.h', '.vue', '.svelte',
13
+ '.env', '.env.example', '.env.local',
12
14
  ]);
13
- export function getProjectFiles(root, maxFiles = 30) {
15
+ const CONFIG_FILES = new Set([
16
+ 'package.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js',
17
+ 'next.config.js', 'next.config.ts', 'nuxt.config.ts', 'nuxt.config.js',
18
+ 'composer.json', 'requirements.txt', 'pyproject.toml', 'Cargo.toml',
19
+ 'go.mod', 'pom.xml', 'build.gradle', 'Dockerfile', 'docker-compose.yml',
20
+ '.env', '.env.example', '.eslintrc.js', '.prettierrc',
21
+ 'tailwind.config.js', 'tailwind.config.ts', 'postcss.config.js',
22
+ 'webpack.config.js', 'rollup.config.js', 'babel.config.js',
23
+ '.gitignore', 'README.md',
24
+ ]);
25
+ export function getProjectFiles(root, maxFiles = 50) {
14
26
  const files = [];
15
27
  function walk(dir, depth = 0) {
16
28
  if (depth > 4 || files.length >= maxFiles)
@@ -22,14 +34,24 @@ export function getProjectFiles(root, maxFiles = 30) {
22
34
  catch {
23
35
  return;
24
36
  }
25
- for (const e of entries) {
37
+ // Sort: config files first, then directories, then files
38
+ const sorted = entries.sort((a, b) => {
39
+ const aConfig = CONFIG_FILES.has(a.name) ? 0 : 1;
40
+ const bConfig = CONFIG_FILES.has(b.name) ? 0 : 1;
41
+ if (aConfig !== bConfig)
42
+ return aConfig - bConfig;
43
+ if (a.isDirectory() !== b.isDirectory())
44
+ return a.isDirectory() ? -1 : 1;
45
+ return a.name.localeCompare(b.name);
46
+ });
47
+ for (const e of sorted) {
26
48
  if (IGNORE.has(e.name) || e.name.startsWith('.'))
27
49
  continue;
28
50
  const fullPath = path.join(dir, e.name);
29
51
  if (e.isDirectory()) {
30
52
  walk(fullPath, depth + 1);
31
53
  }
32
- else if (CODE_EXTS.has(path.extname(e.name).toLowerCase())) {
54
+ else if (CODE_EXTS.has(path.extname(e.name).toLowerCase()) || CONFIG_FILES.has(e.name)) {
33
55
  files.push(path.relative(root, fullPath));
34
56
  }
35
57
  }
@@ -61,6 +83,19 @@ export function getGitStatus(root) {
61
83
  return '';
62
84
  }
63
85
  }
86
+ export function getGitBranch(root) {
87
+ try {
88
+ return execSync('git branch --show-current', {
89
+ cwd: root,
90
+ encoding: 'utf-8',
91
+ timeout: 5000,
92
+ stdio: ['pipe', 'pipe', 'pipe'],
93
+ }).trim();
94
+ }
95
+ catch {
96
+ return '';
97
+ }
98
+ }
64
99
  const STOP_WORDS = new Set([
65
100
  'this', 'that', 'with', 'from', 'have', 'will', 'what', 'when', 'where', 'make', 'into',
66
101
  'does', 'should', 'would', 'could', 'then', 'they', 'your', 'just', 'also', 'more',
@@ -130,6 +165,7 @@ function detectSeparateProjects(root, files) {
130
165
  const PROJECT_FILES = new Set([
131
166
  'package.json', 'composer.json', 'config.php', 'index.php', 'index.js',
132
167
  'index.ts', 'main.py', 'app.py', 'bot.php', 'main.go', 'pom.xml',
168
+ 'requirements.txt', 'pyproject.toml', 'Cargo.toml', 'go.mod',
133
169
  ]);
134
170
  const projectDirs = [];
135
171
  for (const [dir, dirFiles] of topDirFiles) {
@@ -138,9 +174,50 @@ function detectSeparateProjects(root, files) {
138
174
  }
139
175
  return projectDirs;
140
176
  }
177
+ // Detect project type from files
178
+ function detectProjectType(files) {
179
+ const fileNames = files.map(f => path.basename(f).toLowerCase());
180
+ const fileExts = files.map(f => path.extname(f).toLowerCase());
181
+ if (fileNames.includes('package.json')) {
182
+ if (fileNames.includes('next.config.js') || fileNames.includes('next.config.ts'))
183
+ return 'Next.js';
184
+ if (fileNames.includes('nuxt.config.ts') || fileNames.includes('nuxt.config.js'))
185
+ return 'Nuxt.js';
186
+ if (fileNames.includes('vite.config.ts') || fileNames.includes('vite.config.js'))
187
+ return 'Vite';
188
+ if (fileNames.includes('angular.json'))
189
+ return 'Angular';
190
+ if (fileNames.includes('vue.config.js'))
191
+ return 'Vue.js';
192
+ if (fileExts.includes('.tsx') || fileExts.includes('.jsx'))
193
+ return 'React';
194
+ return 'Node.js';
195
+ }
196
+ if (fileNames.includes('composer.json'))
197
+ return 'Laravel/PHP';
198
+ if (fileNames.includes('requirements.txt') || fileNames.includes('pyproject.toml')) {
199
+ if (fileNames.includes('manage.py'))
200
+ return 'Django';
201
+ if (fileNames.includes('app.py') || fileNames.includes('flask'))
202
+ return 'Flask';
203
+ return 'Python';
204
+ }
205
+ if (fileNames.includes('go.mod'))
206
+ return 'Go';
207
+ if (fileNames.includes('Cargo.toml'))
208
+ return 'Rust';
209
+ if (fileNames.includes('pom.xml') || fileNames.includes('build.gradle'))
210
+ return 'Java';
211
+ if (fileExts.includes('.vue'))
212
+ return 'Vue.js';
213
+ if (fileExts.includes('.svelte'))
214
+ return 'Svelte';
215
+ return 'Unknown';
216
+ }
141
217
  export function buildContext(root) {
142
218
  const files = getProjectFiles(root);
143
219
  const git = getGitStatus(root);
220
+ const branch = getGitBranch(root);
144
221
  const projectName = path.basename(root);
145
222
  const shell = process.platform === 'win32' ? 'Windows — PowerShell'
146
223
  : process.platform === 'darwin' ? 'macOS — bash/zsh'
@@ -151,11 +228,33 @@ export function buildContext(root) {
151
228
  `RULE: NEVER modify files inside existing project folders (${separateProjects.join(', ')}) unless the user explicitly asks to work on that specific project.\n` +
152
229
  `When creating a NEW project, ALWAYS create a NEW dedicated subfolder.`
153
230
  : '';
154
- return `Project: ${projectName}
231
+ const projectType = detectProjectType(files);
232
+ // Auto-read key config files for better context
233
+ const configContents = [];
234
+ const configToRead = ['package.json', 'tsconfig.json', 'composer.json', 'requirements.txt'];
235
+ for (const cfg of configToRead) {
236
+ const cfgPath = path.join(root, cfg);
237
+ if (fs.existsSync(cfgPath)) {
238
+ try {
239
+ const content = fs.readFileSync(cfgPath, 'utf-8');
240
+ if (content.length < 3000) {
241
+ configContents.push(`[${cfg}]:\n${content}`);
242
+ }
243
+ }
244
+ catch { }
245
+ }
246
+ }
247
+ const gitInfo = branch ? `Branch: ${branch}\nGit status:\n${git || '(clean)'}` : `Git status:\n${git || '(clean)'}`;
248
+ let context = `Project: ${projectName}
249
+ Type: ${projectType}
155
250
  Working dir: ${root}
156
251
  OS: ${shell}
157
- Git status:
158
- ${git || '(clean)'}
252
+ ${gitInfo}
159
253
  Files:
160
- ${files.length > 0 ? files.join('\n') : '(empty project)'}${parentDirWarning}`;
254
+ ${files.length > 0 ? files.join('\n') : '(empty project)'}`;
255
+ if (configContents.length > 0) {
256
+ context += `\n\nKey config files:\n${configContents.join('\n\n')}`;
257
+ }
258
+ context += parentDirWarning;
259
+ return context;
161
260
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { spawn } from 'child_process';
4
+ import { backupFile, recordWrite, recordEdit, recordDelete, startUndoSession, saveUndoSession } from './undo.js';
4
5
  const SKIP_DIRS = new Set([
5
6
  'node_modules', '.git', 'dist', 'build', '.next', 'out',
6
7
  '.cache', 'coverage', '__pycache__', '.venv', 'venv', 'env',
@@ -139,9 +140,20 @@ export function parseOps(text) {
139
140
  while ((m = readFolderRe.exec(text)) !== null) {
140
141
  ops.push({ type: 'read_folder', path: m[2] });
141
142
  }
142
- const searchRe = /<search_code\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?\s*(?:\/>|><\/search_code>)/g;
143
+ const searchRe = /<search_code\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?(?:\s+include=(["'])([^"']*)\5)?(?:\s+context=(["'])(\d+)\7)?(?:\s+regex=(["'])(true|false)\9)?\s*(?:\/>|><\/search_code>)/g;
143
144
  while ((m = searchRe.exec(text)) !== null) {
144
- ops.push({ type: 'search_code', pattern: m[2], path: m[4] || undefined });
145
+ ops.push({
146
+ type: 'search_code',
147
+ pattern: m[2],
148
+ path: m[4] || undefined,
149
+ include: m[6] || undefined,
150
+ context: m[8] ? parseInt(m[8]) : undefined,
151
+ regex: m[10] === 'true' ? true : m[10] === 'false' ? false : undefined,
152
+ });
153
+ }
154
+ const globRe = /<glob_files\s+pattern=(["'])([^"']+)\1(?:\s+path=(["'])([^"']*)\3)?\s*(?:\/>|><\/glob_files>)/g;
155
+ while ((m = globRe.exec(text)) !== null) {
156
+ ops.push({ type: 'glob_files', pattern: m[2], path: m[4] || undefined });
145
157
  }
146
158
  // suppress unused-variable warnings for Q / AV helpers used only for readability
147
159
  void Q;
@@ -158,6 +170,10 @@ export async function executeSingleOp(op, cwd, onStage) {
158
170
  return { type: 'error', message: `Access denied: ${op.path} is outside the project directory` };
159
171
  const exists = fs.existsSync(fullPath);
160
172
  stage(exists ? `Writing ${op.path}` : `Creating ${op.path}`);
173
+ // Backup before write (for undo)
174
+ const backupPath = exists ? backupFile(fullPath, cwd) : null;
175
+ if (exists)
176
+ recordWrite(fullPath, backupPath);
161
177
  const oldContent = exists ? fs.readFileSync(fullPath, 'utf-8') : '';
162
178
  if (exists && oldContent === op.content)
163
179
  return { type: 'skipped', path: op.path };
@@ -305,6 +321,9 @@ export async function executeSingleOp(op, cwd, onStage) {
305
321
  };
306
322
  }
307
323
  stage(`Applying ${resolvedPath}`);
324
+ // Backup before edit (for undo)
325
+ const editBackupPath = backupFile(fullPath, cwd);
326
+ recordEdit(fullPath, editBackupPath);
308
327
  const newContent = oldContent.replace(actualFind, op.replace);
309
328
  // No actual change — skip write and return skipped
310
329
  if (newContent === oldContent)
@@ -374,6 +393,8 @@ export async function executeSingleOp(op, cwd, onStage) {
374
393
  fullPath = foundFull;
375
394
  }
376
395
  stage(`Deleting ${resolvedPath}`);
396
+ // Record for undo before deleting
397
+ recordDelete(fullPath);
377
398
  const stat = fs.statSync(fullPath);
378
399
  if (stat.isDirectory()) {
379
400
  fs.rmSync(fullPath, { recursive: true, force: true });
@@ -627,9 +648,29 @@ export async function executeSingleOp(op, cwd, onStage) {
627
648
  '.json', '.yaml', '.yml', '.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh',
628
649
  '.env', '.sql', '.graphql', '.prisma', '.proto', '.swift', '.kt', '.dart',
629
650
  ]);
651
+ // Build regex or literal matcher
652
+ let matcher;
653
+ let useRegex = op.regex ?? false;
654
+ if (useRegex) {
655
+ try {
656
+ const re = new RegExp(op.pattern, 'i');
657
+ matcher = (line) => re.test(line);
658
+ }
659
+ catch {
660
+ // Invalid regex — fall back to literal
661
+ const patternLower = op.pattern.toLowerCase();
662
+ matcher = (line) => line.toLowerCase().includes(patternLower);
663
+ }
664
+ }
665
+ else {
666
+ const patternLower = op.pattern.toLowerCase();
667
+ matcher = (line) => line.toLowerCase().includes(patternLower);
668
+ }
669
+ // File extension filter
670
+ const includeFilter = op.include ? op.include.toLowerCase().replace(/^\*\./, '.') : null;
671
+ const contextLines = op.context ?? 0;
630
672
  const matches = [];
631
673
  const MAX_MATCHES = 200;
632
- const patternLower = op.pattern.toLowerCase();
633
674
  function walkSearch(dir) {
634
675
  if (matches.length >= MAX_MATCHES)
635
676
  return;
@@ -652,7 +693,10 @@ export async function executeSingleOp(op, cwd, onStage) {
652
693
  walkSearch(eFull);
653
694
  }
654
695
  else {
655
- if (!TEXT_EXTS.has(path.extname(entry.name).toLowerCase()))
696
+ const ext = path.extname(entry.name).toLowerCase();
697
+ if (!TEXT_EXTS.has(ext))
698
+ continue;
699
+ if (includeFilter && ext !== includeFilter)
656
700
  continue;
657
701
  let content;
658
702
  try {
@@ -666,8 +710,14 @@ export async function executeSingleOp(op, cwd, onStage) {
666
710
  for (let i = 0; i < fileLines.length; i++) {
667
711
  if (matches.length >= MAX_MATCHES)
668
712
  break;
669
- if (fileLines[i].toLowerCase().includes(patternLower)) {
670
- matches.push({ file: relFile, line: i + 1, content: fileLines[i].trim().slice(0, 200) });
713
+ if (matcher(fileLines[i])) {
714
+ const before = contextLines > 0
715
+ ? fileLines.slice(Math.max(0, i - contextLines), i).map(l => ` ${l}`)
716
+ : undefined;
717
+ const after = contextLines > 0
718
+ ? fileLines.slice(i + 1, Math.min(fileLines.length, i + 1 + contextLines)).map(l => ` ${l}`)
719
+ : undefined;
720
+ matches.push({ file: relFile, line: i + 1, content: fileLines[i].trim().slice(0, 200), before, after });
671
721
  }
672
722
  }
673
723
  }
@@ -686,7 +736,11 @@ export async function executeSingleOp(op, cwd, onStage) {
686
736
  const outLines = [];
687
737
  for (const [file, fileMatches] of byFile) {
688
738
  for (const fm of fileMatches) {
739
+ if (fm.before)
740
+ outLines.push(...fm.before);
689
741
  outLines.push(`${file}:${fm.line}: ${fm.content}`);
742
+ if (fm.after)
743
+ outLines.push(...fm.after);
690
744
  }
691
745
  }
692
746
  if (matches.length >= MAX_MATCHES)
@@ -697,12 +751,194 @@ export async function executeSingleOp(op, cwd, onStage) {
697
751
  catch (e) {
698
752
  return { type: 'error', message: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
699
753
  }
754
+ // ── glob_files ────────────────────────────────────────────────
755
+ }
756
+ else if (op.type === 'glob_files' && op.pattern) {
757
+ try {
758
+ const searchRoot = safeResolvePath(cwd, op.path ?? '.');
759
+ if (!searchRoot)
760
+ return { type: 'error', message: `Access denied: path is outside the project directory` };
761
+ stage(`Globbing ${op.pattern.slice(0, 45)}`);
762
+ // Parse glob pattern: support **, *, ?, and negation with !
763
+ const pattern = op.pattern;
764
+ const isRecursive = pattern.includes('**');
765
+ // Extract file extension or name pattern from glob
766
+ // e.g., "**/*.ts" → match all .ts files recursively
767
+ // e.g., "src/**/*.tsx" → match .tsx files in src/ recursively
768
+ // e.g., "*.json" → match .json files in current dir only
769
+ const extMatch = pattern.match(/\*(?:\.\w+)?$/);
770
+ const targetExt = extMatch ? extMatch[0].replace(/^\*\./, '.') : null;
771
+ const nameMatch = pattern.match(/\/([^/*?]+)$/);
772
+ const targetName = nameMatch ? nameMatch[1] : null;
773
+ // Extract base directory from pattern
774
+ const baseDir = pattern.includes('/')
775
+ ? pattern.split('*')[0].replace(/\/$/, '')
776
+ : '';
777
+ const resolvedBase = baseDir ? path.join(searchRoot, baseDir) : searchRoot;
778
+ const files = [];
779
+ const MAX_FILES = 500;
780
+ function walkGlob(dir, depth = 0) {
781
+ if (files.length >= MAX_FILES)
782
+ return;
783
+ if (depth > (isRecursive ? 20 : 1))
784
+ return;
785
+ let de;
786
+ try {
787
+ de = fs.readdirSync(dir, { withFileTypes: true });
788
+ }
789
+ catch {
790
+ return;
791
+ }
792
+ for (const entry of de) {
793
+ if (files.length >= MAX_FILES)
794
+ return;
795
+ if (SKIP_DIRS.has(entry.name))
796
+ continue;
797
+ if (entry.name.startsWith('.') && entry.name !== '.env' && entry.name !== '.gitignore')
798
+ continue;
799
+ const eFull = path.join(dir, entry.name);
800
+ if (entry.isDirectory()) {
801
+ if (isRecursive || depth === 0)
802
+ walkGlob(eFull, depth + 1);
803
+ }
804
+ else {
805
+ // Match against pattern
806
+ let matches = false;
807
+ if (targetName) {
808
+ matches = entry.name === targetName;
809
+ }
810
+ else if (targetExt) {
811
+ matches = path.extname(entry.name).toLowerCase() === targetExt;
812
+ }
813
+ else {
814
+ // Simple wildcard match
815
+ const nameParts = pattern.split('*');
816
+ if (nameParts.length === 2) {
817
+ matches = entry.name.startsWith(nameParts[0]) && entry.name.endsWith(nameParts[1]);
818
+ }
819
+ else {
820
+ matches = true;
821
+ }
822
+ }
823
+ if (matches) {
824
+ try {
825
+ const stat = fs.statSync(eFull);
826
+ files.push({
827
+ path: path.relative(cwd, eFull).replace(/\\/g, '/'),
828
+ size: stat.size,
829
+ modified: stat.mtime.toISOString().split('T')[0],
830
+ });
831
+ }
832
+ catch {
833
+ files.push({
834
+ path: path.relative(cwd, eFull).replace(/\\/g, '/'),
835
+ size: 0,
836
+ modified: '',
837
+ });
838
+ }
839
+ }
840
+ }
841
+ }
842
+ }
843
+ if (fs.existsSync(resolvedBase)) {
844
+ walkGlob(resolvedBase);
845
+ }
846
+ // Sort by path
847
+ files.sort((a, b) => a.path.localeCompare(b.path));
848
+ if (files.length === 0) {
849
+ return { type: 'run', message: `glob:${op.pattern}`, output: `No files matched pattern "${op.pattern}"` };
850
+ }
851
+ const outLines = files.map(f => {
852
+ const sizeStr = f.size > 1024 * 1024 ? `${(f.size / 1024 / 1024).toFixed(1)}MB`
853
+ : f.size > 1024 ? `${(f.size / 1024).toFixed(1)}KB`
854
+ : `${f.size}B`;
855
+ return `${f.path} ${sizeStr} ${f.modified}`;
856
+ });
857
+ if (files.length >= MAX_FILES)
858
+ outLines.push(`... truncated at ${MAX_FILES} files`);
859
+ outLines.push(`\nTotal: ${files.length} file${files.length !== 1 ? 's' : ''}`);
860
+ return { type: 'run', message: `glob:${op.pattern}`, output: outLines.join('\n') };
861
+ }
862
+ catch (e) {
863
+ return { type: 'error', message: `Glob failed: ${e instanceof Error ? e.message : String(e)}` };
864
+ }
700
865
  }
701
866
  return { type: 'error', message: 'Unknown operation' };
702
867
  }
703
868
  export async function executeOps(ops, cwd) {
869
+ // Start undo session for this batch of operations
870
+ startUndoSession();
871
+ const results = [];
872
+ // Separate read-only ops from write/mutation ops
873
+ const READ_OPS = new Set(['read_file', 'read_folder', 'search_code', 'glob_files']);
874
+ const readOps = [];
875
+ const writeOps = [];
876
+ for (let i = 0; i < ops.length; i++) {
877
+ if (READ_OPS.has(ops[i].type)) {
878
+ readOps.push({ idx: i, op: ops[i] });
879
+ }
880
+ else {
881
+ writeOps.push({ idx: i, op: ops[i] });
882
+ }
883
+ }
884
+ // Initialize results array
885
+ for (let i = 0; i < ops.length; i++) {
886
+ results[i] = { type: 'skipped', message: 'pending' };
887
+ }
888
+ // Execute read-only ops in parallel (max 6 concurrent)
889
+ const MAX_CONCURRENT = 6;
890
+ for (let i = 0; i < readOps.length; i += MAX_CONCURRENT) {
891
+ const batch = readOps.slice(i, i + MAX_CONCURRENT);
892
+ const batchResults = await Promise.all(batch.map(({ op }) => executeSingleOp(op, cwd)));
893
+ for (let j = 0; j < batch.length; j++) {
894
+ results[batch[j].idx] = batchResults[j];
895
+ }
896
+ }
897
+ // Execute write ops sequentially with atomic rollback on failure
898
+ const executedWrites = [];
899
+ let atomicFailed = false;
900
+ for (const { idx, op } of writeOps) {
901
+ const result = await executeSingleOp(op, cwd);
902
+ results[idx] = result;
903
+ if (result.type === 'error') {
904
+ // Atomic mode: if any write fails, rollback all previous writes
905
+ atomicFailed = true;
906
+ for (const prev of executedWrites) {
907
+ // Try to rollback each previous operation
908
+ try {
909
+ if (prev.op.type === 'write' && prev.op.path) {
910
+ const fullPath = path.resolve(cwd, prev.op.path);
911
+ if (prev.result.type === 'modified' || prev.result.type === 'created') {
912
+ // Restore from backup if available
913
+ const backupDir = path.join(require('os').homedir(), '.dravix-code', 'undo');
914
+ const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.json')).sort().reverse();
915
+ if (files.length > 0) {
916
+ const session = JSON.parse(fs.readFileSync(path.join(backupDir, files[0]), 'utf-8'));
917
+ const backup = session.operations?.find((o) => o.path === fullPath && o.backupPath);
918
+ if (backup?.backupPath && fs.existsSync(backup.backupPath)) {
919
+ fs.copyFileSync(backup.backupPath, fullPath);
920
+ }
921
+ }
922
+ }
923
+ }
924
+ }
925
+ catch { /* ignore rollback errors */ }
926
+ }
927
+ break; // Stop executing remaining writes
928
+ }
929
+ executedWrites.push({ idx, op, result });
930
+ }
931
+ // Save undo session after all operations complete
932
+ saveUndoSession();
933
+ return results;
934
+ }
935
+ // Execute all ops in parallel (for use when order doesn't matter, e.g., all reads)
936
+ export async function executeOpsParallel(ops, cwd, maxConcurrent = 6) {
704
937
  const results = [];
705
- for (const op of ops)
706
- results.push(await executeSingleOp(op, cwd));
938
+ for (let i = 0; i < ops.length; i += maxConcurrent) {
939
+ const batch = ops.slice(i, i + maxConcurrent);
940
+ const batchResults = await Promise.all(batch.map(op => executeSingleOp(op, cwd)));
941
+ results.push(...batchResults);
942
+ }
707
943
  return results;
708
944
  }