parapet-rails 0.1.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.
Files changed (231) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +48 -0
  4. data/Rakefile +7 -0
  5. data/app/assets/config/parapet/grammar-registry.json +52 -0
  6. data/app/assets/images/parapet/icons/activities/component-inspector.svg +4 -0
  7. data/app/assets/images/parapet/icons/activities/coverage-heatmap.svg +3 -0
  8. data/app/assets/images/parapet/icons/activities/discovery-sidebar.svg +3 -0
  9. data/app/assets/images/parapet/icons/activities/environment-toggles.svg +4 -0
  10. data/app/assets/images/parapet/icons/activities/failure-inspector.svg +5 -0
  11. data/app/assets/images/parapet/icons/activities/flaky-history.svg +3 -0
  12. data/app/assets/images/parapet/icons/activities/flight-recorder.svg +5 -0
  13. data/app/assets/images/parapet/icons/activities/health-graph.svg +3 -0
  14. data/app/assets/images/parapet/icons/activities/injection-engine.svg +3 -0
  15. data/app/assets/images/parapet/icons/activities/live-sql-stream.svg +5 -0
  16. data/app/assets/images/parapet/icons/activities/live-web-preview.svg +4 -0
  17. data/app/assets/images/parapet/icons/activities/monaco-editor.svg +5 -0
  18. data/app/assets/images/parapet/icons/activities/progress-area.svg +3 -0
  19. data/app/assets/images/parapet/icons/activities/registry-metadata.svg +5 -0
  20. data/app/assets/images/parapet/icons/activities/seed-inspector.svg +4 -0
  21. data/app/assets/images/parapet/icons/activities/slow-test-bottlenecks.svg +4 -0
  22. data/app/assets/images/parapet/icons/activities/smart-search.svg +4 -0
  23. data/app/assets/images/parapet/icons/activities/snippet-library.svg +4 -0
  24. data/app/assets/images/parapet/icons/activities/terminal.svg +4 -0
  25. data/app/assets/images/parapet/icons/activities/unified-schema-view.svg +4 -0
  26. data/app/assets/images/parapet/icons/activities/visual-comparison.svg +3 -0
  27. data/app/assets/images/parapet/icons/activities/visual-summary-bar.svg +5 -0
  28. data/app/assets/images/parapet/icons/nav/ideas.svg +4 -0
  29. data/app/assets/images/parapet/icons/nav/languages.svg +8 -0
  30. data/app/assets/images/parapet/icons/nav/profile.svg +4 -0
  31. data/app/assets/images/parapet/icons/nav/pulse.svg +7 -0
  32. data/app/assets/images/parapet/icons/nav/settings.svg +4 -0
  33. data/app/assets/images/parapet/icons/nav/state.svg +5 -0
  34. data/app/assets/images/parapet/icons/nav/styles.svg +7 -0
  35. data/app/assets/images/parapet/icons/nav/tests.svg +4 -0
  36. data/app/assets/images/parapet/icons/nav/traffic.svg +12 -0
  37. data/app/assets/javascripts/parapet/activity-card.js +439 -0
  38. data/app/assets/javascripts/parapet/activity-cards.js +291 -0
  39. data/app/assets/javascripts/parapet/activity-registry.js +181 -0
  40. data/app/assets/javascripts/parapet/activity-toggles.js +76 -0
  41. data/app/assets/javascripts/parapet/application.js +37 -0
  42. data/app/assets/javascripts/parapet/card-failure-inspector.js +134 -0
  43. data/app/assets/javascripts/parapet/card-file-editor.js +1722 -0
  44. data/app/assets/javascripts/parapet/card-progress-area.js +297 -0
  45. data/app/assets/javascripts/parapet/card-smart-search.js +114 -0
  46. data/app/assets/javascripts/parapet/card-snippet-library.js +251 -0
  47. data/app/assets/javascripts/parapet/explorer-resizer.js +92 -0
  48. data/app/assets/javascripts/parapet/parapet_qss.js +342 -0
  49. data/app/assets/javascripts/parapet/proficiency-filter.js +53 -0
  50. data/app/assets/javascripts/parapet/sidebar-resizer.js +197 -0
  51. data/app/assets/javascripts/parapet/styles-workbench.js +819 -0
  52. data/app/assets/javascripts/parapet/tree-logic.js +320 -0
  53. data/app/assets/stylesheets/parapet/_qss_variables.scss +122 -0
  54. data/app/assets/stylesheets/parapet/parapet_qss.scss +69 -0
  55. data/app/assets/stylesheets/parapet/qss_application.css +260940 -0
  56. data/app/assets/stylesheets/parapet/qss_application.css.map +1 -0
  57. data/app/assets/stylesheets/parapet/qss_application.scss +5 -0
  58. data/app/assets/stylesheets/parapet/qss_workspace.scss +209 -0
  59. data/app/assets/stylesheets/parapet/sample.qss +22 -0
  60. data/app/controllers/parapet/application_controller.rb +54 -0
  61. data/app/controllers/parapet/base_controller.rb +18 -0
  62. data/app/controllers/parapet/build_suite/dashboard_controller.rb +10 -0
  63. data/app/controllers/parapet/css_suite/compiler_controller.rb +95 -0
  64. data/app/controllers/parapet/css_suite/dashboard_controller.rb +10 -0
  65. data/app/controllers/parapet/css_suite/preview_controller.rb +46 -0
  66. data/app/controllers/parapet/dashboard_controller.rb +8 -0
  67. data/app/controllers/parapet/drafts_controller.rb +30 -0
  68. data/app/controllers/parapet/explorers_controller.rb +53 -0
  69. data/app/controllers/parapet/files_controller.rb +208 -0
  70. data/app/controllers/parapet/footers_controller.rb +24 -0
  71. data/app/controllers/parapet/intentions_suite/dashboard_controller.rb +10 -0
  72. data/app/controllers/parapet/landing_controller.rb +14 -0
  73. data/app/controllers/parapet/monitor_suite/dashboard_controller.rb +10 -0
  74. data/app/controllers/parapet/pm_suite/publish_controller.rb +16 -0
  75. data/app/controllers/parapet/polyglot_suite/dashboard_controller.rb +10 -0
  76. data/app/controllers/parapet/runner_controller.rb +34 -0
  77. data/app/controllers/parapet/sessions_controller.rb +35 -0
  78. data/app/controllers/parapet/signal_suite/dashboard_controller.rb +10 -0
  79. data/app/controllers/parapet/test_suite/dashboard_controller.rb +10 -0
  80. data/app/controllers/parapet/widgets_controller.rb +33 -0
  81. data/app/helpers/parapet/application_helper.rb +23 -0
  82. data/app/helpers/parapet/icon_helper.rb +57 -0
  83. data/app/jobs/parapet/application_job.rb +4 -0
  84. data/app/mailers/parapet/application_mailer.rb +6 -0
  85. data/app/models/parapet/activity.rb +14 -0
  86. data/app/models/parapet/application_record.rb +5 -0
  87. data/app/models/parapet/current.rb +6 -0
  88. data/app/models/parapet/draft_recovery.rb +9 -0
  89. data/app/models/parapet/internal_record.rb +34 -0
  90. data/app/models/parapet/run_history.rb +22 -0
  91. data/app/models/parapet/runtime_setting.rb +8 -0
  92. data/app/models/parapet/session.rb +7 -0
  93. data/app/models/parapet/suite_assignment.rb +30 -0
  94. data/app/models/parapet/user.rb +12 -0
  95. data/app/services/parapet/activity_phrase_renderer.rb +35 -0
  96. data/app/services/parapet/activity_writer.rb +26 -0
  97. data/app/services/parapet/authorization.rb +198 -0
  98. data/app/services/parapet/consistency_auditor.rb +58 -0
  99. data/app/services/parapet/directory_scanner.rb +159 -0
  100. data/app/services/parapet/draft_recovery_store.rb +18 -0
  101. data/app/services/parapet/explorer_manager.rb +402 -0
  102. data/app/services/parapet/file_save_service.rb +21 -0
  103. data/app/services/parapet/git_side_ref_committer.rb +50 -0
  104. data/app/services/parapet/noun_discovery.rb +26 -0
  105. data/app/services/parapet/publish_gate.rb +83 -0
  106. data/app/services/parapet/test_runner.rb +178 -0
  107. data/app/views/layouts/parapet/application.html.erb +83 -0
  108. data/app/views/layouts/parapet/landing.html.erb +19 -0
  109. data/app/views/parapet/activities/_ai_error_explainer.html.erb +33 -0
  110. data/app/views/parapet/activities/_ai_explainer_widget.html.erb +20 -0
  111. data/app/views/parapet/activities/_calendar_widget.html.erb +31 -0
  112. data/app/views/parapet/activities/_code_snippets_widget.html.erb +22 -0
  113. data/app/views/parapet/activities/_coverage_explorer.html.erb +1 -0
  114. data/app/views/parapet/activities/_dropdown_test.html.erb +20 -0
  115. data/app/views/parapet/activities/_explorer_node.html.erb +102 -0
  116. data/app/views/parapet/activities/_explorer_tree.html.erb +16 -0
  117. data/app/views/parapet/activities/_file_editor.html.erb +183 -0
  118. data/app/views/parapet/activities/_file_explorer.html.erb +23 -0
  119. data/app/views/parapet/activities/_fix_suggestions_widget.html.erb +13 -0
  120. data/app/views/parapet/activities/_generic.html.erb +6 -0
  121. data/app/views/parapet/activities/_live_preview.html.erb +13 -0
  122. data/app/views/parapet/activities/_perspective_filters.html.erb +36 -0
  123. data/app/views/parapet/activities/_progress_area.html.erb +37 -0
  124. data/app/views/parapet/activities/_project_explorer.html.erb +1 -0
  125. data/app/views/parapet/activities/_registry_explorer.html.erb +1 -0
  126. data/app/views/parapet/activities/_run_history.html.erb +2 -0
  127. data/app/views/parapet/activities/_smart_search.html.erb +11 -0
  128. data/app/views/parapet/activities/file_explorer/_perspective_scripts.html.erb +58 -0
  129. data/app/views/parapet/activities/file_explorer/_perspectives.html.erb +0 -0
  130. data/app/views/parapet/activities/file_explorer/_viewport_scripts.html.erb +33 -0
  131. data/app/views/parapet/css_suite/_workbench.html.erb +51 -0
  132. data/app/views/parapet/dashboards/index.html.erb +113 -0
  133. data/app/views/parapet/debug_panel.html.erb +79 -0
  134. data/app/views/parapet/debug_panel_tailwind.html.erb +98 -0
  135. data/app/views/parapet/erb_patch_test.html.erb +5 -0
  136. data/app/views/parapet/explorers/show.html.erb +1 -0
  137. data/app/views/parapet/git_viewer.html.erb +62 -0
  138. data/app/views/parapet/git_viewer_after.html.erb +14 -0
  139. data/app/views/parapet/git_viewer_before.html.erb +14 -0
  140. data/app/views/parapet/git_viewer_governed.html.erb +88 -0
  141. data/app/views/parapet/git_viewer_governed_after.html.erb +36 -0
  142. data/app/views/parapet/git_viewer_governed_before.html.erb +36 -0
  143. data/app/views/parapet/git_viewer_tailwind.html.erb +88 -0
  144. data/app/views/parapet/git_viewer_thin_wrapper.html.erb +85 -0
  145. data/app/views/parapet/hierarchical_layout_test.html.erb +85 -0
  146. data/app/views/parapet/hybrid_stress_test.html.erb +25 -0
  147. data/app/views/parapet/index.html.erb +35 -0
  148. data/app/views/parapet/mutation_stress_test.html.erb +82 -0
  149. data/app/views/parapet/recursive_anchoring_test.html.erb +66 -0
  150. data/app/views/parapet/sessions/new.html.erb +28 -0
  151. data/app/views/parapet/shared/_cad_inspector.html.erb +92 -0
  152. data/app/views/parapet/shared/_discovery_sidebar.html.erb +48 -0
  153. data/app/views/parapet/shared/_explorer_bar.html.erb +71 -0
  154. data/app/views/parapet/shared/_footer.html.erb +10 -0
  155. data/app/views/parapet/shared/_footer_ideas.html.erb +36 -0
  156. data/app/views/parapet/shared/_footer_languages.html.erb +33 -0
  157. data/app/views/parapet/shared/_footer_pulse.html.erb +33 -0
  158. data/app/views/parapet/shared/_footer_state.html.erb +33 -0
  159. data/app/views/parapet/shared/_footer_styles.html.erb +37 -0
  160. data/app/views/parapet/shared/_footer_tests.html.erb +39 -0
  161. data/app/views/parapet/shared/_footer_traffic.html.erb +45 -0
  162. data/app/views/parapet/shared/_global_nav.html.erb +55 -0
  163. data/app/views/parapet/shared/_head.html.erb +4 -0
  164. data/app/views/parapet/shared/_header.html.erb +20 -0
  165. data/app/views/parapet/shared/_object_inspector.html.erb +76 -0
  166. data/app/views/parapet/shared/_ribbon_header.html.erb +3 -0
  167. data/app/views/parapet/shared/_root_selector_modal.html.erb +15 -0
  168. data/app/views/parapet/shared/_tab_bar.html.erb +62 -0
  169. data/app/views/parapet/shared/activities_ribbon/_agent_group.html.erb +15 -0
  170. data/app/views/parapet/shared/activities_ribbon/_analysis_group.html.erb +15 -0
  171. data/app/views/parapet/shared/activities_ribbon/_architect_group.html.erb +21 -0
  172. data/app/views/parapet/shared/activities_ribbon/_explorer_group.html.erb +12 -0
  173. data/app/views/parapet/shared/activities_ribbon/_inspector_group.html.erb +15 -0
  174. data/app/views/parapet/shared/activities_ribbon/_proficiency_filter.html.erb +24 -0
  175. data/app/views/parapet/shared/activities_ribbon/_runner_group.html.erb +21 -0
  176. data/app/views/parapet/shared/activities_ribbon/_spatial_inspector_group.html.erb +18 -0
  177. data/app/views/parapet/shared/activities_ribbon/_studio_group.html.erb +18 -0
  178. data/app/views/parapet/shared/activities_ribbon/_stylist_group.html.erb +18 -0
  179. data/app/views/parapet/shared/explorers/_base.html.erb +107 -0
  180. data/app/views/parapet/shared/explorers/_css.html.erb +1 -0
  181. data/app/views/parapet/shared/explorers/_intentions.html.erb +1 -0
  182. data/app/views/parapet/shared/explorers/_project.html.erb +1 -0
  183. data/app/views/parapet/shared/explorers/_tests.html.erb +1 -0
  184. data/app/views/parapet/shared/head/_meta.html.erb +3 -0
  185. data/app/views/parapet/shared/head/_styles_activities.html.erb +152 -0
  186. data/app/views/parapet/shared/head/_styles_shared.html.erb +52 -0
  187. data/app/views/parapet/shared/head/_styles_workspace.html.erb +67 -0
  188. data/app/views/parapet/shared/ribbon_header/_diagnostic_controls.html.erb +17 -0
  189. data/app/views/parapet/shared/ribbon_header/_execution_controls.html.erb +28 -0
  190. data/app/views/parapet/shared/ribbon_header/_focus_search.html.erb +5 -0
  191. data/app/views/parapet/shared/root_selector/_header.html.erb +10 -0
  192. data/app/views/parapet/shared/root_selector/_path_input.html.erb +13 -0
  193. data/app/views/parapet/shared/root_selector/_quick_select.html.erb +20 -0
  194. data/app/views/parapet/shared/root_selector/_scripts.html.erb +61 -0
  195. data/app/views/parapet/shared/root_selector/_warning.html.erb +11 -0
  196. data/app/views/parapet/shared/widgets/_base.html.erb +6 -0
  197. data/app/views/parapet/shared/widgets/_css.html.erb +79 -0
  198. data/app/views/parapet/widgets/_explainer.html.erb +17 -0
  199. data/app/views/parapet/widgets/_hotkeys.html.erb +20 -0
  200. data/app/views/parapet/widgets/_search.html.erb +68 -0
  201. data/app/views/parapet/widgets/_spatial_inspector.html.erb +54 -0
  202. data/app/views/parapet/widgets/_widget-explainer.html.erb +17 -0
  203. data/app/views/parapet/widgets/_widget-hotkeys.html.erb +20 -0
  204. data/app/views/parapet/widgets/_widget-search.html.erb +68 -0
  205. data/app/views/parapet/widgets/_widget-snippets.html.erb +18 -0
  206. data/app/views/parapet/widgets/_widget-suggestions.html.erb +15 -0
  207. data/app/views/parapet/widgets/show.html.erb +3 -0
  208. data/app/views/shared/_footer.html.erb +0 -0
  209. data/config/parapet/qss_config.yml +117 -0
  210. data/config/parapet/qss_registry.yml +14 -0
  211. data/config/routes.rb +40 -0
  212. data/db/migrate/20260916035300_create_run_histories.rb +16 -0
  213. data/db/migrate/20261002000000_expand_run_histories_for_activity_tracking.rb +7 -0
  214. data/db/parapet_migrate/20260929184200_create_parapet_users.rb +12 -0
  215. data/db/parapet_migrate/20260929184300_create_parapet_sessions.rb +9 -0
  216. data/db/parapet_migrate/20260930000000_create_parapet_runtime_settings.rb +12 -0
  217. data/db/parapet_migrate/20260930000100_create_parapet_suite_assignments.rb +14 -0
  218. data/db/parapet_migrate/20261002000000_create_parapet_activities.rb +16 -0
  219. data/db/parapet_migrate/20261002000100_create_parapet_draft_recoveries.rb +13 -0
  220. data/db/parapet_schema.rb +64 -0
  221. data/db/schema.rb +21 -0
  222. data/db/schema.released.rb +0 -0
  223. data/lib/parapet/engine.rb +11 -0
  224. data/lib/parapet/environment_guard.rb +14 -0
  225. data/lib/parapet/version.rb +3 -0
  226. data/lib/parapet-rails.rb +1 -0
  227. data/lib/parapet.rb +59 -0
  228. data/lib/tasks/parapet_qss.rake +26 -0
  229. data/lib/tasks/parapet_tasks.rake +25 -0
  230. data/lib/tasks/randomize_explorer_health.rb +101 -0
  231. metadata +423 -0
