x-ipe 1.0.23__py3-none-any.whl → 1.0.25__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 (146) hide show
  1. x_ipe/app.py +32 -1
  2. x_ipe/handlers/terminal_handlers.py +6 -0
  3. x_ipe/handlers/voice_handlers.py +5 -0
  4. x_ipe/resources/copilot-instructions.md +19 -6
  5. x_ipe/resources/skills/lesson-learned/SKILL.md +208 -0
  6. x_ipe/resources/skills/lesson-learned/references/examples.md +238 -0
  7. x_ipe/resources/skills/project-quality-board-management/SKILL.md +135 -298
  8. x_ipe/resources/skills/project-quality-board-management/references/evaluation-principles.md +213 -0
  9. x_ipe/resources/skills/project-quality-board-management/references/evaluation-procedures.md +214 -0
  10. x_ipe/resources/skills/project-quality-board-management/templates/quality-report.md +70 -18
  11. x_ipe/resources/skills/task-execution-guideline/SKILL.md +2 -2
  12. x_ipe/resources/skills/task-execution-guideline/templates/task-record.yaml +1 -1
  13. x_ipe/resources/skills/task-type-code-implementation/SKILL.md +72 -270
  14. x_ipe/resources/skills/task-type-code-implementation/references/implementation-guidelines.md +432 -0
  15. x_ipe/resources/skills/task-type-code-refactor-v2/SKILL.md +127 -353
  16. x_ipe/resources/skills/task-type-code-refactor-v2/references/refactoring-techniques.md +373 -0
  17. x_ipe/resources/skills/task-type-feature-breakdown/SKILL.md +31 -243
  18. x_ipe/resources/skills/task-type-feature-breakdown/references/breakdown-guidelines.md +330 -0
  19. x_ipe/resources/skills/task-type-feature-refinement/SKILL.md +27 -180
  20. x_ipe/resources/skills/task-type-feature-refinement/references/specification-writing-guide.md +267 -0
  21. x_ipe/resources/skills/task-type-idea-mockup/SKILL.md +38 -276
  22. x_ipe/resources/skills/task-type-idea-mockup/references/mockup-guidelines.md +299 -0
  23. x_ipe/resources/skills/task-type-idea-to-architecture/SKILL.md +20 -218
  24. x_ipe/resources/skills/task-type-idea-to-architecture/references/architecture-patterns.md +342 -0
  25. x_ipe/resources/skills/task-type-ideation/SKILL.md +10 -266
  26. x_ipe/resources/skills/task-type-ideation/references/folder-naming-guide.md +55 -0
  27. x_ipe/resources/skills/task-type-ideation/references/tool-usage-guide.md +236 -0
  28. x_ipe/resources/skills/task-type-ideation-v2/SKILL.md +488 -0
  29. x_ipe/resources/skills/task-type-ideation-v2/references/examples.md +377 -0
  30. x_ipe/resources/skills/task-type-ideation-v2/references/folder-naming-guide.md +74 -0
  31. x_ipe/resources/skills/task-type-ideation-v2/references/tool-usage-guide.md +145 -0
  32. x_ipe/resources/skills/task-type-ideation-v2/references/visualization-guide.md +160 -0
  33. x_ipe/resources/skills/task-type-ideation-v2/templates/idea-summary.md +86 -0
  34. x_ipe/resources/skills/task-type-refactoring-analysis/SKILL.md +83 -145
  35. x_ipe/resources/skills/task-type-refactoring-analysis/references/output-schema.md +172 -0
  36. x_ipe/resources/skills/task-type-technical-design/SKILL.md +28 -214
  37. x_ipe/resources/skills/task-type-technical-design/references/design-templates.md +422 -0
  38. x_ipe/resources/skills/task-type-test-generation/SKILL.md +47 -332
  39. x_ipe/resources/skills/task-type-test-generation/references/test-patterns.md +368 -0
  40. x_ipe/resources/skills/tool-tracing-creator/SKILL.md +312 -0
  41. x_ipe/resources/skills/tool-tracing-creator/references/examples.md +324 -0
  42. x_ipe/resources/skills/tool-tracing-instrumentation/SKILL.md +373 -0
  43. x_ipe/resources/skills/tool-tracing-instrumentation/references/examples.md +264 -0
  44. x_ipe/resources/skills/x-ipe-skill-creator-v3/SKILL.md +486 -0
  45. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/10. example-gate-conditions.md +73 -0
  46. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/11. reference-quality-standards.md +127 -0
  47. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/2. reference-section-order.md +127 -0
  48. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/3. example-step-based-code-review.md +84 -0
  49. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/4. example-step-based-feature-implementation.md +113 -0
  50. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/5. example-function-based-validation.md +73 -0
  51. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/6. example-function-based-analysis.md +94 -0
  52. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/7. example-task-io-code-implementation.md +36 -0
  53. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/8. example-structured-summary.md +43 -0
  54. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/9. example-dor-dod.md +77 -0
  55. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/examples.md +429 -0
  56. x_ipe/resources/skills/x-ipe-skill-creator-v3/references/skill-general-guidelines-v2.md +611 -0
  57. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/skill-meta-x-ipe-meta.md +153 -0
  58. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/skill-meta-x-ipe-task-based.md +324 -0
  59. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/skill-meta-x-ipe-task-category.md +109 -0
  60. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/skill-meta-x-ipe-tool.md +205 -0
  61. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/x-ipe-meta.md +334 -0
  62. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/x-ipe-task-based.md +279 -0
  63. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/x-ipe-tool.md +175 -0
  64. x_ipe/resources/skills/x-ipe-skill-creator-v3/templates/x-ipe-workflow-orchestration.md +329 -0
  65. x_ipe/resources/skills/x-ipe-task-based-ideation/SKILL.md +487 -0
  66. x_ipe/resources/skills/x-ipe-task-based-ideation/references/examples.md +377 -0
  67. x_ipe/resources/skills/x-ipe-task-based-ideation/references/folder-naming-guide.md +74 -0
  68. x_ipe/resources/skills/x-ipe-task-based-ideation/references/tool-usage-guide.md +145 -0
  69. x_ipe/resources/skills/x-ipe-task-based-ideation/references/visualization-guide.md +160 -0
  70. x_ipe/resources/skills/x-ipe-task-based-ideation/templates/idea-summary.md +86 -0
  71. x_ipe/routes/__init__.py +2 -0
  72. x_ipe/routes/ideas_routes.py +289 -0
  73. x_ipe/routes/kb_routes.py +80 -0
  74. x_ipe/routes/main_routes.py +18 -0
  75. x_ipe/routes/project_routes.py +7 -0
  76. x_ipe/routes/proxy_routes.py +10 -2
  77. x_ipe/routes/quality_evaluation_routes.py +193 -0
  78. x_ipe/routes/settings_routes.py +6 -0
  79. x_ipe/routes/tools_routes.py +6 -0
  80. x_ipe/routes/tracing_routes.py +232 -0
  81. x_ipe/routes/uiux_feedback_routes.py +50 -0
  82. x_ipe/services/__init__.py +5 -0
  83. x_ipe/services/config_service.py +6 -0
  84. x_ipe/services/file_service.py +20 -0
  85. x_ipe/services/homepage_service.py +160 -0
  86. x_ipe/services/ideas_service.py +535 -2
  87. x_ipe/services/kb_service.py +378 -0
  88. x_ipe/services/proxy_service.py +37 -7
  89. x_ipe/services/settings_service.py +13 -0
  90. x_ipe/services/skills_service.py +4 -0
  91. x_ipe/services/terminal_service.py +24 -0
  92. x_ipe/services/themes_service.py +4 -0
  93. x_ipe/services/tools_config_service.py +4 -0
  94. x_ipe/services/tracing_service.py +333 -0
  95. x_ipe/services/uiux_feedback_service.py +148 -1
  96. x_ipe/services/voice_input_service_v2.py +11 -0
  97. x_ipe/static/css/base.css +7 -0
  98. x_ipe/static/css/homepage-infinity.css +330 -0
  99. x_ipe/static/css/kb-core.css +301 -0
  100. x_ipe/static/css/quality-evaluation.css +345 -0
  101. x_ipe/static/css/sidebar.css +14 -4
  102. x_ipe/static/css/terminal.css +23 -0
  103. x_ipe/static/css/tracing-dashboard.css +796 -0
  104. x_ipe/static/css/uiux-feedback.css +7 -1
  105. x_ipe/static/css/workplace.css +636 -0
  106. x_ipe/static/img/homepage-infinity-loop.png +0 -0
  107. x_ipe/static/js/features/confirm-dialog.js +169 -0
  108. x_ipe/static/js/features/folder-view.js +742 -0
  109. x_ipe/static/js/features/homepage-infinity.js +314 -0
  110. x_ipe/static/js/features/kb-core.js +371 -0
  111. x_ipe/static/js/features/quality-evaluation.js +387 -0
  112. x_ipe/static/js/features/sidebar.js +255 -12
  113. x_ipe/static/js/features/tracing-dashboard.js +855 -0
  114. x_ipe/static/js/features/tracing-graph.js +1031 -0
  115. x_ipe/static/js/features/tree-drag.js +227 -0
  116. x_ipe/static/js/features/tree-search.js +228 -0
  117. x_ipe/static/js/features/workplace.js +661 -33
  118. x_ipe/static/js/init.js +76 -0
  119. x_ipe/static/js/terminal-v2.js +45 -14
  120. x_ipe/static/js/terminal.js +50 -49
  121. x_ipe/static/js/uiux-feedback.js +75 -16
  122. x_ipe/templates/base.html +24 -0
  123. x_ipe/templates/index.html +10 -1
  124. x_ipe/templates/knowledge-base.html +110 -0
  125. x_ipe/templates/workplace.html +4 -0
  126. x_ipe/tracing/__init__.py +37 -0
  127. x_ipe/tracing/buffer.py +135 -0
  128. x_ipe/tracing/context.py +125 -0
  129. x_ipe/tracing/decorator.py +288 -0
  130. x_ipe/tracing/middleware.py +197 -0
  131. x_ipe/tracing/parser.py +235 -0
  132. x_ipe/tracing/redactor.py +111 -0
  133. x_ipe/tracing/writer.py +122 -0
  134. {x_ipe-1.0.23.dist-info → x_ipe-1.0.25.dist-info}/METADATA +2 -2
  135. {x_ipe-1.0.23.dist-info → x_ipe-1.0.25.dist-info}/RECORD +138 -65
  136. x_ipe/app.py.bak +0 -1333
  137. x_ipe/resources/skills/x-ipe-skill-creator/SKILL.md +0 -329
  138. x_ipe/resources/skills/x-ipe-skill-creator/references/output-patterns.md +0 -169
  139. x_ipe/resources/skills/x-ipe-skill-creator/references/skill-structure.md +0 -162
  140. x_ipe/resources/skills/x-ipe-skill-creator/references/workflows.md +0 -110
  141. x_ipe/resources/skills/x-ipe-skill-creator/templates/references/examples.md +0 -113
  142. x_ipe/resources/skills/x-ipe-skill-creator/templates/skill-category-skill.md +0 -296
  143. x_ipe/resources/skills/x-ipe-skill-creator/templates/task-type-skill.md +0 -269
  144. {x_ipe-1.0.23.dist-info → x_ipe-1.0.25.dist-info}/WHEEL +0 -0
  145. {x_ipe-1.0.23.dist-info → x_ipe-1.0.25.dist-info}/entry_points.txt +0 -0
  146. {x_ipe-1.0.23.dist-info → x_ipe-1.0.25.dist-info}/licenses/LICENSE +0 -0
