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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +48 -0
- data/Rakefile +7 -0
- data/app/assets/config/parapet/grammar-registry.json +52 -0
- data/app/assets/images/parapet/icons/activities/component-inspector.svg +4 -0
- data/app/assets/images/parapet/icons/activities/coverage-heatmap.svg +3 -0
- data/app/assets/images/parapet/icons/activities/discovery-sidebar.svg +3 -0
- data/app/assets/images/parapet/icons/activities/environment-toggles.svg +4 -0
- data/app/assets/images/parapet/icons/activities/failure-inspector.svg +5 -0
- data/app/assets/images/parapet/icons/activities/flaky-history.svg +3 -0
- data/app/assets/images/parapet/icons/activities/flight-recorder.svg +5 -0
- data/app/assets/images/parapet/icons/activities/health-graph.svg +3 -0
- data/app/assets/images/parapet/icons/activities/injection-engine.svg +3 -0
- data/app/assets/images/parapet/icons/activities/live-sql-stream.svg +5 -0
- data/app/assets/images/parapet/icons/activities/live-web-preview.svg +4 -0
- data/app/assets/images/parapet/icons/activities/monaco-editor.svg +5 -0
- data/app/assets/images/parapet/icons/activities/progress-area.svg +3 -0
- data/app/assets/images/parapet/icons/activities/registry-metadata.svg +5 -0
- data/app/assets/images/parapet/icons/activities/seed-inspector.svg +4 -0
- data/app/assets/images/parapet/icons/activities/slow-test-bottlenecks.svg +4 -0
- data/app/assets/images/parapet/icons/activities/smart-search.svg +4 -0
- data/app/assets/images/parapet/icons/activities/snippet-library.svg +4 -0
- data/app/assets/images/parapet/icons/activities/terminal.svg +4 -0
- data/app/assets/images/parapet/icons/activities/unified-schema-view.svg +4 -0
- data/app/assets/images/parapet/icons/activities/visual-comparison.svg +3 -0
- data/app/assets/images/parapet/icons/activities/visual-summary-bar.svg +5 -0
- data/app/assets/images/parapet/icons/nav/ideas.svg +4 -0
- data/app/assets/images/parapet/icons/nav/languages.svg +8 -0
- data/app/assets/images/parapet/icons/nav/profile.svg +4 -0
- data/app/assets/images/parapet/icons/nav/pulse.svg +7 -0
- data/app/assets/images/parapet/icons/nav/settings.svg +4 -0
- data/app/assets/images/parapet/icons/nav/state.svg +5 -0
- data/app/assets/images/parapet/icons/nav/styles.svg +7 -0
- data/app/assets/images/parapet/icons/nav/tests.svg +4 -0
- data/app/assets/images/parapet/icons/nav/traffic.svg +12 -0
- data/app/assets/javascripts/parapet/activity-card.js +439 -0
- data/app/assets/javascripts/parapet/activity-cards.js +291 -0
- data/app/assets/javascripts/parapet/activity-registry.js +181 -0
- data/app/assets/javascripts/parapet/activity-toggles.js +76 -0
- data/app/assets/javascripts/parapet/application.js +37 -0
- data/app/assets/javascripts/parapet/card-failure-inspector.js +134 -0
- data/app/assets/javascripts/parapet/card-file-editor.js +1722 -0
- data/app/assets/javascripts/parapet/card-progress-area.js +297 -0
- data/app/assets/javascripts/parapet/card-smart-search.js +114 -0
- data/app/assets/javascripts/parapet/card-snippet-library.js +251 -0
- data/app/assets/javascripts/parapet/explorer-resizer.js +92 -0
- data/app/assets/javascripts/parapet/parapet_qss.js +342 -0
- data/app/assets/javascripts/parapet/proficiency-filter.js +53 -0
- data/app/assets/javascripts/parapet/sidebar-resizer.js +197 -0
- data/app/assets/javascripts/parapet/styles-workbench.js +819 -0
- data/app/assets/javascripts/parapet/tree-logic.js +320 -0
- data/app/assets/stylesheets/parapet/_qss_variables.scss +122 -0
- data/app/assets/stylesheets/parapet/parapet_qss.scss +69 -0
- data/app/assets/stylesheets/parapet/qss_application.css +260940 -0
- data/app/assets/stylesheets/parapet/qss_application.css.map +1 -0
- data/app/assets/stylesheets/parapet/qss_application.scss +5 -0
- data/app/assets/stylesheets/parapet/qss_workspace.scss +209 -0
- data/app/assets/stylesheets/parapet/sample.qss +22 -0
- data/app/controllers/parapet/application_controller.rb +54 -0
- data/app/controllers/parapet/base_controller.rb +18 -0
- data/app/controllers/parapet/build_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/css_suite/compiler_controller.rb +95 -0
- data/app/controllers/parapet/css_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/css_suite/preview_controller.rb +46 -0
- data/app/controllers/parapet/dashboard_controller.rb +8 -0
- data/app/controllers/parapet/drafts_controller.rb +30 -0
- data/app/controllers/parapet/explorers_controller.rb +53 -0
- data/app/controllers/parapet/files_controller.rb +208 -0
- data/app/controllers/parapet/footers_controller.rb +24 -0
- data/app/controllers/parapet/intentions_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/landing_controller.rb +14 -0
- data/app/controllers/parapet/monitor_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/pm_suite/publish_controller.rb +16 -0
- data/app/controllers/parapet/polyglot_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/runner_controller.rb +34 -0
- data/app/controllers/parapet/sessions_controller.rb +35 -0
- data/app/controllers/parapet/signal_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/test_suite/dashboard_controller.rb +10 -0
- data/app/controllers/parapet/widgets_controller.rb +33 -0
- data/app/helpers/parapet/application_helper.rb +23 -0
- data/app/helpers/parapet/icon_helper.rb +57 -0
- data/app/jobs/parapet/application_job.rb +4 -0
- data/app/mailers/parapet/application_mailer.rb +6 -0
- data/app/models/parapet/activity.rb +14 -0
- data/app/models/parapet/application_record.rb +5 -0
- data/app/models/parapet/current.rb +6 -0
- data/app/models/parapet/draft_recovery.rb +9 -0
- data/app/models/parapet/internal_record.rb +34 -0
- data/app/models/parapet/run_history.rb +22 -0
- data/app/models/parapet/runtime_setting.rb +8 -0
- data/app/models/parapet/session.rb +7 -0
- data/app/models/parapet/suite_assignment.rb +30 -0
- data/app/models/parapet/user.rb +12 -0
- data/app/services/parapet/activity_phrase_renderer.rb +35 -0
- data/app/services/parapet/activity_writer.rb +26 -0
- data/app/services/parapet/authorization.rb +198 -0
- data/app/services/parapet/consistency_auditor.rb +58 -0
- data/app/services/parapet/directory_scanner.rb +159 -0
- data/app/services/parapet/draft_recovery_store.rb +18 -0
- data/app/services/parapet/explorer_manager.rb +402 -0
- data/app/services/parapet/file_save_service.rb +21 -0
- data/app/services/parapet/git_side_ref_committer.rb +50 -0
- data/app/services/parapet/noun_discovery.rb +26 -0
- data/app/services/parapet/publish_gate.rb +83 -0
- data/app/services/parapet/test_runner.rb +178 -0
- data/app/views/layouts/parapet/application.html.erb +83 -0
- data/app/views/layouts/parapet/landing.html.erb +19 -0
- data/app/views/parapet/activities/_ai_error_explainer.html.erb +33 -0
- data/app/views/parapet/activities/_ai_explainer_widget.html.erb +20 -0
- data/app/views/parapet/activities/_calendar_widget.html.erb +31 -0
- data/app/views/parapet/activities/_code_snippets_widget.html.erb +22 -0
- data/app/views/parapet/activities/_coverage_explorer.html.erb +1 -0
- data/app/views/parapet/activities/_dropdown_test.html.erb +20 -0
- data/app/views/parapet/activities/_explorer_node.html.erb +102 -0
- data/app/views/parapet/activities/_explorer_tree.html.erb +16 -0
- data/app/views/parapet/activities/_file_editor.html.erb +183 -0
- data/app/views/parapet/activities/_file_explorer.html.erb +23 -0
- data/app/views/parapet/activities/_fix_suggestions_widget.html.erb +13 -0
- data/app/views/parapet/activities/_generic.html.erb +6 -0
- data/app/views/parapet/activities/_live_preview.html.erb +13 -0
- data/app/views/parapet/activities/_perspective_filters.html.erb +36 -0
- data/app/views/parapet/activities/_progress_area.html.erb +37 -0
- data/app/views/parapet/activities/_project_explorer.html.erb +1 -0
- data/app/views/parapet/activities/_registry_explorer.html.erb +1 -0
- data/app/views/parapet/activities/_run_history.html.erb +2 -0
- data/app/views/parapet/activities/_smart_search.html.erb +11 -0
- data/app/views/parapet/activities/file_explorer/_perspective_scripts.html.erb +58 -0
- data/app/views/parapet/activities/file_explorer/_perspectives.html.erb +0 -0
- data/app/views/parapet/activities/file_explorer/_viewport_scripts.html.erb +33 -0
- data/app/views/parapet/css_suite/_workbench.html.erb +51 -0
- data/app/views/parapet/dashboards/index.html.erb +113 -0
- data/app/views/parapet/debug_panel.html.erb +79 -0
- data/app/views/parapet/debug_panel_tailwind.html.erb +98 -0
- data/app/views/parapet/erb_patch_test.html.erb +5 -0
- data/app/views/parapet/explorers/show.html.erb +1 -0
- data/app/views/parapet/git_viewer.html.erb +62 -0
- data/app/views/parapet/git_viewer_after.html.erb +14 -0
- data/app/views/parapet/git_viewer_before.html.erb +14 -0
- data/app/views/parapet/git_viewer_governed.html.erb +88 -0
- data/app/views/parapet/git_viewer_governed_after.html.erb +36 -0
- data/app/views/parapet/git_viewer_governed_before.html.erb +36 -0
- data/app/views/parapet/git_viewer_tailwind.html.erb +88 -0
- data/app/views/parapet/git_viewer_thin_wrapper.html.erb +85 -0
- data/app/views/parapet/hierarchical_layout_test.html.erb +85 -0
- data/app/views/parapet/hybrid_stress_test.html.erb +25 -0
- data/app/views/parapet/index.html.erb +35 -0
- data/app/views/parapet/mutation_stress_test.html.erb +82 -0
- data/app/views/parapet/recursive_anchoring_test.html.erb +66 -0
- data/app/views/parapet/sessions/new.html.erb +28 -0
- data/app/views/parapet/shared/_cad_inspector.html.erb +92 -0
- data/app/views/parapet/shared/_discovery_sidebar.html.erb +48 -0
- data/app/views/parapet/shared/_explorer_bar.html.erb +71 -0
- data/app/views/parapet/shared/_footer.html.erb +10 -0
- data/app/views/parapet/shared/_footer_ideas.html.erb +36 -0
- data/app/views/parapet/shared/_footer_languages.html.erb +33 -0
- data/app/views/parapet/shared/_footer_pulse.html.erb +33 -0
- data/app/views/parapet/shared/_footer_state.html.erb +33 -0
- data/app/views/parapet/shared/_footer_styles.html.erb +37 -0
- data/app/views/parapet/shared/_footer_tests.html.erb +39 -0
- data/app/views/parapet/shared/_footer_traffic.html.erb +45 -0
- data/app/views/parapet/shared/_global_nav.html.erb +55 -0
- data/app/views/parapet/shared/_head.html.erb +4 -0
- data/app/views/parapet/shared/_header.html.erb +20 -0
- data/app/views/parapet/shared/_object_inspector.html.erb +76 -0
- data/app/views/parapet/shared/_ribbon_header.html.erb +3 -0
- data/app/views/parapet/shared/_root_selector_modal.html.erb +15 -0
- data/app/views/parapet/shared/_tab_bar.html.erb +62 -0
- data/app/views/parapet/shared/activities_ribbon/_agent_group.html.erb +15 -0
- data/app/views/parapet/shared/activities_ribbon/_analysis_group.html.erb +15 -0
- data/app/views/parapet/shared/activities_ribbon/_architect_group.html.erb +21 -0
- data/app/views/parapet/shared/activities_ribbon/_explorer_group.html.erb +12 -0
- data/app/views/parapet/shared/activities_ribbon/_inspector_group.html.erb +15 -0
- data/app/views/parapet/shared/activities_ribbon/_proficiency_filter.html.erb +24 -0
- data/app/views/parapet/shared/activities_ribbon/_runner_group.html.erb +21 -0
- data/app/views/parapet/shared/activities_ribbon/_spatial_inspector_group.html.erb +18 -0
- data/app/views/parapet/shared/activities_ribbon/_studio_group.html.erb +18 -0
- data/app/views/parapet/shared/activities_ribbon/_stylist_group.html.erb +18 -0
- data/app/views/parapet/shared/explorers/_base.html.erb +107 -0
- data/app/views/parapet/shared/explorers/_css.html.erb +1 -0
- data/app/views/parapet/shared/explorers/_intentions.html.erb +1 -0
- data/app/views/parapet/shared/explorers/_project.html.erb +1 -0
- data/app/views/parapet/shared/explorers/_tests.html.erb +1 -0
- data/app/views/parapet/shared/head/_meta.html.erb +3 -0
- data/app/views/parapet/shared/head/_styles_activities.html.erb +152 -0
- data/app/views/parapet/shared/head/_styles_shared.html.erb +52 -0
- data/app/views/parapet/shared/head/_styles_workspace.html.erb +67 -0
- data/app/views/parapet/shared/ribbon_header/_diagnostic_controls.html.erb +17 -0
- data/app/views/parapet/shared/ribbon_header/_execution_controls.html.erb +28 -0
- data/app/views/parapet/shared/ribbon_header/_focus_search.html.erb +5 -0
- data/app/views/parapet/shared/root_selector/_header.html.erb +10 -0
- data/app/views/parapet/shared/root_selector/_path_input.html.erb +13 -0
- data/app/views/parapet/shared/root_selector/_quick_select.html.erb +20 -0
- data/app/views/parapet/shared/root_selector/_scripts.html.erb +61 -0
- data/app/views/parapet/shared/root_selector/_warning.html.erb +11 -0
- data/app/views/parapet/shared/widgets/_base.html.erb +6 -0
- data/app/views/parapet/shared/widgets/_css.html.erb +79 -0
- data/app/views/parapet/widgets/_explainer.html.erb +17 -0
- data/app/views/parapet/widgets/_hotkeys.html.erb +20 -0
- data/app/views/parapet/widgets/_search.html.erb +68 -0
- data/app/views/parapet/widgets/_spatial_inspector.html.erb +54 -0
- data/app/views/parapet/widgets/_widget-explainer.html.erb +17 -0
- data/app/views/parapet/widgets/_widget-hotkeys.html.erb +20 -0
- data/app/views/parapet/widgets/_widget-search.html.erb +68 -0
- data/app/views/parapet/widgets/_widget-snippets.html.erb +18 -0
- data/app/views/parapet/widgets/_widget-suggestions.html.erb +15 -0
- data/app/views/parapet/widgets/show.html.erb +3 -0
- data/app/views/shared/_footer.html.erb +0 -0
- data/config/parapet/qss_config.yml +117 -0
- data/config/parapet/qss_registry.yml +14 -0
- data/config/routes.rb +40 -0
- data/db/migrate/20260916035300_create_run_histories.rb +16 -0
- data/db/migrate/20261002000000_expand_run_histories_for_activity_tracking.rb +7 -0
- data/db/parapet_migrate/20260929184200_create_parapet_users.rb +12 -0
- data/db/parapet_migrate/20260929184300_create_parapet_sessions.rb +9 -0
- data/db/parapet_migrate/20260930000000_create_parapet_runtime_settings.rb +12 -0
- data/db/parapet_migrate/20260930000100_create_parapet_suite_assignments.rb +14 -0
- data/db/parapet_migrate/20261002000000_create_parapet_activities.rb +16 -0
- data/db/parapet_migrate/20261002000100_create_parapet_draft_recoveries.rb +13 -0
- data/db/parapet_schema.rb +64 -0
- data/db/schema.rb +21 -0
- data/db/schema.released.rb +0 -0
- data/lib/parapet/engine.rb +11 -0
- data/lib/parapet/environment_guard.rb +14 -0
- data/lib/parapet/version.rb +3 -0
- data/lib/parapet-rails.rb +1 -0
- data/lib/parapet.rb +59 -0
- data/lib/tasks/parapet_qss.rake +26 -0
- data/lib/tasks/parapet_tasks.rake +25 -0
- data/lib/tasks/randomize_explorer_health.rb +101 -0
- 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
|
+
})();
|