@@ -0,0 +1,1722 @@
1
+ (function() {
2
+
3
+ // --- Native Anchor Positioning + Popover replacement gate (stub, not active) ---
4
+ // Detects real browser capability, but capability alone does NOT flip this on --
5
+ // portalDropdownNative() below is unimplemented. Flip
6
+ // NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY only once that method is built and
7
+ // verified in a real browser. Until then this always resolves to the existing
8
+ // portalDropdown()/closeAllDropdowns() path, unchanged, on every browser.
9
+ const SUPPORTS_NATIVE_POPOVER_ANCHOR =
10
+ typeof HTMLElement !== 'undefined' &&
11
+ typeof HTMLElement.prototype.showPopover === 'function' &&
12
+ typeof CSS !== 'undefined' &&
13
+ CSS.supports('anchor-name: --a') &&
14
+ CSS.supports('position-anchor: --a');
15
+
16
+ const NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY = false; // set true only when portalDropdownNative() is finished
17
+
18
+ const USE_NATIVE_POPOVER_ANCHOR = SUPPORTS_NATIVE_POPOVER_ANCHOR && NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY;
19
+
20
+ class FileEditorCard extends ActivityCard {
21
+
22
+ get cardTitle() { return 'File Editor'; }
23
+ get cardKeyArea() { return 'Studio'; }
24
+ get cardProficiency() { return 0; }
25
+ get cardIcon() {
26
+ return `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
27
+ <path class="qss-icon-path" d="M17 13L22 18L17 23" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
28
+ <path class="qss-icon-path" d="M7 23L2 18L7 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
29
+ <path class="qss-icon-path" d="M14 4L10 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
30
+ </svg>`;
31
+ }
32
+
33
+ defaults() {
34
+ return Object.assign({}, super.defaults(), {
35
+ orientation: 'neutral',
36
+ size: 'large',
37
+ width: 800, height: 500,
38
+ minWidth: 400, minHeight: 300,
39
+ panes: [
40
+ {
41
+ openFiles: [{ name: 'untitled.rb', buffer: '', language: 'ruby' }],
42
+ activeFileIndex: 0,
43
+ linked: false,
44
+ drawerOpen: false
45
+ }
46
+ ],
47
+ activePaneIndex: 0
48
+ });
49
+ }
50
+
51
+ // Card header actions — Act on the active pane
52
+ renderHeaderActions() {
53
+ const template = document.getElementById('qss-tpl-header-actions');
54
+ if (!template) return '';
55
+
56
+ const clone = template.content.cloneNode(true);
57
+ const paneIdx = this.state.activePaneIndex || 0;
58
+
59
+ // Assign pane index to actions
60
+ clone.querySelectorAll('[data-action]').forEach(btn => {
61
+ btn.setAttribute('data-pane', paneIdx);
62
+ });
63
+
64
+ const div = document.createElement('div');
65
+ div.appendChild(clone);
66
+ return div.innerHTML;
67
+ }
68
+
69
+ async handleFileSelected(event) {
70
+ if (!event.detail || !event.detail.path) return;
71
+ const path = event.detail.path;
72
+ const paneIdx = this.state.activePaneIndex || 0;
73
+ const pane = this.state.panes[paneIdx];
74
+ if (!pane) return;
75
+
76
+ const existingIdx = pane.openFiles.findIndex(f => f.path === path);
77
+ if (existingIdx !== -1) {
78
+ pane.activeFileIndex = existingIdx;
79
+ this.renderPaneTabs(paneIdx);
80
+ this.loadPaneFile(paneIdx);
81
+ } else {
82
+ await this.openFile(path, paneIdx);
83
+ }
84
+ }
85
+
86
+
87
+ onInit() {
88
+ this.draftTimers = this.draftTimers || new Map();
89
+ this.draftRequests = this.draftRequests || new Map();
90
+ this.programmaticModelUpdates = new WeakSet();
91
+
92
+ // Clean up existing listeners
93
+ if (this._onFileSelected) document.removeEventListener('parapet:fileSelected', this._onFileSelected);
94
+
95
+ // Create hidden file input for the "Open" action
96
+ this.filePicker = document.createElement('input');
97
+ this.filePicker.type = 'file';
98
+ this.filePicker.multiple = true;
99
+ this.filePicker.style.display = 'none';
100
+ this.element.appendChild(this.filePicker);
101
+
102
+ this.filePicker.addEventListener('change', (e) => {
103
+ const files = Array.from(e.target.files);
104
+ const targetPane = this._targetPaneForPicker || this.state.activePaneIndex || 0;
105
+
106
+ files.forEach(file => {
107
+ const reader = new FileReader();
108
+ reader.onload = (re) => {
109
+ this.openFile(file.name, targetPane, {
110
+ name: file.name,
111
+ path: `external://${file.name}`,
112
+ buffer: re.target.result,
113
+ language: this.getLanguageFromExt(file.name)
114
+ });
115
+ };
116
+ reader.readAsText(file);
117
+ });
118
+ // Reset for next use
119
+ this.filePicker.value = '';
120
+ });
121
+
122
+ // Migrate/Reset state
123
+ if (!this.state.panes || !Array.isArray(this.state.panes)) {
124
+ this.state.panes = [{
125
+ openFiles: [],
126
+ activeFileIndex: 0,
127
+ linkedTo: null,
128
+ drawerOpen: false
129
+ }];
130
+ delete this.state.openFiles;
131
+ delete this.state.activeFileIndex;
132
+ this.saveState();
133
+ } else {
134
+ // Ensure state consistency for dogfooding
135
+ this.state.panes.forEach((p, i) => {
136
+ if (p.drawerOpen === undefined) p.drawerOpen = false;
137
+ p.openFiles.forEach(f => {
138
+ if (f.linkedTo === undefined) f.linkedTo = null;
139
+ });
140
+ delete p.linkedTo;
141
+ });
142
+ }
143
+
144
+ this.viewport = this.element.querySelector('.qss-editor-viewport');
145
+
146
+ // Define listener and store reference for cleanup
147
+ this._onFileSelected = (e) => this.handleFileSelected(e);
148
+ document.addEventListener('parapet:fileSelected', this._onFileSelected);
149
+
150
+ this._onRevealLine = async (e) => {
151
+ const { path, line, message } = e.detail;
152
+ if (!path || !line) return;
153
+
154
+ // 1. Open the file in the active pane
155
+ await this.openFile(path);
156
+
157
+ // 2. Find the pane that now has this file active
158
+ const targetPaneIdx = this.state.panes.findIndex(p => p.openFiles[p.activeFileIndex]?.path === path);
159
+ if (targetPaneIdx === -1) return;
160
+
161
+ const instance = this.paneInstances[targetPaneIdx];
162
+ if (!instance || !instance.editor) return;
163
+
164
+ // 3. Reveal and Select
165
+ setTimeout(() => {
166
+ instance.editor.revealLineInCenter(line);
167
+ instance.editor.setSelection({
168
+ startLineNumber: line,
169
+ startColumn: 1,
170
+ endLineNumber: line,
171
+ endColumn: 1000
172
+ });
173
+
174
+ // Focus the editor
175
+ instance.editor.focus();
176
+
177
+ // 4. Show Pane Status with error message
178
+ this.showPaneStatus(targetPaneIdx, `Error at L${line}: ${message}`, true);
179
+
180
+ // 5. Open/Update the Fix Footer
181
+ this.showFixFooter(targetPaneIdx, line, message);
182
+
183
+ // 6. Spawn AI Error Explainer Card if not active
184
+ if (!ActivityCards.active['ai-error-explainer']) {
185
+ ActivityCards.spawn({ id: 'ai-error-explainer' });
186
+ }
187
+
188
+ // 7. Update the Sidebar Widget to show the AI-Error tab
189
+ const widgetTab = document.querySelector('.qss-widget-tab[data-target="ai-error"]');
190
+ if (widgetTab) widgetTab.click();
191
+
192
+ }, 100);
193
+ };
194
+ document.addEventListener('parapet:revealLine', this._onRevealLine);
195
+
196
+ this._onInsertSnippet = (e) => {
197
+ const { code } = e.detail;
198
+ if (!code) return;
199
+
200
+ // Insert into the editor in the active pane
201
+ const paneIdx = this.state.activePaneIndex || 0;
202
+ const instance = this.paneInstances[paneIdx];
203
+ if (!instance || !instance.editor) return;
204
+
205
+ const selection = instance.editor.getSelection();
206
+ const range = new this.monaco.Range(
207
+ selection.startLineNumber,
208
+ selection.startColumn,
209
+ selection.endLineNumber,
210
+ selection.endColumn
211
+ );
212
+
213
+ instance.editor.executeEdits("snippet", [{
214
+ range: range,
215
+ text: code,
216
+ forceMoveMarkers: true
217
+ }]);
218
+
219
+ instance.editor.focus();
220
+ this.showPaneStatus(paneIdx, "Snippet Inserted");
221
+ };
222
+ document.addEventListener('parapet:insertSnippet', this._onInsertSnippet);
223
+
224
+ // Drag and Drop support
225
+ this.element.addEventListener('dragover', (e) => {
226
+ e.preventDefault();
227
+ e.dataTransfer.dropEffect = 'copy';
228
+ this.element.classList.add('qss-drag-over');
229
+ });
230
+
231
+ this.element.addEventListener('dragleave', () => {
232
+ this.element.classList.remove('qss-drag-over');
233
+ });
234
+
235
+ this.element.addEventListener('drop', async (e) => {
236
+ e.preventDefault();
237
+ this.element.classList.remove('qss-drag-over');
238
+
239
+ const path = e.dataTransfer.getData('text/plain');
240
+ if (path) {
241
+ console.log("FileEditorCard: Dropped file:", path);
242
+ const paneIdx = this.state.activePaneIndex || 0;
243
+ await this.handleFileSelected({ detail: { path: path } });
244
+ }
245
+ });
246
+
247
+ if (typeof require === 'undefined') return;
248
+ require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
249
+ require(['vs/editor/editor.main'], (monaco) => {
250
+ this.monaco = monaco;
251
+ this.viewport = this.element.querySelector('.qss-editor-viewport');
252
+ if (!this.viewport) return;
253
+
254
+ this.renderPanes();
255
+ this.initActionHandlers();
256
+ });
257
+ }
258
+
259
+ onDestroy() {
260
+ if (this._onFileSelected) document.removeEventListener('parapet:fileSelected', this._onFileSelected);
261
+ if (this._onRevealLine) document.removeEventListener('parapet:revealLine', this._onRevealLine);
262
+ if (this._onInsertSnippet) document.removeEventListener('parapet:insertSnippet', this._onInsertSnippet);
263
+
264
+ // Dispose Monaco instances
265
+ if (this.paneInstances) {
266
+ this.paneInstances.forEach(p => {
267
+ if (p.editor) p.editor.dispose();
268
+ });
269
+ }
270
+ }
271
+
272
+ renderPanes() {
273
+ if (!this.viewport) return;
274
+
275
+ try {
276
+ // Save all current scroll positions before any potential DOM churn
277
+ if (this.paneInstances) {
278
+ this.paneInstances.forEach((instance, idx) => {
279
+ const pane = this.state.panes[idx];
280
+ if (pane && instance.editor) {
281
+ const file = pane.openFiles[pane.activeFileIndex];
282
+ if (file) file.scrollPosition = instance.editor.getScrollTop();
283
+ }
284
+ });
285
+ }
286
+
287
+ // Sanity check for state
288
+ if (!this.state.panes || !Array.isArray(this.state.panes)) {
289
+ this.state.panes = [{
290
+ openFiles: [{ name: 'untitled.rb', buffer: '', language: 'ruby' }],
291
+ activeFileIndex: 0,
292
+ linked: false
293
+ }];
294
+ try { this.saveState(); } catch(e) {}
295
+ }
296
+
297
+ // Only perform full innerHTML reset if the number of panes changed
298
+ // or if we haven't rendered yet.
299
+ const needsFullReset = !this.paneInstances ||
300
+ this.paneInstances.length !== this.state.panes.length;
301
+
302
+ if (needsFullReset) {
303
+ // Dispose old editors properly
304
+ if (this.paneInstances) {
305
+ this.paneInstances.forEach(p => {
306
+ try { if (p.editor) p.editor.dispose(); } catch(e) {}
307
+ });
308
+ }
309
+
310
+ this.viewport.innerHTML = '';
311
+ this.paneInstances = [];
312
+
313
+ this.state.panes.forEach((paneState, index) => {
314
+ try {
315
+ const paneEl = this.createPaneElement(index);
316
+ this.viewport.appendChild(paneEl);
317
+
318
+ const editorTarget = paneEl.querySelector('.qss-monaco-editor-target');
319
+ const editor = this.createEditor(editorTarget, index);
320
+
321
+ this.paneInstances.push({
322
+ element: paneEl,
323
+ editor: editor,
324
+ tabStrip: paneEl.querySelector('.qss-editor-tab-strip'),
325
+ models: {}
326
+ });
327
+
328
+ this.renderPaneTabs(index);
329
+ this.loadPaneFile(index);
330
+ } catch (paneErr) {
331
+ console.error(`FileEditorCard: Pane ${index} render failed`, paneErr);
332
+ }
333
+ });
334
+ } else {
335
+ // Incremental update: Just refresh tabs and content
336
+ this.state.panes.forEach((paneState, index) => {
337
+ this.renderPaneTabs(index);
338
+ this.loadPaneFile(index);
339
+ });
340
+ }
341
+
342
+ this.updatePaneFocus();
343
+
344
+ // Clear debug outline
345
+ this.element.style.outline = 'none';
346
+
347
+ // Force a layout pass
348
+ setTimeout(() => this.onResize(), 10);
349
+
350
+ } catch (err) {
351
+ console.error("FileEditorCard: Global render failed", err);
352
+ }
353
+ }
354
+
355
+ getNewFileTemplates() {
356
+ // For now, hardcode 'test' as the suite, but could be dynamic
357
+ const suite = 'test';
358
+
359
+ const templates = {
360
+ test: [
361
+ { label: 'RSpec Spec', icon: 'test-tube', ext: '_spec.rb', defaultDir: 'spec', content: "# frozen_string_literal: true\n\nrequire 'rails_helper'\n\nRSpec.describe \"Something\" do\n it \"does something\" do\n end\nend\n" },
362
+ { label: 'Minitest Test', icon: 'file-check', ext: '_test.rb', defaultDir: 'test', content: "require 'test_helper'\n\nclass SomethingTest < ActiveSupport::TestCase\n test \"the truth\" do\n assert true\n end\nend\n" },
363
+ { label: 'Factory', icon: 'factory', ext: '_factory.rb', defaultDir: 'spec/factories', content: "FactoryBot.define do\n factory :something do\n end\nend\n" },
364
+ { label: 'Support Module', icon: 'lifesaver', ext: '.rb', defaultDir: 'spec/support', content: "module Support\n module Something\n end\nend\n" },
365
+ { label: 'Rake Task', icon: 'command', ext: '.rake', defaultDir: 'lib/tasks', content: "namespace :something do\n desc \"description\"\n task :run do\n end\nend\n" },
366
+ { label: 'Config JSON', icon: 'settings', ext: '.json', defaultDir: 'config', content: "{\n \"key\": \"value\"\n}\n" }
367
+ ],
368
+ general: [
369
+ { label: 'Ruby Script', icon: 'file-code', ext: '.rb', defaultDir: 'lib', content: "# frozen_string_literal: true\n\n" },
370
+ { label: 'Text File', icon: 'file-text', ext: '.txt', defaultDir: 'doc', content: "" }
371
+ ]
372
+ };
373
+
374
+ return templates[suite] || templates.general;
375
+ }
376
+
377
+ createPaneElement(index) {
378
+ const tpl = document.getElementById('qss-tpl-editor-pane');
379
+ const paneEl = tpl.content.cloneNode(true).firstElementChild;
380
+ const pane = this.state.panes[index];
381
+ const isActive = index === (this.state.activePaneIndex || 0);
382
+
383
+ paneEl.id = `pp-editor-pane-${this.id}-${index}`;
384
+ paneEl.dataset.index = index;
385
+ paneEl.dataset.prptActive = isActive;
386
+
387
+ // Wire up constant action buttons
388
+ paneEl.querySelectorAll('[data-action]').forEach(btn => {
389
+ btn.dataset.pane = index;
390
+ });
391
+
392
+ // Initial drawer state
393
+ const drawer = paneEl.querySelector('.qss-snippet-drawer');
394
+ if (drawer) {
395
+ drawer.style.setProperty('--drawer-w', pane.drawerOpen ? '220px' : '0px');
396
+ }
397
+
398
+ // Hide/Show close split button
399
+ const closeSplitBtn = paneEl.querySelector('.qss-close-split-btn');
400
+ if (closeSplitBtn) {
401
+ closeSplitBtn.classList.toggle('qss-hidden', index === 0);
402
+ }
403
+
404
+ return paneEl;
405
+ }
406
+ updatePaneFocus() {
407
+ this.paneInstances.forEach((instance, idx) => {
408
+ const isActive = idx === this.state.activePaneIndex;
409
+ const tabBar = instance.element.querySelector('.qss-editor-tab-bar');
410
+
411
+ // Active pane: Highlighted border and shadow
412
+ if (isActive) {
413
+ instance.element.classList.add('qss-ring-1', 'qss-ring-indigo-500', 'qss-z-10', 'qss-shadow-2xl', 'qss-shadow-black');
414
+ } else {
415
+ instance.element.classList.remove('qss-ring-1', 'qss-ring-indigo-500', 'qss-z-10', 'qss-shadow-2xl', 'qss-shadow-black');
416
+ }
417
+
418
+ // Always full opacity and color
419
+ instance.element.classList.add('qss-opacity-100');
420
+ instance.element.classList.remove('qss-opacity-40', 'qss-grayscale');
421
+
422
+ if (tabBar) {
423
+ tabBar.classList.toggle('qss-bg-[#2a2e38]', isActive);
424
+ tabBar.classList.toggle('qss-bg-[#1a1d23]', !isActive);
425
+ }
426
+ });
427
+ this.updatePaneActions(this.state.activePaneIndex);
428
+ }
429
+
430
+ createEditor(container, paneIndex) {
431
+ const editor = this.monaco.editor.create(container, {
432
+ value: '',
433
+ language: 'ruby',
434
+ theme: 'vs-dark',
435
+ fontSize: 13,
436
+ minimap: { enabled: false },
437
+ automaticLayout: true,
438
+ scrollBeyondLastLine: false,
439
+ wordWrap: 'on',
440
+ contextmenu: false,
441
+ lineNumbersMinChars: 3,
442
+ glyphMargin: false,
443
+ folding: false,
444
+ lineDecorationsWidth: 5
445
+ });
446
+
447
+ editor.onMouseDown(() => {
448
+ this.state.activePaneIndex = paneIndex;
449
+ this.updatePaneFocus();
450
+ this.saveState();
451
+ });
452
+
453
+ // Ctrl+S Keybinding
454
+ editor.addCommand(this.monaco.KeyMod.CtrlCmd | this.monaco.KeyCode.KeyS, () => {
455
+ this.saveCurrentFile(paneIndex);
456
+ });
457
+
458
+ editor.onDidChangeModelContent((event) => {
459
+ if (event.isFlush || this.programmaticModelUpdates.has(editor)) return;
460
+
461
+ const pane = this.state.panes[paneIndex];
462
+ const activeFile = pane.openFiles[pane.activeFileIndex];
463
+ if (activeFile && activeFile.path) {
464
+ activeFile.buffer = editor.getValue();
465
+ this.queueDraftSnapshot(activeFile);
466
+
467
+ // Mark dirty if content changed
468
+ if (!activeFile.isDirty) {
469
+ activeFile.isDirty = true;
470
+ this.renderPaneTabs(paneIndex);
471
+ }
472
+
473
+ this.saveState();
474
+
475
+ // Propagation: Sync tabs in other panes ONLY if they are explicitly linked to this pane
476
+ this.state.panes.forEach((p, pIdx) => {
477
+ if (pIdx === paneIndex) return; // Skip own pane
478
+
479
+ const targetActiveFile = p.openFiles[p.activeFileIndex];
480
+ if (targetActiveFile && targetActiveFile.linkedTo === paneIndex) {
481
+ targetActiveFile.buffer = activeFile.buffer;
482
+
483
+ // Update the Monaco instance for the target pane
484
+ const instance = this.paneInstances[pIdx];
485
+ if (instance && instance.editor && instance.editor.getValue() !== targetActiveFile.buffer) {
486
+ this.withProgrammaticModelUpdateSuppressed(instance.editor, () => {
487
+ instance.editor.setValue(targetActiveFile.buffer);
488
+ });
489
+ }
490
+ }
491
+ });
492
+ }
493
+ });
494
+
495
+ return editor;
496
+ }
497
+
498
+ withProgrammaticModelUpdateSuppressed(editor, update) {
499
+ if (!editor) {
500
+ update();
501
+ return;
502
+ }
503
+
504
+ this.programmaticModelUpdates.add(editor);
505
+ try {
506
+ update();
507
+ } finally {
508
+ this.programmaticModelUpdates.delete(editor);
509
+ }
510
+ }
511
+
512
+ loadPaneFile(paneIndex) {
513
+ try {
514
+ const pane = this.state.panes[paneIndex];
515
+ const file = pane.openFiles[pane.activeFileIndex];
516
+ const instance = this.paneInstances[paneIndex];
517
+ if (!instance || !instance.editor) return;
518
+
519
+ if (!file) {
520
+ if (!this._nullModel) {
521
+ this._nullModel = this.monaco.editor.createModel('', 'plaintext');
522
+ }
523
+ instance.editor.setModel(this._nullModel);
524
+ return;
525
+ }
526
+
527
+ const lang = file.language || 'ruby';
528
+ // Use the file path as a stable key instead of the unstable array index
529
+ const modelKey = file.path || `untitled-${file.name}`;
530
+
531
+ if (!instance.models[modelKey]) {
532
+ instance.models[modelKey] = this.monaco.editor.createModel(file.buffer || '', lang);
533
+ } else {
534
+ // Update content if it has changed (e.g., via global buffer sync)
535
+ if (instance.models[modelKey].getValue() !== file.buffer) {
536
+ this.withProgrammaticModelUpdateSuppressed(instance.editor, () => {
537
+ instance.models[modelKey].setValue(file.buffer || '');
538
+ });
539
+ }
540
+ }
541
+ instance.editor.setModel(instance.models[modelKey]);
542
+
543
+ // Restore scroll position
544
+ if (file.scrollPosition !== undefined) {
545
+ instance.editor.setScrollTop(file.scrollPosition);
546
+ }
547
+
548
+ // If drawer is open, refresh snippets for new file type
549
+ if (pane.drawerOpen) {
550
+ this.renderDrawerSnippets(paneIndex);
551
+ }
552
+
553
+ this.updatePaneActions(paneIndex);
554
+ } catch (err) {
555
+ console.error(`FileEditorCard: loadPaneFile(${paneIndex}) failed`, err);
556
+ }
557
+ }
558
+
559
+ updatePaneActions(paneIndex) {
560
+ const instance = this.paneInstances[paneIndex];
561
+ if (!instance) return;
562
+
563
+ const pane = this.state.panes[paneIndex];
564
+ const file = pane.openFiles[pane.activeFileIndex];
565
+
566
+ // Refresh header actions if this is the active pane
567
+ if (paneIndex === this.state.activePaneIndex) {
568
+ const headerActions = this.element.querySelector('.qss-header-actions');
569
+ if (headerActions) {
570
+ headerActions.innerHTML = this.renderHeaderActions();
571
+ }
572
+ }
573
+
574
+ // 1. Update Save Button
575
+ const saveBtns = instance.element.querySelectorAll('[data-action="save"]');
576
+ saveBtns.forEach(saveBtn => {
577
+ if (file) {
578
+ saveBtn.classList.remove('qss-hidden');
579
+ saveBtn.classList.toggle('qss-text-emerald-400', file.isDirty);
580
+ saveBtn.classList.toggle('qss-text-slate-500', !file.isDirty);
581
+ saveBtn.title = file.isDirty ? "Save Changes (Ctrl+S)" : "File Saved";
582
+ } else {
583
+ saveBtn.classList.add('qss-hidden');
584
+ }
585
+ });
586
+
587
+ // 2. Update Run Button
588
+ const runBtns = instance.element.querySelectorAll('[data-action="run"]');
589
+ const headerRunBtn = this.element.querySelector('.qss-header-actions [data-action="run"]');
590
+
591
+ const updateRunUI = (btn) => {
592
+ if (!btn) return;
593
+ if (file && file.path) {
594
+ btn.classList.remove('qss-opacity-20', 'qss-pointer-events-none');
595
+ btn.classList.remove('qss-text-rose-400', 'qss-text-emerald-400', 'qss-text-green-400', 'qss-text-indigo-400', 'qss-text-slate-500', 'qss-text-red-400');
596
+ const filename = file.name || "";
597
+
598
+
599
+
600
+ let templateId = 'qss-tpl-run-content-default';
601
+ let runTitle = "Run Tool";
602
+ let runColor = 'qss-text-slate-500';
603
+
604
+ if (filename.endsWith('_spec.rb')) {
605
+ templateId = 'qss-tpl-run-content-rspec';
606
+ runTitle = "Run RSpec Spec";
607
+ runColor = 'qss-text-red-400';
608
+ } else if (filename.endsWith('_test.rb')) {
609
+ templateId = 'qss-tpl-run-content-minitest';
610
+ runTitle = "Run Minitest Test";
611
+ runColor = 'qss-text-emerald-400';
612
+ } else if (filename.endsWith('.feature')) {
613
+ templateId = 'qss-tpl-run-content-cucumber';
614
+ runTitle = "Run Cucumber Feature";
615
+ runColor = 'qss-text-emerald-400';
616
+ }
617
+
618
+ const tpl = document.getElementById(templateId);
619
+ if (tpl) {
620
+ btn.innerHTML = '';
621
+ const wrap = document.createElement('div');
622
+ wrap.className = 'qss-flex qss-items-center';
623
+ wrap.appendChild(tpl.content.cloneNode(true));
624
+ btn.appendChild(wrap);
625
+ }
626
+
627
+ btn.title = runTitle;
628
+ btn.classList.add(runColor);
629
+ } else {
630
+ btn.classList.add('qss-opacity-20', 'qss-pointer-events-none');
631
+ }
632
+ };
633
+
634
+ runBtns.forEach(updateRunUI);
635
+ if (paneIndex === this.state.activePaneIndex) updateRunUI(headerRunBtn);
636
+
637
+ // 3. Update Split Button
638
+ const splitBtns = instance.element.querySelectorAll('[data-action="split"]');
639
+ const headerSplitBtn = this.element.querySelector('.qss-header-actions [data-action="split"]');
640
+
641
+ const updateSplitUI = (btn) => {
642
+ if (!btn) return;
643
+ if (this.state.panes.length >= 3) {
644
+ btn.classList.add('qss-opacity-20', 'qss-pointer-events-none');
645
+ } else {
646
+ btn.classList.remove('qss-opacity-20', 'qss-pointer-events-none');
647
+ }
648
+ };
649
+ splitBtns.forEach(updateSplitUI);
650
+ if (paneIndex === this.state.activePaneIndex) updateSplitUI(headerSplitBtn);
651
+
652
+ // 4. Update Link Button
653
+ const linkBtns = instance.element.querySelectorAll('[data-action="toggle-link"]');
654
+ const headerLinkBtn = this.element.querySelector('.qss-header-actions [data-action="toggle-link"]');
655
+ const isLinked = file && file.linkedTo !== null && file.linkedTo !== undefined;
656
+
657
+ const updateLinkUI = (btn) => {
658
+ if (!btn) return;
659
+ btn.classList.toggle('qss-text-indigo-400', isLinked);
660
+ btn.classList.toggle('qss-animate-pulse', isLinked);
661
+ btn.classList.toggle('qss-text-slate-500', !isLinked);
662
+ };
663
+ linkBtns.forEach(updateLinkUI);
664
+ if (paneIndex === this.state.activePaneIndex) updateLinkUI(headerLinkBtn);
665
+
666
+ // 5. Update Snippet/Help Button
667
+ const snippetBtns = instance.element.querySelectorAll('[data-action="toggle-snippets"]');
668
+ const headerSnippetBtn = this.element.querySelector('.qss-header-actions [data-action="toggle-snippets"]');
669
+
670
+ const updateSnippetUI = (btn) => {
671
+ if (!btn) return;
672
+ btn.classList.remove('qss-text-orange-400', 'qss-text-amber-400', 'qss-text-slate-500');
673
+ btn.classList.add(pane.drawerOpen ? 'qss-text-orange-400' : 'qss-text-slate-500');
674
+ };
675
+ snippetBtns.forEach(updateSnippetUI);
676
+ if (paneIndex === this.state.activePaneIndex) updateSnippetUI(headerSnippetBtn);
677
+
678
+ // 6. Update Save Button
679
+ const saveBtns_alt = instance.element.querySelectorAll('[data-action="save"]');
680
+ const headerSaveBtn = this.element.querySelector('.qss-header-actions [data-action="save"]');
681
+
682
+ const updateSaveUI = (btn) => {
683
+ if (!btn) return;
684
+ if (file) {
685
+ btn.classList.remove('qss-hidden');
686
+ btn.classList.toggle('qss-text-emerald-400', file.isDirty);
687
+ btn.classList.toggle('qss-text-slate-500', !file.isDirty);
688
+ btn.title = file.isDirty ? "Save Changes (Ctrl+S)" : "File Saved";
689
+ } else {
690
+ btn.classList.add('qss-hidden');
691
+ }
692
+ };
693
+ saveBtns_alt.forEach(updateSaveUI);
694
+ if (paneIndex === this.state.activePaneIndex) updateSaveUI(headerSaveBtn);
695
+
696
+ // Refresh header actions if this is the active pane
697
+ if (paneIndex === this.state.activePaneIndex) {
698
+ const headerActions = this.element.querySelector('.qss-header-actions');
699
+ if (headerActions && !headerActions.querySelector('.qss-header-action-btn')) {
700
+ headerActions.innerHTML = this.renderHeaderActions();
701
+ }
702
+ }
703
+ }
704
+
705
+ renderPaneTabs(paneIndex) {
706
+ const pane = this.state.panes[paneIndex];
707
+ const instance = this.paneInstances[paneIndex];
708
+ if (!instance || !instance.tabStrip) return;
709
+
710
+ instance.tabStrip.innerHTML = '';
711
+ const bufferList = instance.element.querySelector('.qss-buffer-list');
712
+ if (bufferList) bufferList.innerHTML = '';
713
+
714
+ const tabTpl = document.getElementById('qss-tpl-editor-tab');
715
+ const itemTpl = document.getElementById('qss-tpl-editor-buffer-item');
716
+
717
+ pane.openFiles.forEach((file, index) => {
718
+ const isActive = index === pane.activeFileIndex;
719
+ const fileType = this.getFileIcon(file.name);
720
+
721
+ // 1. Create Tab
722
+ if (tabTpl) {
723
+ const tabClone = tabTpl.content.cloneNode(true);
724
+ const tab = tabClone.firstElementChild;
725
+ tab.dataset.prptActive = isActive;
726
+
727
+ if (isActive) {
728
+ tab.classList.add('active', 'qss-bg-[#1e1e1e]');
729
+ } else {
730
+ tab.classList.add('qss-bg-[#1a1d23]', 'hover:qss-bg-white/5');
731
+ }
732
+
733
+ const iconWrap = tab.querySelector('.qss-tab-icon');
734
+ if (iconWrap) {
735
+ iconWrap.className = `qss-tab-icon ${fileType.color} qss-w-3.5 qss-h-3.5 qss-flex qss-items-center qss-justify-center`;
736
+ iconWrap.innerHTML = fileType.svg;
737
+ }
738
+
739
+ const label = tab.querySelector('.qss-editor-tab-label');
740
+ if (label) {
741
+ label.textContent = file.name;
742
+ label.classList.add(isActive ? 'qss-text-slate-100' : 'qss-text-slate-400');
743
+ label.title = "Double click to rename";
744
+ }
745
+
746
+ const dirty = tab.querySelector('.qss-dirty-indicator');
747
+ if (dirty) dirty.classList.toggle('qss-hidden', !file.isDirty);
748
+
749
+ tab.addEventListener('click', (e) => {
750
+ if (e.target.classList.contains('qss-editor-tab-close')) {
751
+ this.closeFile(paneIndex, index);
752
+ } else {
753
+ this.switchToFile(paneIndex, index);
754
+ }
755
+ });
756
+
757
+ tab.addEventListener('dblclick', () => this.renameFile(paneIndex, index));
758
+ instance.tabStrip.appendChild(tab);
759
+ }
760
+
761
+ // 2. Populate Mini-Explorer buffer list
762
+ if (bufferList && itemTpl) {
763
+ const itemClone = itemTpl.content.cloneNode(true);
764
+ const item = itemClone.firstElementChild;
765
+ item.dataset.prptActive = isActive;
766
+
767
+ if (isActive) {
768
+ item.classList.add('qss-text-indigo-400', 'qss-bg-indigo-500/5');
769
+ } else {
770
+ item.classList.add('qss-text-slate-400', 'hover:qss-text-indigo-400');
771
+ }
772
+
773
+ const iconWrap = item.querySelector('.qss-item-icon');
774
+ if (iconWrap) {
775
+ iconWrap.className = `qss-item-icon ${fileType.color} qss-w-3 qss-h-3 qss-flex qss-items-center qss-justify-center`;
776
+ iconWrap.innerHTML = fileType.svg;
777
+ }
778
+
779
+ const label = item.querySelector('.qss-truncate');
780
+ if (label) label.textContent = file.name;
781
+
782
+ const dirty = item.querySelector('.qss-dirty-indicator');
783
+ if (dirty) dirty.classList.toggle('qss-hidden', !file.isDirty);
784
+
785
+ item.addEventListener('click', () => {
786
+ this.switchToFile(paneIndex, index);
787
+ this.closeAllDropdowns();
788
+ });
789
+ bufferList.appendChild(item);
790
+ }
791
+ });
792
+
793
+ // Handle search in Mini-Explorer
794
+ const portal = document.getElementById('pp-portal-root');
795
+ const searchInput = portal ? portal.querySelector('.qss-mini-explorer-dropdown input') : null;
796
+ if (searchInput) {
797
+ searchInput.addEventListener('input', (e) => {
798
+ const query = e.target.value.toLowerCase();
799
+ const items = bufferList.querySelectorAll('.qss-buffer-item');
800
+ items.forEach(item => {
801
+ const nameEl = item.querySelector('.qss-truncate');
802
+ if (nameEl) {
803
+ const name = nameEl.textContent.toLowerCase();
804
+ item.classList.toggle('qss-hidden', !name.includes(query));
805
+ }
806
+ });
807
+ });
808
+ }
809
+
810
+ // Ensure active tab is visible in the strip
811
+ const activeTab = instance.tabStrip.children[pane.activeFileIndex];
812
+ if (activeTab) {
813
+ activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
814
+ }
815
+
816
+ // Sync action buttons state
817
+ this.updatePaneActions(paneIndex);
818
+ }
819
+
820
+ showNotice(message, type = 'notice') {
821
+ document.dispatchEvent(new CustomEvent('parapet:flash', {
822
+ detail: { message: message, type: type }
823
+ }));
824
+ }
825
+
826
+ showPaneStatus(paneIndex, message, isError = false) {
827
+ const instance = this.paneInstances[paneIndex];
828
+ if (!instance) return;
829
+
830
+ const msgEl = instance.element.querySelector('.qss-pane-status-msg');
831
+ if (!msgEl) return;
832
+
833
+ msgEl.textContent = message;
834
+ msgEl.classList.toggle('qss-bg-emerald-600', !isError);
835
+ msgEl.classList.toggle('qss-bg-red-600', isError);
836
+ msgEl.classList.toggle('bg-emerald-600', false);
837
+ msgEl.classList.toggle('bg-red-600', false);
838
+
839
+ // Slide In
840
+ msgEl.classList.remove('qss-translate-x-full', 'translate-x-full');
841
+
842
+ setTimeout(() => {
843
+ // Slide Out
844
+ msgEl.classList.add('qss-translate-x-full');
845
+ }, 2500);
846
+ }
847
+
848
+ closeAllDropdowns() {
849
+ const portal = document.getElementById('pp-portal-root');
850
+ if (portal) {
851
+ portal.querySelectorAll('.qss-mini-explorer-dropdown, .qss-new-menu-dropdown, .qss-link-menu-dropdown').forEach(d => {
852
+ d.classList.add('qss-hidden');
853
+ });
854
+ }
855
+ }
856
+
857
+ portalDropdown(btn, dropdownSelector) {
858
+ if (USE_NATIVE_POPOVER_ANCHOR) {
859
+ this.portalDropdownNative(btn, dropdownSelector);
860
+ return;
861
+ }
862
+
863
+ const instance = this.paneInstances[btn.dataset.pane];
864
+ if (!instance) return;
865
+
866
+ const dropdown = instance.element.querySelector(dropdownSelector);
867
+ const portal = document.getElementById('pp-portal-root');
868
+ if (!dropdown || !portal) return;
869
+
870
+ // Close others first
871
+ this.closeAllDropdowns();
872
+
873
+ // Move to portal if not already there
874
+ if (dropdown.parentElement !== portal) {
875
+ portal.appendChild(dropdown);
876
+ }
877
+
878
+ // Position it
879
+ const rect = btn.getBoundingClientRect();
880
+ dropdown.style.setProperty('--dropdown-top', `${rect.bottom + 2}px`);
881
+ dropdown.style.setProperty('--dropdown-left', `${rect.left}px`);
882
+
883
+ // Toggle
884
+ dropdown.classList.remove('qss-hidden');
885
+
886
+ // Click-away listener
887
+ const clickOutside = (e) => {
888
+ if (!dropdown.contains(e.target) && !btn.contains(e.target)) {
889
+ dropdown.classList.add('qss-hidden');
890
+ document.removeEventListener('mousedown', clickOutside);
891
+ }
892
+ };
893
+ document.addEventListener('mousedown', clickOutside);
894
+ }
895
+
896
+ // STUB -- not implemented. Gated off by NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY
897
+ // above, so this is never called today regardless of real browser support.
898
+ //
899
+ // When built, this replaces portalDropdown()/closeAllDropdowns()'s manual
900
+ // bounding-rect math, portal relocation, and click-outside listener with:
901
+ // - `popover` on each dropdown -- native top-layer promotion means the
902
+ // dropdown never needs to move into #pp-portal-root at all; the popover
903
+ // top layer solves the same stacking-context escape problem the portal
904
+ // was built for.
905
+ // - `popovertarget="<dropdown-id>"` on each trigger button in place of the
906
+ // click listener calling this method.
907
+ // - `anchor-name` on the trigger plus `position-anchor`/`position-area`
908
+ // (with `position-try` fallbacks) on the dropdown, in place of the
909
+ // getBoundingClientRect() + --dropdown-top/--dropdown-left math.
910
+ // - native light-dismiss (popover="auto") in place of the manual
911
+ // mousedown/clickOutside listener.
912
+ // - NOTE: "only one open at a time" is not automatic across independent
913
+ // popovers -- still needs an explicit check here, or a shared group once
914
+ // that part of the popover spec stabilizes.
915
+ // Once browser support is broad enough and this method is built and verified
916
+ // in a real browser, flip NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY above.
917
+ portalDropdownNative(btn, dropdownSelector) {
918
+ throw new Error('portalDropdownNative() is a stub -- not implemented. See NATIVE_POPOVER_ANCHOR_IMPLEMENTATION_READY.');
919
+ }
920
+
921
+ initActionHandlers() {
922
+ // Use mousedown to trigger before any potential drag/focus interceptors
923
+ this.element.addEventListener('mousedown', (e) => {
924
+ const btn = e.target.closest('.qss-pane-action-btn') || e.target.closest('.qss-pane-action-btn');
925
+ if (!btn) return;
926
+
927
+ e.preventDefault();
928
+ e.stopPropagation();
929
+
930
+ const action = btn.dataset.action;
931
+ const paneIndex = parseInt(btn.dataset.pane || 0);
932
+
933
+ if (action === 'mini-explorer') this.portalDropdown(btn, '.qss-mini-explorer-dropdown');
934
+ if (action === 'new-menu') this.portalDropdown(btn, '.qss-new-menu-dropdown');
935
+ if (action === 'link-menu') this.portalDropdown(btn, '.qss-link-menu-dropdown');
936
+
937
+ if (action === 'new') this.newFile(paneIndex);
938
+ if (action === 'new-template') {
939
+ const tplIdx = parseInt(btn.dataset.tplIdx);
940
+ this.newFromTemplate(paneIndex, tplIdx);
941
+ }
942
+ if (action === 'save') this.saveCurrentFile(paneIndex);
943
+ if (action === 'run') this.runCurrentFile(paneIndex);
944
+ if (action === 'toggle-snippets') this.toggleSnippetDrawer(paneIndex);
945
+ if (action === 'toggle-link') this.toggleLink(paneIndex);
946
+ if (action === 'open') {
947
+ this._targetPaneForPicker = paneIndex;
948
+ this.filePicker.click();
949
+ }
950
+ if (action === 'split') this.addSplit(paneIndex);
951
+ if (action === 'close-split') this.closeSplit(paneIndex);
952
+ if (action === 'set-link') {
953
+ const targetIdx = parseInt(btn.dataset.target);
954
+ const currentPane = this.state.panes[paneIndex];
955
+ let activeFile = currentPane.openFiles[currentPane.activeFileIndex];
956
+
957
+ // Direction: The establisher (this tab) pulls from the target pane
958
+ const sourcePane = this.state.panes[targetIdx];
959
+ const sourceFile = sourcePane.openFiles[sourcePane.activeFileIndex];
960
+
961
+ if (!activeFile) {
962
+ activeFile = {
963
+ name: sourceFile ? sourceFile.name : 'Mirroring...',
964
+ path: sourceFile ? sourceFile.path : '',
965
+ buffer: sourceFile ? sourceFile.buffer : '',
966
+ language: sourceFile ? sourceFile.language : 'plaintext',
967
+ linkedTo: targetIdx
968
+ };
969
+ currentPane.openFiles.push(activeFile);
970
+ currentPane.activeFileIndex = currentPane.openFiles.length - 1;
971
+ } else {
972
+ activeFile.linkedTo = targetIdx;
973
+ if (sourceFile) {
974
+ activeFile.name = sourceFile.name;
975
+ activeFile.path = sourceFile.path;
976
+ activeFile.buffer = sourceFile.buffer;
977
+ activeFile.language = sourceFile.language;
978
+ }
979
+ }
980
+
981
+ // Shift focus back to the SOURCE pane
982
+ this.state.activePaneIndex = targetIdx;
983
+
984
+ this.renderPanes();
985
+ this.saveState();
986
+ }
987
+ if (action === 'unlink') {
988
+ const currentPane = this.state.panes[paneIndex];
989
+ const activeFile = currentPane.openFiles[currentPane.activeFileIndex];
990
+ if (activeFile) activeFile.linkedTo = null;
991
+ this.renderPanes();
992
+ this.saveState();
993
+ }
994
+ });
995
+ }
996
+
997
+ async openFile(path, targetPaneIndex = null, directData = null) {
998
+ try {
999
+ let filename, buffer, language, isDirty = false;
1000
+
1001
+ if (directData) {
1002
+ filename = directData.name;
1003
+ buffer = directData.buffer;
1004
+ language = directData.language;
1005
+ path = directData.path;
1006
+ } else {
1007
+ const response = await fetch(`/files/content?path=${encodeURIComponent(path)}`);
1008
+ const data = await response.json();
1009
+ filename = path.replace(/\\/g, '/').split('/').pop();
1010
+ buffer = data.content;
1011
+ language = this.getLanguageFromExt(filename);
1012
+
1013
+ const draftResponse = await fetch(`/files/draft?path=${encodeURIComponent(path)}`);
1014
+ if (draftResponse.ok && draftResponse.status !== 204) {
1015
+ const draft = await draftResponse.json();
1016
+ const decision = await this.promptDraftRecovery(filename);
1017
+ if (decision === 'cancel') return;
1018
+ if (decision === 'restore') {
1019
+ buffer = draft.content;
1020
+ isDirty = true;
1021
+ } else {
1022
+ await this.discardDraft(path);
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ const paneIndex = targetPaneIndex !== null ? targetPaneIndex : (this.state.activePaneIndex || 0);
1028
+ const pane = this.state.panes[paneIndex];
1029
+ const isInitiallyEmpty = pane.openFiles.length === 0;
1030
+
1031
+ // Check if file already open in this pane
1032
+ const existingIndex = pane.openFiles.findIndex(f => f.path === path);
1033
+ if (existingIndex >= 0) {
1034
+ this.switchToFile(paneIndex, existingIndex);
1035
+ return;
1036
+ }
1037
+
1038
+ const newFile = {
1039
+ name: filename,
1040
+ path: path,
1041
+ buffer: buffer,
1042
+ language: language,
1043
+ isDirty: isDirty
1044
+ };
1045
+
1046
+ pane.openFiles.push(newFile);
1047
+
1048
+ if (isInitiallyEmpty) {
1049
+ // Force a full re-render of the panes to ensure the editor is created
1050
+ // since createPaneElement logic might have skipped it or it was in a "null" state
1051
+ this.renderPanes();
1052
+ } else {
1053
+ this.switchToFile(paneIndex, pane.openFiles.length - 1);
1054
+ }
1055
+ } catch (err) {
1056
+ console.error("FileEditorCard: Failed to open file", path, err);
1057
+ }
1058
+ }
1059
+
1060
+ promptDraftRecovery(filename) {
1061
+ const portal = document.getElementById('pp-portal-root');
1062
+ if (!portal) return Promise.resolve('cancel');
1063
+
1064
+ return new Promise((resolve) => {
1065
+ const previousFocus = document.activeElement;
1066
+ const overlay = document.createElement('div');
1067
+ overlay.className = 'qss-fixed qss-inset-0 qss-z-[2000] qss-flex qss-items-center qss-justify-center qss-bg-black/60 qss-backdrop-blur-sm qss-p-4 qss-pointer-events-auto';
1068
+ overlay.dataset.draftRecoveryPrompt = 'true';
1069
+
1070
+ const panel = document.createElement('section');
1071
+ panel.className = 'qss-w-full qss-max-w-lg qss-bg-[#1a1d23] qss-border qss-border-slate-700 qss-rounded-lg qss-shadow-2xl qss-overflow-hidden qss-flex qss-flex-col';
1072
+ panel.setAttribute('role', 'alertdialog');
1073
+ panel.setAttribute('aria-modal', 'true');
1074
+ panel.tabIndex = -1;
1075
+
1076
+ this.draftPromptSequence = (this.draftPromptSequence || 0) + 1;
1077
+ const heading = document.createElement('h2');
1078
+ heading.id = `parapet-draft-recovery-title-${this.draftPromptSequence}`;
1079
+ heading.className = 'qss-px-4 qss-pt-4 qss-text-sm qss-font-bold qss-text-slate-100';
1080
+ heading.textContent = 'Unsaved changes found';
1081
+ panel.setAttribute('aria-labelledby', heading.id);
1082
+
1083
+ const message = document.createElement('p');
1084
+ message.className = 'qss-px-4 qss-py-3 qss-text-sm qss-text-slate-300';
1085
+ message.textContent = `Restore the unsaved draft for ${filename}?`;
1086
+
1087
+ const actions = document.createElement('div');
1088
+ actions.className = 'qss-flex qss-justify-end qss-gap-2 qss-border-t qss-border-slate-700 qss-bg-[#111418] qss-px-4 qss-py-3';
1089
+
1090
+ const restoreButton = document.createElement('button');
1091
+ restoreButton.type = 'button';
1092
+ restoreButton.className = 'qss-rounded qss-bg-indigo-600 qss-px-3 qss-py-2 qss-text-xs qss-font-bold qss-text-white hover:qss-bg-indigo-500 focus:qss-outline-none';
1093
+ restoreButton.textContent = 'Restore draft';
1094
+
1095
+ const discardButton = document.createElement('button');
1096
+ discardButton.type = 'button';
1097
+ discardButton.className = 'qss-rounded qss-bg-red-700 qss-px-3 qss-py-2 qss-text-xs qss-font-bold qss-text-white hover:qss-bg-red-600 focus:qss-outline-none';
1098
+ discardButton.textContent = 'Discard draft';
1099
+
1100
+ const cancelButton = document.createElement('button');
1101
+ cancelButton.type = 'button';
1102
+ cancelButton.className = 'qss-rounded qss-border qss-border-slate-600 qss-px-3 qss-py-2 qss-text-xs qss-font-semibold qss-text-slate-300 hover:qss-bg-slate-800 focus:qss-outline-none';
1103
+ cancelButton.textContent = 'Cancel';
1104
+
1105
+ let settled = false;
1106
+ const finish = (decision) => {
1107
+ if (settled) return;
1108
+ settled = true;
1109
+ overlay.remove();
1110
+ if (previousFocus && typeof previousFocus.focus === 'function') previousFocus.focus();
1111
+ resolve(decision);
1112
+ };
1113
+
1114
+ restoreButton.addEventListener('click', () => finish('restore'));
1115
+ discardButton.addEventListener('click', () => finish('discard'));
1116
+ cancelButton.addEventListener('click', () => finish('cancel'));
1117
+ overlay.addEventListener('click', (event) => {
1118
+ if (event.target === overlay) finish('cancel');
1119
+ });
1120
+ overlay.addEventListener('keydown', (event) => {
1121
+ if (event.key === 'Escape') {
1122
+ event.preventDefault();
1123
+ finish('cancel');
1124
+ return;
1125
+ }
1126
+
1127
+ if (event.key === 'Tab') {
1128
+ const buttons = [restoreButton, discardButton, cancelButton];
1129
+ if (event.shiftKey && document.activeElement === buttons[0]) {
1130
+ event.preventDefault();
1131
+ buttons[buttons.length - 1].focus();
1132
+ } else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) {
1133
+ event.preventDefault();
1134
+ buttons[0].focus();
1135
+ }
1136
+ }
1137
+ });
1138
+
1139
+ actions.append(restoreButton, discardButton, cancelButton);
1140
+ panel.append(heading, message, actions);
1141
+ overlay.appendChild(panel);
1142
+ portal.appendChild(overlay);
1143
+ restoreButton.focus();
1144
+ });
1145
+ }
1146
+
1147
+ queueDraftSnapshot(file) {
1148
+ if (!file.path || file.path.startsWith('external://')) return;
1149
+
1150
+ const path = file.path;
1151
+ clearTimeout(this.draftTimers.get(path));
1152
+ const timer = setTimeout(() => {
1153
+ this.draftTimers.delete(path);
1154
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1155
+ const request = fetch('/files/draft', {
1156
+ method: 'PATCH',
1157
+ headers: {
1158
+ 'Content-Type': 'application/json',
1159
+ 'X-CSRF-Token': csrfToken
1160
+ },
1161
+ body: JSON.stringify({ path: path, content: file.buffer })
1162
+ }).then((response) => {
1163
+ if (!response.ok) throw new Error(response.statusText);
1164
+ }).catch((error) => {
1165
+ console.error('FileEditorCard: Draft recovery save failed', error);
1166
+ }).finally(() => {
1167
+ if (this.draftRequests.get(path) === request) this.draftRequests.delete(path);
1168
+ });
1169
+ this.draftRequests.set(path, request);
1170
+ }, 750);
1171
+ this.draftTimers.set(path, timer);
1172
+ }
1173
+
1174
+ async flushDraftSnapshot(path) {
1175
+ clearTimeout(this.draftTimers.get(path));
1176
+ this.draftTimers.delete(path);
1177
+ const request = this.draftRequests.get(path);
1178
+ if (request) await request;
1179
+ }
1180
+
1181
+ async discardDraft(path) {
1182
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1183
+ const response = await fetch(`/files/draft?path=${encodeURIComponent(path)}`, {
1184
+ method: 'DELETE',
1185
+ headers: { 'X-CSRF-Token': csrfToken }
1186
+ });
1187
+ if (!response.ok) throw new Error(response.statusText);
1188
+ }
1189
+
1190
+ getLanguageFromExt(filename) {
1191
+ const ext = filename.split('.').pop().toLowerCase();
1192
+ const map = {
1193
+ 'rb': 'ruby',
1194
+ 'js': 'javascript',
1195
+ 'ts': 'typescript',
1196
+ 'html': 'html',
1197
+ 'css': 'css',
1198
+ 'json': 'json',
1199
+ 'md': 'markdown',
1200
+ 'feature': 'gherkin'
1201
+ };
1202
+ return map[ext] || 'plaintext';
1203
+ }
1204
+
1205
+ switchToFile(paneIndex, fileIndex) {
1206
+ const pane = this.state.panes[paneIndex];
1207
+ if (pane.activeFileIndex === fileIndex && this.state.activePaneIndex === paneIndex) return;
1208
+
1209
+ // Save scroll state of outgoing file
1210
+ const oldFile = pane.openFiles[pane.activeFileIndex];
1211
+ const instance = this.paneInstances[paneIndex];
1212
+ if (oldFile && instance && instance.editor) {
1213
+ oldFile.scrollPosition = instance.editor.getScrollTop();
1214
+ }
1215
+
1216
+ pane.activeFileIndex = fileIndex;
1217
+ this.state.activePaneIndex = paneIndex;
1218
+
1219
+ this.loadPaneFile(paneIndex);
1220
+ this.renderPaneTabs(paneIndex);
1221
+ this.updatePaneFocus();
1222
+ this.saveState();
1223
+ }
1224
+
1225
+ newFile(paneIndex) {
1226
+ const pane = this.state.panes[paneIndex];
1227
+ pane.openFiles.push({
1228
+ name: 'untitled.rb',
1229
+ buffer: '',
1230
+ language: 'ruby',
1231
+ defaultDir: 'test'
1232
+ });
1233
+ this.switchToFile(paneIndex, pane.openFiles.length - 1);
1234
+ }
1235
+
1236
+ newFromTemplate(paneIndex, templateIdx) {
1237
+ const templates = this.getNewFileTemplates();
1238
+ const tpl = templates[templateIdx];
1239
+ if (!tpl) return;
1240
+
1241
+ const pane = this.state.panes[paneIndex];
1242
+ const name = `untitled${tpl.ext}`;
1243
+ pane.openFiles.push({
1244
+ name: name,
1245
+ buffer: tpl.content,
1246
+ language: this.getLanguageFromExt(name),
1247
+ defaultDir: tpl.defaultDir,
1248
+ isDirty: true
1249
+ });
1250
+ this.switchToFile(paneIndex, pane.openFiles.length - 1);
1251
+ }
1252
+
1253
+ async renameFile(paneIndex, fileIndex) {
1254
+ const pane = this.state.panes[paneIndex];
1255
+ const file = pane.openFiles[fileIndex];
1256
+ if (!file) return;
1257
+
1258
+ const newPath = prompt("Rename file to (path relative to test root):", file.path || file.name);
1259
+ if (!newPath || newPath === file.path) return;
1260
+
1261
+ const oldPath = file.path;
1262
+ const newName = newPath.split('/').pop();
1263
+ const newLang = this.getLanguageFromExt(newName);
1264
+
1265
+ if (oldPath) {
1266
+ try {
1267
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1268
+ const response = await fetch('/files/move', {
1269
+ method: 'POST',
1270
+ headers: {
1271
+ 'Content-Type': 'application/json',
1272
+ 'X-CSRF-Token': csrfToken
1273
+ },
1274
+ body: JSON.stringify({ old_path: oldPath, new_path: newPath })
1275
+ });
1276
+
1277
+ if (!response.ok) {
1278
+ const data = await response.json();
1279
+ this.showNotice(`Rename failed: ${data.error}`, 'alert');
1280
+ return;
1281
+ }
1282
+ } catch (err) {
1283
+ console.error("Rename failed", err);
1284
+ this.showNotice("Network error during rename", 'alert');
1285
+ return;
1286
+ }
1287
+ }
1288
+
1289
+ // Update state locally (for this tab and all linked/synced tabs)
1290
+ const oldModelKey = oldPath || `untitled-${file.name}`;
1291
+ const newModelKey = newPath;
1292
+
1293
+ this.state.panes.forEach((p, pIdx) => {
1294
+ p.openFiles.forEach((f, fIdx) => {
1295
+ if (f.path === oldPath || (f.name === file.name && !f.path)) {
1296
+ f.path = newPath;
1297
+ f.name = newName;
1298
+ f.language = newLang;
1299
+
1300
+ // Update Monaco models in this pane instance
1301
+ const instance = this.paneInstances[pIdx];
1302
+ if (instance && instance.models[oldModelKey]) {
1303
+ instance.models[newModelKey] = instance.models[oldModelKey];
1304
+ delete instance.models[oldModelKey];
1305
+
1306
+ // If this model is currently active, we might need to notify monaco but
1307
+ // usually setModel handles it if we just change the reference.
1308
+ // However, we already changed the path in the state, so next loadPaneFile will work.
1309
+ }
1310
+ }
1311
+ });
1312
+ });
1313
+
1314
+ this.renderPaneTabs(paneIndex);
1315
+ this.loadPaneFile(paneIndex);
1316
+ this.saveState();
1317
+
1318
+ document.dispatchEvent(new CustomEvent('parapet:flash', {
1319
+ detail: { message: `Renamed to ${newName}`, type: 'notice' }
1320
+ }));
1321
+
1322
+ // Trigger Tree Refresh
1323
+ document.dispatchEvent(new CustomEvent('parapet:fileSystemChanged'));
1324
+ }
1325
+
1326
+ toggleSnippetDrawer(paneIndex) {
1327
+ const pane = this.state.panes[paneIndex];
1328
+ const instance = this.paneInstances[paneIndex];
1329
+ if (!instance) return;
1330
+
1331
+ const drawer = instance.element.querySelector('.qss-snippet-drawer');
1332
+ const closeBtn = drawer.querySelector('.qss-close-drawer-btn');
1333
+ if (!drawer) return;
1334
+
1335
+ pane.drawerOpen = !pane.drawerOpen;
1336
+
1337
+ if (pane.drawerOpen) {
1338
+ this.renderDrawerSnippets(paneIndex);
1339
+ drawer.style.setProperty('--drawer-w', '220px');
1340
+ } else {
1341
+ drawer.style.setProperty('--drawer-w', '0px');
1342
+ }
1343
+
1344
+ this.saveState();
1345
+ this.updatePaneActions(paneIndex);
1346
+
1347
+ // Trigger Monaco layout update after transition
1348
+ setTimeout(() => instance.editor && instance.editor.layout(), 310);
1349
+ }
1350
+
1351
+ renderDrawerSnippets(paneIndex) {
1352
+ const pane = this.state.panes[paneIndex];
1353
+ const file = pane.openFiles[pane.activeFileIndex];
1354
+ const instance = this.paneInstances[paneIndex];
1355
+ const content = instance.element.querySelector('.qss-drawer-content');
1356
+ if (!content) return;
1357
+
1358
+ const lib = ActivityCards.registry['snippet-library'];
1359
+ const allSnippets = lib ? (new lib()).getSnippets() : [];
1360
+
1361
+ const filename = (file?.name || '').toLowerCase();
1362
+ let filtered = [];
1363
+
1364
+ if (filename.endsWith('.feature')) {
1365
+ filtered = allSnippets.filter(c => c.category.includes('Cucumber'));
1366
+ } else if (filename.endsWith('_spec.rb')) {
1367
+ filtered = allSnippets.filter(c =>
1368
+ c.category.includes('RSpec') ||
1369
+ c.category.includes('FactoryBot') ||
1370
+ c.category.includes('Setup')
1371
+ );
1372
+ } else if (filename.endsWith('_test.rb')) {
1373
+ filtered = allSnippets.filter(c =>
1374
+ c.category.includes('Minitest') ||
1375
+ c.category.includes('FactoryBot')
1376
+ );
1377
+ } else if (filename.endsWith('.rb')) {
1378
+ filtered = allSnippets.filter(c =>
1379
+ c.category.includes('RSpec') ||
1380
+ c.category.includes('FactoryBot') ||
1381
+ c.category.includes('Setup')
1382
+ );
1383
+ } else {
1384
+ filtered = allSnippets.filter(c => c.category.includes('FactoryBot'));
1385
+ }
1386
+
1387
+ const isSingle = filtered.length === 1;
1388
+ const groupTpl = document.getElementById('qss-tpl-snippet-group');
1389
+ const tileTpl = document.getElementById('qss-tpl-snippet-tile');
1390
+
1391
+ content.innerHTML = '';
1392
+
1393
+ filtered.forEach(cat => {
1394
+ const groupEl = groupTpl.content.cloneNode(true).firstElementChild;
1395
+ groupEl.dataset.category = cat.category;
1396
+ groupEl.querySelector('.qss-category-label').textContent = cat.category;
1397
+
1398
+ const body = groupEl.querySelector('.qss-category-body');
1399
+ const icon = groupEl.querySelector('.qss-category-header svg');
1400
+
1401
+ if (isSingle) {
1402
+ groupEl.classList.add('qss-border-slate-700', 'qss-bg-white/[0.04]');
1403
+ body.classList.remove('qss-hidden');
1404
+ if (icon) icon.style.transform = 'rotate(180deg)';
1405
+ }
1406
+
1407
+ cat.items.forEach(item => {
1408
+ const tileEl = tileTpl.content.cloneNode(true).firstElementChild;
1409
+ tileEl.querySelector('.qss-snippet-name').textContent = item.name;
1410
+ tileEl.querySelector('.qss-snippet-desc').textContent = item.description || 'No description available.';
1411
+ tileEl.querySelector('.qss-snippet-code-preview').textContent = item.code;
1412
+
1413
+ const insertBtn = tileEl.querySelector('.qss-insert-btn');
1414
+ insertBtn.onclick = (e) => {
1415
+ e.stopPropagation();
1416
+ this.insertSnippet(paneIndex, item.code);
1417
+ this.closeAllDropdowns();
1418
+ };
1419
+
1420
+ body.appendChild(tileEl);
1421
+ });
1422
+
1423
+ groupEl.querySelector('.qss-category-header').onclick = () => {
1424
+ const isHidden = body.classList.contains('qss-hidden');
1425
+ if (isHidden) {
1426
+ // Collapse all others
1427
+ content.querySelectorAll('.qss-snippet-group').forEach(otherGroup => {
1428
+ if (otherGroup === groupEl) return;
1429
+ const otherBody = otherGroup.querySelector('.qss-category-body');
1430
+ const otherIcon = otherGroup.querySelector('.qss-category-header svg');
1431
+ otherBody.classList.add('qss-hidden');
1432
+ if (otherIcon) otherIcon.style.transform = 'rotate(0deg)';
1433
+ otherGroup.classList.remove('qss-border-slate-700', 'qss-bg-white/[0.04]');
1434
+ });
1435
+ }
1436
+
1437
+ body.classList.toggle('qss-hidden');
1438
+ if (icon) icon.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)';
1439
+ groupEl.classList.toggle('qss-border-slate-700', isHidden);
1440
+ groupEl.classList.toggle('qss-bg-white/[0.04]', isHidden);
1441
+ };
1442
+
1443
+ content.appendChild(groupEl);
1444
+ });
1445
+ }
1446
+
1447
+ insertSnippet(paneIndex, code) {
1448
+ const instance = this.paneInstances[paneIndex];
1449
+ if (!instance || !instance.editor) return;
1450
+
1451
+ const selection = instance.editor.getSelection();
1452
+ const range = new this.monaco.Range(
1453
+ selection.startLineNumber,
1454
+ selection.startColumn,
1455
+ selection.endLineNumber,
1456
+ selection.endColumn
1457
+ );
1458
+
1459
+ instance.editor.executeEdits("snippet", [{
1460
+ range: range,
1461
+ text: code,
1462
+ forceMoveMarkers: true
1463
+ }]);
1464
+
1465
+ instance.editor.focus();
1466
+ this.showPaneStatus(paneIndex, "Snippet Inserted");
1467
+ }
1468
+
1469
+ async runCurrentFile(paneIndex) {
1470
+ const pane = this.state.panes[paneIndex];
1471
+ const file = pane.openFiles[pane.activeFileIndex];
1472
+
1473
+ if (!file || !file.path) {
1474
+ this.showNotice("Cannot run unsaved file", 'alert');
1475
+ return;
1476
+ }
1477
+
1478
+ // Save first if dirty
1479
+ if (file.isDirty) {
1480
+ await this.saveCurrentFile(paneIndex);
1481
+ }
1482
+
1483
+ // Ensure Progress Area card is open
1484
+ if (!ActivityCards.active['progress-area']) {
1485
+ ActivityCards.spawn({ id: 'progress-area' });
1486
+ }
1487
+
1488
+ // Dispatch event to the runner card
1489
+ document.dispatchEvent(new CustomEvent('parapet:runTest', {
1490
+ detail: { path: file.path }
1491
+ }));
1492
+
1493
+ this.showPaneStatus(paneIndex, `Running: ${file.name}`);
1494
+ }
1495
+
1496
+ showFixFooter(paneIndex, line, message) {
1497
+ const instance = this.paneInstances[paneIndex];
1498
+ if (!instance) return;
1499
+
1500
+ const footer = instance.element.querySelector('.qss-fix-footer');
1501
+ if (!footer) return;
1502
+
1503
+ footer.classList.remove('qss-hidden');
1504
+ footer.style.setProperty('--footer-h', 'auto');
1505
+ const fullHeight = footer.scrollHeight;
1506
+ footer.style.setProperty('--footer-h', '0px');
1507
+
1508
+ // Trigger animation
1509
+ setTimeout(() => {
1510
+ footer.style.setProperty('--footer-h', `${fullHeight}px`);
1511
+ }, 10);
1512
+
1513
+ footer.querySelector('.qss-fix-line-num').textContent = line;
1514
+ footer.querySelector('.qss-fix-message').textContent = message;
1515
+
1516
+ // Handlers
1517
+ const closeBtn = footer.querySelector('.qss-close-fix-btn');
1518
+ const ignoreBtn = footer.querySelector('.qss-ignore-fix-btn');
1519
+ const hide = () => {
1520
+ footer.style.setProperty('--footer-h', '0px');
1521
+ setTimeout(() => footer.classList.add('qss-hidden'), 300);
1522
+ };
1523
+
1524
+ closeBtn.onclick = hide;
1525
+ ignoreBtn.onclick = hide;
1526
+
1527
+ footer.querySelector('.qss-apply-fix-btn').onclick = () => {
1528
+ this.showNotice("Applying AI Fix...", 'notice');
1529
+ // Simulate fix applying logic here
1530
+ hide();
1531
+ };
1532
+ }
1533
+
1534
+ async saveCurrentFile(paneIndex) {
1535
+ const pane = this.state.panes[paneIndex];
1536
+ const file = pane.openFiles[pane.activeFileIndex];
1537
+
1538
+ if (!file) return;
1539
+
1540
+ if (!file.path) {
1541
+ const defaultPath = file.defaultDir ? `${file.defaultDir}/${file.name}` : file.name;
1542
+ const newPath = prompt("Enter path for new file (e.g. models/user_spec.rb):", defaultPath);
1543
+ if (!newPath) return;
1544
+
1545
+ // Ensure it has an extension if omitted
1546
+ file.path = newPath.includes('.') ? newPath : `${newPath}.rb`;
1547
+ file.name = file.path.split('/').pop();
1548
+ file.language = this.getLanguageFromExt(file.name);
1549
+ }
1550
+
1551
+ try {
1552
+ await this.flushDraftSnapshot(file.path);
1553
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
1554
+ const response = await fetch('/files', {
1555
+ method: 'PATCH',
1556
+ headers: {
1557
+ 'Content-Type': 'application/json',
1558
+ 'X-CSRF-Token': csrfToken
1559
+ },
1560
+ body: JSON.stringify({
1561
+ path: file.path,
1562
+ content: file.buffer
1563
+ })
1564
+ });
1565
+
1566
+ if (response.ok) {
1567
+ const data = await response.json();
1568
+ file.isDirty = false;
1569
+ this.renderPaneTabs(paneIndex);
1570
+ this.showPaneStatus(paneIndex, `Saved: ${file.name}`);
1571
+ this.saveState();
1572
+
1573
+ // Dispatch Global Flash Event
1574
+ if (data.flash) {
1575
+ document.dispatchEvent(new CustomEvent('parapet:flash', {
1576
+ detail: { message: data.flash.notice, type: 'notice' }
1577
+ }));
1578
+ }
1579
+
1580
+ // Trigger Tree Refresh
1581
+ document.dispatchEvent(new CustomEvent('parapet:fileSystemChanged'));
1582
+ } else {
1583
+ const data = await response.json();
1584
+ this.showPaneStatus(paneIndex, `Error: ${data.error || response.statusText}`, true);
1585
+
1586
+ if (data.flash) {
1587
+ document.dispatchEvent(new CustomEvent('parapet:flash', {
1588
+ detail: { message: data.flash.alert, type: 'alert' }
1589
+ }));
1590
+ }
1591
+ }
1592
+ } catch (err) {
1593
+ console.error("FileEditorCard: Save failed", err);
1594
+ this.showPaneStatus(paneIndex, "Network Error", true);
1595
+
1596
+ document.dispatchEvent(new CustomEvent('parapet:flash', {
1597
+ detail: { message: "Critical: Network Error during file save", type: 'error' }
1598
+ }));
1599
+ }
1600
+ }
1601
+
1602
+ closeFile(paneIndex, fileIndex) {
1603
+ const pane = this.state.panes[paneIndex];
1604
+ const file = pane.openFiles[fileIndex];
1605
+ const instance = this.paneInstances[paneIndex];
1606
+ const wasActive = fileIndex === pane.activeFileIndex;
1607
+
1608
+ if (file && instance) {
1609
+ const modelKey = file.path || `untitled-${file.name}`;
1610
+ if (instance.models[modelKey]) {
1611
+ instance.models[modelKey].dispose();
1612
+ delete instance.models[modelKey];
1613
+ }
1614
+ }
1615
+
1616
+ pane.openFiles.splice(fileIndex, 1);
1617
+
1618
+ // Adjust active index:
1619
+ // 1. If we closed a tab before the active one, shift active down
1620
+ // 2. If we closed the active tab, it stays the same (now pointing to next) unless it was last
1621
+ if (fileIndex < pane.activeFileIndex) {
1622
+ pane.activeFileIndex--;
1623
+ }
1624
+
1625
+ pane.activeFileIndex = Math.max(0, Math.min(pane.activeFileIndex, pane.openFiles.length - 1));
1626
+
1627
+ this.loadPaneFile(paneIndex);
1628
+ this.renderPaneTabs(paneIndex);
1629
+ this.saveState();
1630
+ }
1631
+
1632
+ addSplit(sourcePaneIndex = 0) {
1633
+ if (this.state.panes.length >= 3) return;
1634
+
1635
+ this.state.panes.push({
1636
+ openFiles: [],
1637
+ activeFileIndex: 0
1638
+ });
1639
+
1640
+ this.saveState();
1641
+ setTimeout(() => {
1642
+ this.renderPanes();
1643
+ this.onResize();
1644
+ // Focus the newly created pane
1645
+ this.state.activePaneIndex = this.state.panes.length - 1;
1646
+ this.updatePaneFocus();
1647
+ }, 50);
1648
+ }
1649
+
1650
+ closeSplit(paneIndex) {
1651
+ if (paneIndex === 0) return;
1652
+
1653
+ // Clean up any panes linked to this one
1654
+ this.state.panes.forEach(p => {
1655
+ if (p.linkedTo === paneIndex) p.linkedTo = null;
1656
+ // Shift indices down if they were linked to a higher pane
1657
+ if (p.linkedTo > paneIndex) p.linkedTo--;
1658
+ });
1659
+
1660
+ this.state.panes.splice(paneIndex, 1);
1661
+
1662
+ // Fix activePaneIndex if it was the closed one
1663
+ if (this.state.activePaneIndex >= this.state.panes.length) {
1664
+ this.state.activePaneIndex = this.state.panes.length - 1;
1665
+ }
1666
+
1667
+ this.renderPanes();
1668
+ this.saveState();
1669
+ }
1670
+
1671
+ toggleLink(paneIndex) {
1672
+ if (paneIndex === 0) return;
1673
+ const pane = this.state.panes[paneIndex];
1674
+ const activeFile = pane.openFiles[pane.activeFileIndex];
1675
+ if (!activeFile) return;
1676
+
1677
+ if (activeFile.linkedTo === null) {
1678
+ // Link to Pane 1 (index 0) by default if toggled via generic action
1679
+ activeFile.linkedTo = 0;
1680
+ const sourcePane = this.state.panes[0];
1681
+ const sourceFile = sourcePane.openFiles[sourcePane.activeFileIndex];
1682
+ if (sourceFile) {
1683
+ activeFile.name = sourceFile.name;
1684
+ activeFile.path = sourceFile.path;
1685
+ activeFile.buffer = sourceFile.buffer;
1686
+ activeFile.language = sourceFile.language;
1687
+ }
1688
+ } else {
1689
+ activeFile.linkedTo = null;
1690
+ }
1691
+
1692
+ this.renderPanes();
1693
+ this.saveState();
1694
+ }
1695
+
1696
+ getFileIcon(filename) {
1697
+ const ext = filename.split('.').pop().toLowerCase();
1698
+ const mapping = {
1699
+ 'rb': { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M6 3h12l4 6-10 12L2 9z"></path><path d="M11 3 8 9l4 12 4-12-3-6"></path><path d="M2 9h20"></path></svg>', color: 'qss-text-red-400' },
1700
+ 'feature': { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="m9 15 2 2 4-4"></path></svg>', color: 'qss-text-emerald-400' },
1701
+ 'js': { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>', color: 'qss-text-yellow-400' },
1702
+ 'css': { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>', color: 'qss-text-blue-400' },
1703
+ 'erb': { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>', color: 'qss-text-indigo-400' }
1704
+ };
1705
+ return mapping[ext] || { svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="qss-w-full qss-h-full"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>', color: 'qss-text-slate-400' };
1706
+ }
1707
+
1708
+ onResize() {
1709
+ this.paneInstances.forEach(p => p.editor && p.editor.layout());
1710
+ }
1711
+
1712
+ onDestroy() {
1713
+ this.paneInstances.forEach(p => {
1714
+ p.editor && p.editor.dispose();
1715
+ Object.values(p.models).forEach(m => m.dispose());
1716
+ });
1717
+ }
1718
+ }
1719
+
1720
+ ActivityCards.register('file-editor', FileEditorCard);
1721
+
1722
+ })();