@@ -11,6 +11,8 @@ from datetime import datetime
11
11
  from pathlib import Path
12
12
  from typing import Dict, List, Any
13
13
 
14
+ from x_ipe.tracing import x_ipe_tracing
15
+
14
16
 
15
17
  class IdeasService:
16
18
  """
@@ -48,6 +50,7 @@ class IdeasService:
48
50
  self.project_root = Path(project_root).resolve()
49
51
  self.ideas_root = self.project_root / self.IDEAS_PATH
50
52
 
53
+ @x_ipe_tracing()
51
54
  def get_tree(self) -> List[Dict]:
52
55
  """
53
56
  Scan x-ipe-docs/ideas/ and return tree structure.
@@ -93,6 +96,7 @@ class IdeasService:
93
96
 
94
97
  return items
95
98
 
99
+ @x_ipe_tracing()
96
100
  def upload(self, files: List[tuple], date: str = None, target_folder: str = None) -> Dict[str, Any]:
97
101
  """
98
102
  Upload files to a new or existing idea folder.
@@ -155,6 +159,73 @@ class IdeasService:
155
159
  'files_uploaded': uploaded_files
156
160
  }
157
161
 
162
+ @x_ipe_tracing()
163
+ def create_folder(self, folder_name: str, parent_folder: str = None) -> Dict[str, Any]:
164
+ """
165
+ Create an empty folder in ideas directory.
166
+
167
+ Args:
168
+ folder_name: Name for the new folder
169
+ parent_folder: Optional parent folder path (relative to ideas root)
170
+
171
+ Returns:
172
+ Dict with success, folder_name, folder_path or error
173
+ """
174
+ # Validate folder name
175
+ folder_name = folder_name.strip()
176
+ is_valid, error = self._validate_folder_name(folder_name)
177
+ if not is_valid:
178
+ return {
179
+ 'success': False,
180
+ 'error': error
181
+ }
182
+
183
+ # Determine base path
184
+ if parent_folder:
185
+ # Strip 'x-ipe-docs/ideas/' prefix if present
186
+ if parent_folder.startswith(self.IDEAS_PATH + '/'):
187
+ parent_folder = parent_folder[len(self.IDEAS_PATH) + 1:]
188
+ elif parent_folder.startswith(self.IDEAS_PATH):
189
+ parent_folder = parent_folder[len(self.IDEAS_PATH):]
190
+
191
+ base_path = self.ideas_root / parent_folder
192
+ if not base_path.exists():
193
+ return {
194
+ 'success': False,
195
+ 'error': f"Parent folder '{parent_folder}' does not exist"
196
+ }
197
+ else:
198
+ base_path = self.ideas_root
199
+
200
+ # Ensure ideas root exists
201
+ self.ideas_root.mkdir(parents=True, exist_ok=True)
202
+
203
+ # Generate unique name if folder exists
204
+ final_name = self._generate_unique_name(folder_name, base_path)
205
+
206
+ # Create the folder
207
+ folder_path = base_path / final_name
208
+ try:
209
+ folder_path.mkdir(parents=True, exist_ok=True)
210
+ except OSError as e:
211
+ return {
212
+ 'success': False,
213
+ 'error': f'Failed to create folder: {str(e)}'
214
+ }
215
+
216
+ # Build relative path for response
217
+ if parent_folder:
218
+ relative_path = f'{self.IDEAS_PATH}/{parent_folder}/{final_name}'
219
+ else:
220
+ relative_path = f'{self.IDEAS_PATH}/{final_name}'
221
+
222
+ return {
223
+ 'success': True,
224
+ 'folder_name': final_name,
225
+ 'folder_path': relative_path
226
+ }
227
+
228
+ @x_ipe_tracing()
158
229
  def rename_folder(self, old_name: str, new_name: str) -> Dict[str, Any]:
159
230
  """
160
231
  Rename an idea folder.
@@ -207,6 +278,7 @@ class IdeasService:
207
278
  'new_path': f'{self.IDEAS_PATH}/{final_name}'
208
279
  }
209
280
 
281
+ @x_ipe_tracing()
210
282
  def rename_file(self, file_path: str, new_name: str) -> Dict[str, Any]:
211
283
  """
212
284
  Rename a file within x-ipe-docs/ideas/.
@@ -312,20 +384,28 @@ class IdeasService:
312
384
 
313
385
  return (True, None)
314
386
 
315
- def _generate_unique_name(self, base_name: str) -> str:
387
+ def _generate_unique_name(self, base_name: str, base_path: Path = None) -> str:
316
388
  """
317
389
  Generate unique folder name if base_name exists.
318
390
  Appends (2), (3), etc. until unique.
391
+
392
+ Args:
393
+ base_name: Base name for the folder
394
+ base_path: Optional base path to check existence (defaults to ideas_root)
319
395
  """
396
+ if base_path is None:
397
+ base_path = self.ideas_root
398
+
320
399
  name = base_name
321
400
  counter = 2
322
401
 
323
- while (self.ideas_root / name).exists():
402
+ while (base_path / name).exists():
324
403
  name = f'{base_name} ({counter})'
325
404
  counter += 1
326
405
 
327
406
  return name
328
407
 
408
+ @x_ipe_tracing()
329
409
  def delete_item(self, path: str) -> Dict[str, Any]:
330
410
  """
331
411
  Delete a file or folder within x-ipe-docs/ideas/.
@@ -386,6 +466,7 @@ class IdeasService:
386
466
  'error': f'Failed to delete: {str(e)}'
387
467
  }
388
468
 
469
+ @x_ipe_tracing()
389
470
  def get_next_version_number(self, folder_path: str, base_name: str = 'idea-summary') -> int:
390
471
  """
391
472
  Get the next version number for a versioned file.
@@ -414,6 +495,7 @@ class IdeasService:
414
495
 
415
496
  return max_version + 1
416
497
 
498
+ @x_ipe_tracing()
417
499
  def create_versioned_summary(self, folder_path: str, content: str, base_name: str = 'idea-summary') -> Dict[str, Any]:
418
500
  """
419
501
  Create a new versioned idea summary file.
@@ -472,6 +554,7 @@ class IdeasService:
472
554
  'error': f'Failed to create file: {str(e)}'
473
555
  }
474
556
 
557
+ @x_ipe_tracing()
475
558
  def get_toolbox(self) -> Dict:
476
559
  """
477
560
  Read toolbox configuration from JSON file.
@@ -489,6 +572,7 @@ class IdeasService:
489
572
  return copy.deepcopy(self.DEFAULT_TOOLBOX)
490
573
  return copy.deepcopy(self.DEFAULT_TOOLBOX)
491
574
 
575
+ @x_ipe_tracing()
492
576
  def save_toolbox(self, config: Dict) -> Dict:
493
577
  """
