janito 0.8.0__py3-none-any.whl → 0.9.0__py3-none-any.whl

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.
Files changed (59) hide show
  1. janito/__init__.py +5 -0
  2. janito/__main__.py +143 -120
  3. janito/callbacks.py +130 -0
  4. janito/cli.py +202 -0
  5. janito/config.py +63 -100
  6. janito/data/instructions.txt +6 -0
  7. janito/test_file.py +4 -0
  8. janito/token_report.py +73 -0
  9. janito/tools/__init__.py +10 -0
  10. janito/tools/decorators.py +84 -0
  11. janito/tools/delete_file.py +44 -0
  12. janito/tools/find_files.py +154 -0
  13. janito/tools/search_text.py +197 -0
  14. janito/tools/str_replace_editor/__init__.py +6 -0
  15. janito/tools/str_replace_editor/editor.py +43 -0
  16. janito/tools/str_replace_editor/handlers.py +338 -0
  17. janito/tools/str_replace_editor/utils.py +88 -0
  18. {janito-0.8.0.dist-info/licenses → janito-0.9.0.dist-info}/LICENSE +2 -2
  19. janito-0.9.0.dist-info/METADATA +9 -0
  20. janito-0.9.0.dist-info/RECORD +23 -0
  21. {janito-0.8.0.dist-info → janito-0.9.0.dist-info}/WHEEL +2 -1
  22. janito-0.9.0.dist-info/entry_points.txt +2 -0
  23. janito-0.9.0.dist-info/top_level.txt +1 -0
  24. janito/agents/__init__.py +0 -22
  25. janito/agents/agent.py +0 -25
  26. janito/agents/claudeai.py +0 -41
  27. janito/agents/deepseekai.py +0 -47
  28. janito/change/applied_blocks.py +0 -34
  29. janito/change/applier.py +0 -167
  30. janito/change/edit_blocks.py +0 -148
  31. janito/change/finder.py +0 -72
  32. janito/change/request.py +0 -144
  33. janito/change/validator.py +0 -87
  34. janito/change/view/content.py +0 -63
  35. janito/change/view/diff.py +0 -44
  36. janito/change/view/panels.py +0 -201
  37. janito/change/view/sections.py +0 -69
  38. janito/change/view/styling.py +0 -140
  39. janito/change/view/summary.py +0 -37
  40. janito/change/view/themes.py +0 -62
  41. janito/change/view/viewer.py +0 -59
  42. janito/cli/__init__.py +0 -2
  43. janito/cli/commands.py +0 -68
  44. janito/cli/functions.py +0 -66
  45. janito/common.py +0 -133
  46. janito/data/change_prompt.txt +0 -81
  47. janito/data/system_prompt.txt +0 -3
  48. janito/qa.py +0 -56
  49. janito/version.py +0 -23
  50. janito/workspace/__init__.py +0 -8
  51. janito/workspace/analysis.py +0 -121
  52. janito/workspace/models.py +0 -97
  53. janito/workspace/show.py +0 -115
  54. janito/workspace/stats.py +0 -42
  55. janito/workspace/workset.py +0 -135
  56. janito/workspace/workspace.py +0 -335
  57. janito-0.8.0.dist-info/METADATA +0 -106
  58. janito-0.8.0.dist-info/RECORD +0 -40
  59. janito-0.8.0.dist-info/entry_points.txt +0 -2