494
578
  Save toolbox configuration to JSON file.
@@ -510,3 +594,452 @@ class IdeasService:
510
594
  return {'success': True}
511
595
  except IOError as e:
512
596
  return {'success': False, 'error': str(e)}
597
+
598
+ # =========================================================================
599
+ # CR-006: Folder Tree UX Enhancement
600
+ # =========================================================================
601
+
602
+ @x_ipe_tracing()
603
+ def move_item(self, source_path: str, target_folder: str) -> Dict[str, Any]:
604
+ """
605
+ Move file or folder to target folder.
606
+
607
+ Args:
608
+ source_path: Path (relative to project root or ideas root)
609
+ target_folder: Target folder path (relative to project root or ideas root)
610
+
611
+ Returns:
612
+ {success: bool, new_path: str, error?: str}
613
+ """
614
+ if not source_path:
615
+ return {'success': False, 'error': 'Source path is required'}
616
+ if target_folder is None:
617
+ return {'success': False, 'error': 'Target folder is required'}
618
+
619
+ # Normalize source path - handle both x-ipe-docs/ideas/... and relative paths
620
+ if source_path.startswith(self.IDEAS_PATH + '/'):
621
+ source_rel = source_path[len(self.IDEAS_PATH) + 1:]
622
+ elif source_path.startswith(self.IDEAS_PATH):
623
+ source_rel = source_path[len(self.IDEAS_PATH):]
624
+ else:
625
+ source_rel = source_path
626
+ source_full = self.ideas_root / source_rel
627
+
628
+ # Normalize target path
629
+ if target_folder == '' or target_folder == self.IDEAS_PATH:
630
+ target_full = self.ideas_root
631
+ target_rel = ''
632
+ elif target_folder.startswith(self.IDEAS_PATH + '/'):
633
+ target_rel = target_folder[len(self.IDEAS_PATH) + 1:]
634
+ target_full = self.ideas_root / target_rel
635
+ elif target_folder.startswith(self.IDEAS_PATH):
636
+ target_rel = target_folder[len(self.IDEAS_PATH):]
637
+ target_full = self.ideas_root / target_rel
638
+ else:
639
+ target_rel = target_folder
640
+ target_full = self.ideas_root / target_folder
641
+
642
+ # Validate source exists
643
+ if not source_full.exists():
644
+ return {'success': False, 'error': f'Source not found: {source_path}'}
645
+
646
+ # Validate target exists
647
+ if not target_full.exists():
648
+ return {'success': False, 'error': f'Target folder not found: {target_folder}'}
649
+
650
+ # Validate target is a folder
651
+ if not target_full.is_dir():
652
+ return {'success': False, 'error': 'Target is not a folder'}
653
+
654
+ # Validate not moving into self or child (use normalized paths)
655
+ if not self.is_valid_drop_target(source_rel, target_rel):
656
+ return {'success': False, 'error': 'Cannot move folder into itself or its children'}
657
+
658
+ # Determine destination path
659
+ dest_path = target_full / source_full.name
660
+
661
+ # Handle name collision
662
+ if dest_path.exists():
663
+ dest_path = self._get_unique_path(dest_path)
664
+
665
+ try:
666
+ shutil.move(str(source_full), str(dest_path))
667
+ # Return path relative to ideas root
668
+ new_path = str(dest_path.relative_to(self.ideas_root))
669
+ return {'success': True, 'new_path': new_path}
670
+ except OSError as e:
671
+ return {'success': False, 'error': f'Failed to move: {str(e)}'}
672
+
673
+ @x_ipe_tracing()
674
+ def duplicate_item(self, path: str) -> Dict[str, Any]:
675
+ """
676
+ Duplicate file or folder with -copy suffix.
677
+
678
+ Creates: filename-copy.ext or foldername-copy/
679
+ If exists: filename-copy-2.ext, etc.
680
+ """
681
+ if not path:
682
+ return {'success': False, 'error': 'Path is required'}
683
+
684
+ # Normalize path
685
+ if path.startswith(self.IDEAS_PATH + '/'):
686
+ path_rel = path[len(self.IDEAS_PATH) + 1:]
687
+ elif path.startswith(self.IDEAS_PATH):
688
+ path_rel = path[len(self.IDEAS_PATH):]
689
+ else:
690
+ path_rel = path
691
+
692
+ full_path = self.ideas_root / path_rel
693
+
694
+ if not full_path.exists():
695
+ return {'success': False, 'error': f'Path not found: {path}'}
696
+
697
+ # Validate path is within ideas directory
698
+ try:
699
+ resolved_path = full_path.resolve()
700
+ ideas_resolved = self.ideas_root.resolve()
701
+
702
+ if not str(resolved_path).startswith(str(ideas_resolved)):
703
+ return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
704
+ except Exception:
705
+ return {'success': False, 'error': 'Invalid path'}
706
+
707
+ # Generate copy name
708
+ if full_path.is_file():
709
+ stem = full_path.stem
710
+ suffix = full_path.suffix
711
+ copy_name = f"{stem}-copy{suffix}"
712
+ copy_path = full_path.parent / copy_name
713
+
714
+ # Handle collisions
715
+ counter = 2
716
+ while copy_path.exists():
717
+ copy_name = f"{stem}-copy-{counter}{suffix}"
718
+ copy_path = full_path.parent / copy_name
719
+ counter += 1
720
+
721
+ try:
722
+ shutil.copy2(str(full_path), str(copy_path))
723
+ except OSError as e:
724
+ return {'success': False, 'error': f'Failed to duplicate: {str(e)}'}
725
+ else:
726
+ # Folder
727
+ copy_name = f"{full_path.name}-copy"
728
+ copy_path = full_path.parent / copy_name
729
+
730
+ # Handle collisions
731
+ counter = 2
732
+ while copy_path.exists():
733
+ copy_name = f"{full_path.name}-copy-{counter}"
734
+ copy_path = full_path.parent / copy_name
735
+ counter += 1
736
+
737
+ try:
738
+ shutil.copytree(str(full_path), str(copy_path))
739
+ except OSError as e:
740
+ return {'success': False, 'error': f'Failed to duplicate: {str(e)}'}
741
+
742
+ # Return path relative to ideas root
743
+ new_path = str(copy_path.relative_to(self.ideas_root))
744
+ return {'success': True, 'new_path': new_path}
745
+
746
+ @x_ipe_tracing()
747
+ def get_folder_contents(self, folder_path: str) -> Dict[str, Any]:
748
+ """
749
+ Get contents of a specific folder for folder view panel.
750
+
751
+ Args:
752
+ folder_path: Path (relative to project root or ideas root)
753
+
754
+ Returns:
755
+ {success: bool, items: [...], error?: str}
756
+ """
757
+ # Normalize path
758
+ if not folder_path:
759
+ folder_path_rel = ''
760
+ elif folder_path.startswith(self.IDEAS_PATH + '/'):
761
+ folder_path_rel = folder_path[len(self.IDEAS_PATH) + 1:]
762
+ elif folder_path.startswith(self.IDEAS_PATH):
763
+ folder_path_rel = folder_path[len(self.IDEAS_PATH):]
764
+ else:
765
+ folder_path_rel = folder_path
766
+
767
+ if folder_path_rel:
768
+ full_path = self.ideas_root / folder_path_rel
769
+ else:
770
+ full_path = self.ideas_root
771
+
772
+ if not full_path.exists():
773
+ return {'success': False, 'error': f'Folder not found: {folder_path}'}
774
+
775
+ if not full_path.is_dir():
776
+ return {'success': False, 'error': 'Path is not a folder'}
777
+
778
+ # Validate path is within ideas directory
779
+ try:
780
+ resolved_path = full_path.resolve()
781
+ ideas_resolved = self.ideas_root.resolve()
782
+
783
+ if not str(resolved_path).startswith(str(ideas_resolved)):
784
+ return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
785
+ except Exception:
786
+ return {'success': False, 'error': 'Invalid path'}
787
+
788
+ items = []
789
+ try:
790
+ for entry in sorted(full_path.iterdir()):
791
+ if entry.name.startswith('.'):
792
+ continue
793
+
794
+ # Return path relative to project root (consistent with get_tree)
795
+ relative_path = str(entry.relative_to(self.project_root))
796
+ item = {
797
+ 'name': entry.name,
798
+ 'type': 'folder' if entry.is_dir() else 'file',
799
+ 'path': relative_path
800
+ }
801
+ if entry.is_dir():
802
+ item['children'] = [] # Lazy load
803
+ items.append(item)
804
+ except PermissionError:
805
+ return {'success': False, 'error': 'Permission denied'}
806
+
807
+ # Return folder_path relative to project root
808
+ folder_path_result = str(full_path.relative_to(self.project_root)) if full_path != self.ideas_root else self.IDEAS_PATH
809
+ return {'success': True, 'items': items, 'folder_path': folder_path_result}
810
+
811
+ @x_ipe_tracing()
812
+ def is_valid_drop_target(self, source_path: str, target_folder: str) -> bool:
813
+ """
814
+ Validate that target is not source or child of source.
815
+
816
+ Args:
817
+ source_path: Path of item being dragged (relative to ideas root)
818
+ target_folder: Path of drop target folder (relative to ideas root)
819
+ """
820
+ if not source_path:
821
+ return False
822
+
823
+ # Normalize paths (remove x-ipe-docs/ideas/ prefix if present)
824
+ if source_path.startswith(self.IDEAS_PATH + '/'):
825
+ source_norm = source_path[len(self.IDEAS_PATH) + 1:].rstrip('/')
826
+ elif source_path.startswith(self.IDEAS_PATH):
827
+ source_norm = source_path[len(self.IDEAS_PATH):].lstrip('/').rstrip('/')
828
+ else:
829
+ source_norm = source_path.rstrip('/')
830
+
831
+ if not target_folder:
832
+ return True # Root is always valid
833
+
834
+ if target_folder.startswith(self.IDEAS_PATH + '/'):
835
+ target_norm = target_folder[len(self.IDEAS_PATH) + 1:].rstrip('/')
836
+ elif target_folder.startswith(self.IDEAS_PATH):
837
+ target_norm = target_folder[len(self.IDEAS_PATH):].lstrip('/').rstrip('/')
838
+ else:
839
+ target_norm = target_folder.rstrip('/')
840
+
841
+ # Cannot drop onto self
842
+ if source_norm == target_norm:
843
+ return False
844
+
845
+ # Check if source is a folder
846
+ source_full = self.ideas_root / source_norm
847
+ if not source_full.exists() or not source_full.is_dir():
848
+ return True # Files can be dropped anywhere, non-existent is handled elsewhere
849
+
850
+ # Cannot drop folder into its own children
851
+ if target_norm.startswith(source_norm + '/'):
852
+ return False
853
+
854
+ return True
855
+
856
+ @x_ipe_tracing()
857
+ def filter_tree(self, query: str) -> List[Dict]:
858
+ """
859
+ Filter tree by search query, returning matching items with parent context.
860
+
861
+ Args:
862
+ query: Search string to match against item names
863
+
864
+ Returns:
865
+ Flat list of matching items and their parent folders
866
+ """
867
+ if not query or not query.strip():
868
+ return self.get_tree()
869
+
870
+ query_lower = query.lower().strip()
871
+ tree = self.get_tree()
872
+
873
+ # Collect all matching items plus their parents
874
+ results = []
875
+ self._collect_matches(tree, query_lower, results)
876
+ return results
877
+
878
+ def _collect_matches(self, items: List[Dict], query: str, results: List[Dict], include_all: bool = False) -> bool:
879
+ """Recursively collect matching items and parents into flat results list.
880
+
881
+ Returns True if any child matches (to include parent in results).
882
+ """
883
+ any_match = False
884
+
885
+ for item in items:
886
+ item_copy = copy.copy(item)
887
+ name_matches = query in item['name'].lower()
888
+
889
+ if item['type'] == 'folder' and 'children' in item:
890
+ # Check if any children match
891
+ child_results = []
892
+ has_child_match = self._collect_matches(item['children'], query, child_results, include_all=name_matches)
893
+
894
+ if name_matches or has_child_match:
895
+ item_copy['_matches'] = name_matches
896
+ item_copy['children'] = [] # Don't include nested children in flat result
897
+ results.append(item_copy)
898
+ results.extend(child_results)
899
+ any_match = True
900
+ else:
901
+ # File - include if name matches or parent matched
902
+ if name_matches or include_all:
903
+ item_copy['_matches'] = name_matches
904
+ results.append(item_copy)
905
+ if name_matches:
906
+ any_match = True
907
+
908
+ return any_match
909
+
910
+ @x_ipe_tracing()
911
+ def get_download_info(self, path: str) -> Dict[str, Any]:
912
+ """
913
+ Get file content and mime type for download.
914
+
915
+ Args:
916
+ path: Path (relative to project root or ideas root)
917
+
918
+ Returns:
919
+ {success: bool, content: bytes, filename: str, mime_type: str}
920
+ """
921
+ if not path:
922
+ return {'success': False, 'error': 'Path is required'}
923
+
924
+ # Normalize path
925
+ if path.startswith(self.IDEAS_PATH + '/'):
926
+ path_rel = path[len(self.IDEAS_PATH) + 1:]
927
+ elif path.startswith(self.IDEAS_PATH):
928
+ path_rel = path[len(self.IDEAS_PATH):]
929
+ else:
930
+ path_rel = path
931
+
932
+ full_path = self.ideas_root / path_rel
933
+
934
+ if not full_path.exists():
935
+ return {'success': False, 'error': f'File not found: {path}'}
936
+
937
+ if not full_path.is_file():
938
+ return {'success': False, 'error': 'Cannot download a folder'}
939
+
940
+ # Validate path is within ideas directory
941
+ try:
942
+ resolved_path = full_path.resolve()
943
+ ideas_resolved = self.ideas_root.resolve()
944
+
945
+ if not str(resolved_path).startswith(str(ideas_resolved)):
946
+ return {'success': False, 'error': 'Path must be within x-ipe-docs/ideas/'}
947
+ except Exception:
948
+ return {'success': False, 'error': 'Invalid path'}
949
+
950
+ # Determine mime type
951
+ suffix = full_path.suffix.lower()
952
+ mime_types = {
953
+ '.md': 'text/markdown',
954
+ '.txt': 'text/plain',
955
+ '.json': 'application/json',
956
+ '.html': 'text/html',
957
+ '.pdf': 'application/pdf',
958
+ '.png': 'image/png',
959
+ '.jpg': 'image/jpeg',
960
+ '.jpeg': 'image/jpeg',
961
+ '.gif': 'image/gif',
962
+ }
963
+ mime_type = mime_types.get(suffix, 'application/octet-stream')
964
+
965
+ try:
966
+ content = full_path.read_bytes()
967
+ # For text files, decode to string for easier testing
968
+ text_types = ['.md', '.txt', '.json', '.html', '.css', '.js']
969
+ if suffix in text_types:
970
+ try:
971
+ content = content.decode('utf-8')
972
+ except UnicodeDecodeError:
973
+ pass # Keep as bytes if decode fails
974
+ return {
975
+ 'success': True,
976
+ 'content': content,
977
+ 'filename': full_path.name,
978
+ 'mime_type': mime_type
979
+ }
980
+ except OSError as e:
981
+ return {'success': False, 'error': f'Failed to read file: {str(e)}'}
982
+
983
+ @x_ipe_tracing()
984
+ def get_delete_info(self, path: str) -> Dict[str, Any]:
985
+ """
986
+ Get item info for delete confirmation dialog.
987
+
988
+ Returns item type and count of children for folders.
989
+ """
990
+ if not path:
991
+ return {'success': False, 'error': 'Path is required'}
992
+
993
+ # Normalize path
994
+ if path.startswith(self.IDEAS_PATH + '/'):
995
+ path_rel = path[len(self.IDEAS_PATH) + 1:]
996
+ elif path.startswith(self.IDEAS_PATH):
997
+ path_rel = path[len(self.IDEAS_PATH):]
998
+ else:
999
+ path_rel = path
1000
+
1001
+ full_path = self.ideas_root / path_rel
1002
+
1003
+ if not full_path.exists():
1004
+ return {'success': False, 'error': f'Path not found: {path}'}
1005
+
1006
+ item_type = 'folder' if full_path.is_dir() else 'file'
1007
+ item_count = 1
1008
+
1009
+ if full_path.is_dir():
1010
+ # Count all items recursively
1011
+ item_count = sum(1 for _ in full_path.rglob('*') if not _.name.startswith('.'))
1012
+
1013
+ return {
1014
+ 'success': True,
1015
+ 'path': path,
1016
+ 'name': full_path.name,
1017
+ 'type': item_type,
1018
+ 'item_count': item_count
1019
+ }
1020
+
1021
+ def _get_unique_path(self, path: Path) -> Path:
1022
+ """Generate unique path if target exists."""
1023
+ if not path.exists():
1024
+ return path
1025
+
1026
+ parent = path.parent
1027
+ if path.is_file() or not path.exists():
1028
+ stem = path.stem
1029
+ suffix = path.suffix
1030
+ counter = 2
1031
+ while True:
1032
+ new_name = f"{stem}-{counter}{suffix}"
1033
+ new_path = parent / new_name
1034
+ if not new_path.exists():
1035
+ return new_path
1036
+ counter += 1
1037
+ else:
1038
+ name = path.name
1039
+ counter = 2
1040
+ while True:
1041
+ new_name = f"{name}-{counter}"
1042
+ new_path = parent / new_name
1043
+ if not new_path.exists():
1044
+ return new_path
1045
+ counter += 1