@@ -0,0 +1,197 @@
1
+ import os
2
+ import fnmatch
3
+ import re
4
+ import pathlib
5
+ from typing import List, Dict, Any, Tuple
6
+ from janito.tools.decorators import tool_meta
7
+
8
+
9
+ @tool_meta(label="Searching for '{text_pattern}' in files matching '{file_pattern}'")
10
+ def search_text(text_pattern: str, file_pattern: str = "*", root_dir: str = ".", recursive: bool = True, respect_gitignore: bool = True) -> Tuple[str, bool]:
11
+ """
12
+ Search for text patterns within files matching a filename pattern.
13
+
14
+ Args:
15
+ text_pattern: Text pattern to search for within files
16
+ file_pattern: Pattern to match file names against (default: "*" - all files)
17
+ root_dir: Root directory to start search from (default: current directory)
18
+ recursive: Whether to search recursively in subdirectories (default: True)
19
+ respect_gitignore: Whether to respect .gitignore files (default: True)
20
+
21
+ Returns:
22
+ A tuple containing (message, is_error)
23
+ """
24
+ try:
25
+ # Convert to absolute path if relative
26
+ abs_root = os.path.abspath(root_dir)
27
+
28
+ if not os.path.isdir(abs_root):
29
+ return f"Error: Directory '{root_dir}' does not exist", True
30
+
31
+ # Compile the regex pattern for better performance
32
+ try:
33
+ regex = re.compile(text_pattern)
34
+ except re.error as e:
35
+ return f"Error: Invalid regex pattern '{text_pattern}': {str(e)}", True
36
+
37
+ matching_files = []
38
+ match_count = 0
39
+ results = []
40
+
41
+ # Get gitignore patterns if needed
42
+ ignored_patterns = []
43
+ if respect_gitignore:
44
+ ignored_patterns = _get_gitignore_patterns(abs_root)
45
+
46
+ # Use os.walk for recursive behavior
47
+ if recursive:
48
+ for dirpath, dirnames, filenames in os.walk(abs_root):
49
+ # Skip ignored directories
50
+ if respect_gitignore:
51
+ dirnames[:] = [d for d in dirnames if not _is_ignored(os.path.join(dirpath, d), ignored_patterns, abs_root)]
52
+
53
+ for filename in fnmatch.filter(filenames, file_pattern):
54
+ file_path = os.path.join(dirpath, filename)
55
+
56
+ # Skip ignored files
57
+ if respect_gitignore and _is_ignored(file_path, ignored_patterns, abs_root):
58
+ continue
59
+
60
+ file_matches = _search_file(file_path, regex, abs_root)
61
+ if file_matches:
62
+ matching_files.append(file_path)
63
+ match_count += len(file_matches)
64
+ results.append(f"\n{os.path.relpath(file_path, abs_root)} ({len(file_matches)} matches):")
65
+ results.extend(file_matches)
66
+ else:
67
+ # Non-recursive mode - only search in the specified directory
68
+ for filename in fnmatch.filter(os.listdir(abs_root), file_pattern):
69
+ file_path = os.path.join(abs_root, filename)
70
+
71
+ # Skip ignored files
72
+ if respect_gitignore and _is_ignored(file_path, ignored_patterns, abs_root):
73
+ continue
74
+
75
+ if os.path.isfile(file_path):
76
+ file_matches = _search_file(file_path, regex, abs_root)
77
+ if file_matches:
78
+ matching_files.append(file_path)
79
+ match_count += len(file_matches)
80
+ results.append(f"\n{os.path.relpath(file_path, abs_root)} ({len(file_matches)} matches):")
81
+ results.extend(file_matches)
82
+
83
+ if matching_files:
84
+ result_text = "\n".join(results)
85
+ summary = f"\n{match_count} matches in {len(matching_files)} files"
86
+ return f"Searching for '{text_pattern}' in files matching '{file_pattern}':{result_text}\n{summary}", False
87
+ else:
88
+ return f"No matches found for '{text_pattern}' in files matching '{file_pattern}' in '{root_dir}'", False
89
+
90
+ except Exception as e:
91
+ return f"Error searching text: {str(e)}", True
92
+
93
+
94
+ def _search_file(file_path: str, pattern: re.Pattern, root_dir: str) -> List[str]:
95
+ """
96
+ Search for regex pattern in a file and return matching lines with line numbers.
97
+
98
+ Args:
99
+ file_path: Path to the file to search
100
+ pattern: Compiled regex pattern to search for
101
+ root_dir: Root directory (for path display)
102
+
103
+ Returns:
104
+ List of formatted matches with line numbers and content
105
+ """
106
+ matches = []
107
+ try:
108
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
109
+ for i, line in enumerate(f, 1):
110
+ if pattern.search(line):
111
+ # Truncate long lines for display
112
+ display_line = line.strip()
113
+ if len(display_line) > 100:
114
+ display_line = display_line[:97] + "..."
115
+ matches.append(f" Line {i}: {display_line}")
116
+ except (UnicodeDecodeError, IOError) as e:
117
+ # Skip binary files or files with encoding issues
118
+ pass
119
+ return matches
120
+
121
+
122
+ def _get_gitignore_patterns(root_dir: str) -> List[str]:
123
+ """
124
+ Get patterns from .gitignore files.
125
+
126
+ Args:
127
+ root_dir: Root directory to start from
128
+
129
+ Returns:
130
+ List of gitignore patterns
131
+ """
132
+ patterns = []
133
+
134
+ # Check for .gitignore in the root directory
135
+ gitignore_path = os.path.join(root_dir, '.gitignore')
136
+ if os.path.isfile(gitignore_path):
137
+ try:
138
+ with open(gitignore_path, 'r', encoding='utf-8') as f:
139
+ for line in f:
140
+ line = line.strip()
141
+ # Skip empty lines and comments
142
+ if line and not line.startswith('#'):
143
+ patterns.append(line)
144
+ except Exception:
145
+ pass
146
+
147
+ # Add common patterns that are always ignored
148
+ common_patterns = [
149
+ '.git/', '.venv/', 'venv/', '__pycache__/', '*.pyc',
150
+ '*.pyo', '*.pyd', '.DS_Store', '*.so', '*.egg-info/'
151
+ ]
152
+ patterns.extend(common_patterns)
153
+
154
+ return patterns
155
+
156
+
157
+ def _is_ignored(path: str, patterns: List[str], root_dir: str) -> bool:
158
+ """
159
+ Check if a path should be ignored based on gitignore patterns.
160
+
161
+ Args:
162
+ path: Path to check
163
+ patterns: List of gitignore patterns
164
+ root_dir: Root directory for relative paths
165
+
166
+ Returns:
167
+ True if the path should be ignored, False otherwise
168
+ """
169
+ # Get the relative path from the root directory
170
+ rel_path = os.path.relpath(path, root_dir)
171
+
172
+ # Convert to forward slashes for consistency with gitignore patterns
173
+ rel_path = rel_path.replace(os.sep, '/')
174
+
175
+ # Add trailing slash for directories
176
+ if os.path.isdir(path) and not rel_path.endswith('/'):
177
+ rel_path += '/'
178
+
179
+ for pattern in patterns:
180
+ # Handle negation patterns (those starting with !)
181
+ if pattern.startswith('!'):
182
+ continue # Skip negation patterns for simplicity
183
+
184
+ # Handle directory-specific patterns (those ending with /)
185
+ if pattern.endswith('/'):
186
+ if os.path.isdir(path) and fnmatch.fnmatch(rel_path, pattern + '*'):
187
+ return True
188
+
189
+ # Handle file patterns
190
+ if fnmatch.fnmatch(rel_path, pattern):
191
+ return True
192
+
193
+ # Handle patterns without wildcards as path prefixes
194
+ if '*' not in pattern and '?' not in pattern and rel_path.startswith(pattern):
195
+ return True
196
+
197
+ return False
@@ -0,0 +1,6 @@
1
+ """
2
+ Package for implementing the Claude text editor functionality.
3
+ """
4
+ from .editor import str_replace_editor
5
+
6
+ __all__ = ["str_replace_editor"]
@@ -0,0 +1,43 @@
1
+ """
2
+ Main module for implementing the Claude text editor functionality.
3
+ """
4
+ from typing import Dict, Any, Tuple
5
+ from .handlers import (
6
+ handle_create,
7
+ handle_view,
8
+ handle_str_replace,
9
+ handle_insert,
10
+ handle_undo_edit
11
+ )
12
+ from .utils import normalize_path
13
+ from janito.tools.decorators import tool_meta
14
+
15
+ @tool_meta(label="Editing file: {file_path} ({command})")
16
+ def str_replace_editor(**kwargs) -> Tuple[str, bool]:
17
+ """
18
+ Handle text editor tool requests from Claude.
19
+ Implements the Claude text editor tool specification.
20
+
21
+ Args:
22
+ **kwargs: All arguments passed to the tool, including:
23
+ - command: The command to execute (view, create, str_replace, insert, undo_edit)
24
+ - path: Path to the file
25
+ - Additional command-specific arguments
26
+
27
+ Returns:
28
+ A tuple containing (message, is_error)
29
+ """
30
+ command = kwargs.get("command")
31
+
32
+ if command == "create":
33
+ return handle_create(kwargs)
34
+ elif command == "view":
35
+ return handle_view(kwargs)
36
+ elif command == "str_replace":
37
+ return handle_str_replace(kwargs)
38
+ elif command == "insert":
39
+ return handle_insert(kwargs)
40
+ elif command == "undo_edit":
41
+ return handle_undo_edit(kwargs)
42
+ else:
43
+ return (f"Command '{command}' not implemented yet", True)
@@ -0,0 +1,338 @@
1
+ """
2
+ Command handlers for the str_replace_editor package.
3
+ """
4
+ import os
5
+ import pathlib
6
+ from typing import Dict, Any, Tuple
7
+ from janito.config import get_config
8
+ from .utils import normalize_path, _file_history, backup_file, get_backup_info
9
+
10
+ def handle_create(args: Dict[str, Any]) -> Tuple[str, bool]:
11
+ """
12
+ Create a new file with the specified content.
13
+
14
+ Args:
15
+ args: Dictionary containing:
16
+ - path: Path to the file to create
17
+ - file_text: Content to write to the file
18
+
19
+ Returns:
20
+ A tuple containing (message, is_error)
21
+ """
22
+ path = args.get("path")
23
+ file_text = args.get("file_text", "")
24
+
25
+ if not path:
26
+ return ("Missing required parameter: path", True)
27
+
28
+ path = normalize_path(path)
29
+
30
+ # Convert to Path object for better path handling
31
+ file_path = pathlib.Path(path)
32
+
33
+ # Check if the file already exists
34
+ if file_path.exists():
35
+ return (f"File {path} already exists", True)
36
+
37
+ # Create parent directories if they don't exist
38
+ file_path.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+ # Write the content to the file
41
+ try:
42
+ with open(file_path, 'w', encoding='utf-8') as f:
43
+ f.write(file_text)
44
+ # Show relative path if it's not an absolute path
45
+ display_path = path if os.path.isabs(path) else os.path.relpath(file_path, get_config().workspace_dir)
46
+ return (f"Successfully created file {display_path}", False)
47
+ except Exception as e:
48
+ return (f"Error creating file {path}: {str(e)}", True)
49
+
50
+
51
+ def handle_view(args: Dict[str, Any]) -> Tuple[str, bool]:
52
+ """
53
+ View the contents of a file or list directory contents.
54
+
55
+ Args:
56
+ args: Dictionary containing:
57
+ - path: Path to the file or directory to view
58
+ - view_range (optional): Array of two integers specifying start and end line numbers
59
+
60
+ Returns:
61
+ A tuple containing (content_or_message, is_error)
62
+ """
63
+ path = args.get("path")
64
+ view_range = args.get("view_range")
65
+
66
+ if not path:
67
+ return ("Missing required parameter: path", True)
68
+
69
+ path = normalize_path(path)
70
+ file_path = pathlib.Path(path)
71
+
72
+ if not file_path.exists():
73
+ return (f"File or directory {path} does not exist", True)
74
+
75
+ # If the path is a directory, list its contents
76
+ if file_path.is_dir():
77
+ try:
78
+ # Get all files and directories in the directory
79
+ items = list(file_path.iterdir())
80
+
81
+ # Sort items (directories first, then files)
82
+ dirs = [item.name + "/" for item in items if item.is_dir()]
83
+ files = [item.name for item in items if item.is_file()]
84
+
85
+ dirs.sort()
86
+ files.sort()
87
+
88
+ # Combine the lists
89
+ contents = dirs + files
90
+
91
+ if not contents:
92
+ return (f"Directory {path} is empty", False)
93
+
94
+ # Add count information to the output
95
+ dir_count = len(dirs)
96
+ file_count = len(files)
97
+ count_info = f"Total: {len(contents)} ({dir_count} directories, {file_count} files)"
98
+
99
+ return ("\n".join(contents) + f"\n{count_info}", False)
100
+ except Exception as e:
101
+ return (f"Error listing directory {path}: {str(e)}", True)
102
+
103
+ # If the path is a file, view its contents
104
+ try:
105
+ with open(file_path, 'r', encoding='utf-8') as f:
106
+ content = f.readlines()
107
+
108
+ # If view_range is specified, return only the specified lines
109
+ if view_range:
110
+ start_line = max(1, view_range[0]) - 1 # Convert to 0-indexed
111
+ end_line = view_range[1] if view_range[1] != -1 else len(content)
112
+ end_line = min(end_line, len(content))
113
+
114
+ # Adjust content to only include the specified lines
115
+ content = content[start_line:end_line]
116
+
117
+ # Add line numbers to each line
118
+ numbered_content = []
119
+ for i, line in enumerate(content):
120
+ line_number = start_line + i + 1 # Convert back to 1-indexed
121
+ numbered_content.append(f"{line_number}: {line}")
122
+
123
+ # Add line count information
124
+ line_count = end_line - start_line
125
+
126
+ # Show relative path if it's not an absolute path
127
+ display_path = path if os.path.isabs(path) else os.path.relpath(file_path, get_config().workspace_dir)
128
+ line_info = f"Viewed {line_count} lines from {display_path}"
129
+
130
+ return ("".join(numbered_content) + f"\n{line_info}", False)
131
+ else:
132
+ # Add line numbers to each line
133
+ numbered_content = []
134
+ for i, line in enumerate(content):
135
+ line_number = i + 1 # 1-indexed line numbers
136
+ numbered_content.append(f"{line_number}: {line}")
137
+
138
+ # Add line count information
139
+ # Show relative path if it's not an absolute path
140
+ display_path = path if os.path.isabs(path) else os.path.relpath(file_path, get_config().workspace_dir)
141
+ line_info = f"Viewed {len(content)} lines from {display_path}"
142
+
143
+ return ("".join(numbered_content) + f"\n{line_info}", False)
144
+ except Exception as e:
145
+ return (f"Error viewing file {path}: {str(e)}", True)
146
+
147
+
148
+ def handle_str_replace(args: Dict[str, Any]) -> Tuple[str, bool]:
149
+ """
150
+ Replace a specific string in a file with a new string.
151
+
152
+ Args:
153
+ args: Dictionary containing:
154
+ - path: Path to the file to modify
155
+ - old_str: The text to replace
156
+ - new_str: The new text to insert
157
+
158
+ Returns:
159
+ A tuple containing (message, is_error)
160
+ """
161
+ path = args.get("path")
162
+ old_str = args.get("old_str")
163
+ new_str = args.get("new_str")
164
+
165
+ if not path:
166
+ return ("Missing required parameter: path", True)
167
+ if old_str is None:
168
+ return ("Missing required parameter: old_str", True)
169
+ if new_str is None:
170
+ return ("Missing required parameter: new_str", True)
171
+
172
+ path = normalize_path(path)
173
+ file_path = pathlib.Path(path)
174
+
175
+ if not file_path.exists():
176
+ return (f"File {path} does not exist", True)
177
+
178
+ try:
179
+ # Read the file content
180
+ with open(file_path, 'r', encoding='utf-8') as f:
181
+ content = f.read()
182
+
183
+ # Backup the file before making changes
184
+ backup_file(path, content)
185
+
186
+ # Save the current content for undo (legacy approach, will be deprecated)
187
+ if path not in _file_history:
188
+ _file_history[path] = []
189
+ _file_history[path].append(content)
190
+
191
+ # Check if old_str exists in the content
192
+ if old_str not in content:
193
+ return ("Error: No match found for replacement. Please check your text and try again.", True)
194
+
195
+ # Count occurrences to check for multiple matches
196
+ match_count = content.count(old_str)
197
+ if match_count > 1:
198
+ return (f"Error: Found {match_count} matches for replacement text. Please provide more context to make a unique match.", True)
199
+
200
+ # Replace the string
201
+ new_content = content.replace(old_str, new_str)
202
+
203
+ # Write the new content
204
+ with open(file_path, 'w', encoding='utf-8') as f:
205
+ f.write(new_content)
206
+
207
+ return (f"Successfully replaced string in file {path}", False)
208
+ except Exception as e:
209
+ return (f"Error replacing string in file {path}: {str(e)}", True)
210
+
211
+
212
+ def handle_insert(args: Dict[str, Any]) -> Tuple[str, bool]:
213
+ """
214
+ Insert text at a specific location in a file.
215
+
216
+ Args:
217
+ args: Dictionary containing:
218
+ - path: Path to the file to modify
219
+ - insert_line: The line number after which to insert the text
220
+ - new_str: The text to insert
221
+
222
+ Returns:
223
+ A tuple containing (message, is_error)
224
+ """
225
+ path = args.get("path")
226
+ insert_line = args.get("insert_line")
227
+ new_str = args.get("new_str")
228
+
229
+ if not path:
230
+ return ("Missing required parameter: path", True)
231
+ if insert_line is None:
232
+ return ("Missing required parameter: insert_line", True)
233
+ if new_str is None:
234
+ return ("Missing required parameter: new_str", True)
235
+
236
+ # Get the workspace directory from config
237
+ workspace_dir = get_config().workspace_dir
238
+
239
+ # Make path absolute if it's not already
240
+ if not os.path.isabs(path):
241
+ path = os.path.join(workspace_dir, path)
242
+
243
+ file_path = pathlib.Path(path)
244
+
245
+ if not file_path.exists():
246
+ return (f"File {path} does not exist", True)
247
+
248
+ try:
249
+ # Read the file content
250
+ with open(file_path, 'r', encoding='utf-8') as f:
251
+ lines = f.readlines()
252
+ content = "".join(lines)
253
+
254
+ # Backup the file before making changes
255
+ backup_file(path, content)
256
+
257
+ # Save the current content for undo (legacy approach, will be deprecated)
258
+ if path not in _file_history:
259
+ _file_history[path] = []
260
+ _file_history[path].append(content)
261
+
262
+ # Check if insert_line is valid
263
+ if insert_line < 0 or insert_line > len(lines):
264
+ return (f"Invalid insert line {insert_line} for file {path}", True)
265
+
266
+ # Ensure new_str ends with a newline if it doesn't already
267
+ if new_str and not new_str.endswith('\n'):
268
+ new_str += '\n'
269
+
270
+ # Insert the new string
271
+ lines.insert(insert_line, new_str)
272
+
273
+ # Write the new content
274
+ with open(file_path, 'w', encoding='utf-8') as f:
275
+ f.writelines(lines)
276
+
277
+ return (f"Successfully inserted text at line {insert_line} in file {path}", False)
278
+ except Exception as e:
279
+ return (f"Error inserting text in file {path}: {str(e)}", True)
280
+
281
+
282
+ def handle_undo_edit(args: Dict[str, Any]) -> Tuple[str, bool]:
283
+ """
284
+ Undo the last edit made to a file.
285
+
286
+ Args:
287
+ args: Dictionary containing:
288
+ - path: Path to the file whose last edit should be undone
289
+
290
+ Returns:
291
+ A tuple containing (message, is_error)
292
+ """
293
+ path = args.get("path")
294
+
295
+ if not path:
296
+ return ("Missing required parameter: path", True)
297
+
298
+ # Get the workspace directory from config
299
+ workspace_dir = get_config().workspace_dir
300
+
301
+ # Make path absolute if it's not already
302
+ if not os.path.isabs(path):
303
+ path = os.path.join(workspace_dir, path)
304
+
305
+ # First try to use the file-based backup system
306
+ backup_info = get_backup_info()
307
+ if backup_info:
308
+ backup_path = backup_info['path']
309
+ backup_content = backup_info['content']
310
+
311
+ # If a path was provided, check if it matches the backup
312
+ if path != backup_path:
313
+ return (f"No backup found for file {path}. Last edited file was {backup_path}", True)
314
+
315
+ try:
316
+ # Write the backup content back to the file
317
+ with open(path, 'w', encoding='utf-8') as f:
318
+ f.write(backup_content)
319
+
320
+ return (f"Successfully undid last edit to file {path}", False)
321
+ except Exception as e:
322
+ return (f"Error undoing edit to file {path}: {str(e)}", True)
323
+
324
+ # Fall back to the in-memory history if no file backup exists
325
+ if path not in _file_history or not _file_history[path]:
326
+ return (f"No edit history for file {path}", True)
327
+
328
+ try:
329
+ # Get the last content
330
+ last_content = _file_history[path].pop()
331
+
332
+ # Write the last content back to the file
333
+ with open(path, 'w', encoding='utf-8') as f:
334
+ f.write(last_content)
335
+
336
+ return (f"Successfully undid last edit to file {path}", False)
337
+ except Exception as e:
338
+ return (f"Error undoing edit to file {path}: {str(e)}", True)
@@ -0,0 +1,88 @@
1
+ """
2
+ Utility functions for the str_replace_editor package.
3
+ """
4
+ import os
5
+ import pathlib
6
+ from typing import Dict, Any, Optional
7
+ from janito.config import get_config
8
+
9
+ def normalize_path(path: str) -> str:
10
+ """
11
+ Normalizes a path relative to the workspace directory.
12
+
13
+ Args:
14
+ path: The original path
15
+
16
+ Returns:
17
+ The normalized path relative to the workspace directory
18
+ """
19
+ # If path is absolute, return it as is
20
+ if os.path.isabs(path):
21
+ return path
22
+
23
+ # Handle paths starting with ./ by removing the ./ prefix
24
+ if path.startswith('./'):
25
+ path = path[2:]
26
+
27
+ # For relative paths, we should keep them relative
28
+ # Only prepend workspace_dir if we need to resolve the path
29
+ # against the workspace directory
30
+ return path
31
+
32
+ def backup_file(file_path: str, content: str) -> None:
33
+ """
34
+ Backup a file before editing it.
35
+
36
+ Args:
37
+ file_path: Path to the file being edited
38
+ content: Current content of the file
39
+ """
40
+ # Get workspace directory
41
+ workspace_dir = get_config().workspace_dir
42
+
43
+ # Create .janito/undo directory in the workspace if it doesn't exist
44
+ backup_dir = pathlib.Path(workspace_dir) / ".janito" / "undo"
45
+ backup_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Store the original path
48
+ path_file = backup_dir / "path"
49
+ with open(path_file, 'w', encoding='utf-8') as f:
50
+ f.write(file_path)
51
+
52
+ # Store the original content
53
+ content_file = backup_dir / "content"
54
+ with open(content_file, 'w', encoding='utf-8') as f:
55
+ f.write(content)
56
+
57
+ def get_backup_info() -> Optional[Dict[str, str]]:
58
+ """
59
+ Get the backup information for the last edited file.
60
+
61
+ Returns:
62
+ Dictionary with 'path' and 'content' keys, or None if no backup exists
63
+ """
64
+ # Get workspace directory
65
+ workspace_dir = get_config().workspace_dir
66
+
67
+ path_file = pathlib.Path(workspace_dir) / ".janito" / "undo" / "path"
68
+ content_file = pathlib.Path(workspace_dir) / ".janito" / "undo" / "content"
69
+
70
+ if not path_file.exists() or not content_file.exists():
71
+ return None
72
+
73
+ try:
74
+ with open(path_file, 'r', encoding='utf-8') as f:
75
+ path = f.read()
76
+
77
+ with open(content_file, 'r', encoding='utf-8') as f:
78
+ content = f.read()
79
+
80
+ return {
81
+ 'path': path,
82
+ 'content': content
83
+ }
84
+ except Exception:
85
+ return None
86
+
87
+ # Store file history for undo operations (in-memory backup, will be deprecated)
88
+ _file_history = {}
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 João Pinto
3
+ Copyright (c) [year] [copyright holder]
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -9,7 +9,7 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
9
  copies of the Software, and to permit persons to whom the Software is
10
10
  furnished to do so, subject to the following conditions:
11
11
 
12
- The above copyright notice and permission notice shall be included in all
12
+ The above copyright notice and this permission notice shall be included in all
13
13
  copies or substantial portions of the Software.
14
14
 
15
15
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR