mbeditor 0.1.4 → 0.1.6

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -1
  3. data/app/assets/javascripts/mbeditor/application.js +1 -0
  4. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +3 -1
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +66 -13
  6. data/app/assets/javascripts/mbeditor/components/FileTree.js +0 -17
  7. data/app/assets/javascripts/mbeditor/components/GitPanel.js +115 -32
  8. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +253 -30
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +50 -18
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +2 -2
  11. data/app/assets/javascripts/mbeditor/editor_plugins.js +49 -0
  12. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  13. data/app/assets/javascripts/mbeditor/file_icon.js +30 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +6 -1
  15. data/app/assets/javascripts/mbeditor/search_service.js +8 -4
  16. data/app/assets/stylesheets/mbeditor/application.css +51 -0
  17. data/app/assets/stylesheets/mbeditor/editor.css +379 -5
  18. data/app/controllers/mbeditor/editors_controller.rb +121 -11
  19. data/app/controllers/mbeditor/git_controller.rb +14 -4
  20. data/app/services/mbeditor/git_service.rb +11 -1
  21. data/app/views/layouts/mbeditor/application.html.erb +5 -1
  22. data/config/routes.rb +1 -0
  23. data/lib/mbeditor/rack/silence_ping_request.rb +20 -6
  24. data/lib/mbeditor/version.rb +1 -1
  25. data/public/monaco-editor/vs/basic-languages/shell/shell.js +41 -0
  26. data/public/monaco-editor/vs/basic-languages/typescript/typescript.js +10 -0
  27. data/public/ts_worker.js +5 -0
  28. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45f561cf7e36141944025bc54ce679c4492a7c7315902532e350eab44b138361
4
- data.tar.gz: 7612a0802d9c8e65d0539b621362b0adc3f09866bf131b14b707efafcd35e752
3
+ metadata.gz: 706d2307424a25e3a1b19683326d62de74d98568f755971d6eeb592ad5bae1a4
4
+ data.tar.gz: 8cfb289f2cc0fd18eb2e8c55ae3a2f766712d68231e695b2e02a1e7778207c2d
5
5
  SHA512:
6
- metadata.gz: c896888e924acc1d11e38a0efcdf6f7caec6013eb02426e4d4a0ee847accb3eb5872e5a40e1d4ca7551892352d9fce72ae4667a9585c8265ec7ffc7927e85341
7
- data.tar.gz: d8cbae4ecfd6b3ec04fd5fde655a52cd176830ff38d02c89bd76e7b09372be63b0f227261ec3dcfda2cd79edbe9811a0a2bfddff58a51b2c5d1d36ac199fc3c5
6
+ metadata.gz: 8b691b32511249206e9af0e7ceeb6b26f9cf5bf63cd4b035e8b7fa9411ba05480b4c8795cdcf7a20ffefd536803a61e79c84edb7874fad7d7308aa4dca622b2f
7
+ data.tar.gz: 41c25d7ad6113629fd50267ceff4e9f07490627f9fc2a8eba2fc2804743e8ba7e3c3c6bedc30c4a225e6a177c3354b1282a88df874086bf2e969bced2d20ea65
data/README.md CHANGED
@@ -47,7 +47,7 @@ mount Mbeditor::Engine, at: "/mbeditor"
47
47
  Use a single initializer to set the engine options you need:
48
48
 
49
49
  ```ruby
50
- MBEditor.configure do |config|
50
+ Mbeditor.configure do |config|
51
51
  config.allowed_environments = [:development]
52
52
  # config.workspace_root = Rails.root
53
53
  config.excluded_paths = %w[.git tmp log node_modules .bundle coverage vendor/bundle]
@@ -70,6 +70,17 @@ Available options:
70
70
  - `redmine_url` sets the Redmine base URL. Required when `redmine_enabled` is `true`.
71
71
  - `redmine_api_key` sets the Redmine API key. Required when `redmine_enabled` is `true`.
72
72
 
73
+ ## Keyboard Shortcuts
74
+
75
+ | Shortcut | Action |
76
+ |----------|--------|
77
+ | `Ctrl+P` | Quick-open file by name |
78
+ | `Ctrl+S` | Save the active file |
79
+ | `Ctrl+Shift+S` | Save all dirty files |
80
+ | `Alt+Shift+F` | Format the active file |
81
+ | `Ctrl+Shift+G` | Toggle the git panel |
82
+ | `Ctrl+Z` / `Ctrl+Y` | Undo / Redo (Monaco built-in) |
83
+
73
84
  ## Host Requirements (Optional)
74
85
  The gem keeps host/tooling responsibilities in the host app:
75
86
  - `rubocop` and `rubocop-rails` gems (optional, required for Ruby lint/format endpoints)
@@ -99,6 +110,10 @@ The gem includes syntax highlighting for common Rails and React development file
99
110
 
100
111
  These language modules are packaged locally with the gem for true offline operation. No network fallback is needed—all highlighting works without internet connectivity.
101
112
 
113
+ ## Asset Pipeline
114
+
115
+ Mbeditor requires **Sprockets** (`sprockets-rails >= 3.4`). Host apps using **Propshaft** as their asset pipeline are not supported — the engine depends on Sprockets directives to load its JavaScript and CSS assets.
116
+
102
117
  ## Development
103
118
 
104
119
  A minimal dummy Rails app is included for local development and testing:
@@ -1,4 +1,5 @@
1
1
  //= require mbeditor/editor_store
2
+ //= require mbeditor/file_icon
2
3
  //= require mbeditor/file_service
3
4
  //= require mbeditor/git_service
4
5
  //= require mbeditor/search_service
@@ -83,10 +83,12 @@ var DiffViewer = function DiffViewer(_ref) {
83
83
 
84
84
  function getLanguageForPath(filePath) {
85
85
  if (!filePath) return 'plaintext';
86
+ var fileName = filePath.split('/').pop().toLowerCase();
87
+ if (fileName === 'gemfile' || fileName === 'gemfile.lock' || fileName === 'rakefile') return 'ruby';
86
88
  var ext = filePath.split('.').pop().toLowerCase();
87
89
  var map = {
88
90
  'rb': 'ruby', 'js': 'javascript', 'jsx': 'javascript',
89
- 'ts': 'javascript', 'tsx': 'javascript',
91
+ 'ts': 'typescript', 'tsx': 'typescript',
90
92
  'json': 'json', 'yml': 'yaml', 'yaml': 'yaml',
91
93
  'css': 'css', 'scss': 'scss', 'html': 'html',
92
94
  'xml': 'xml', 'md': 'markdown', 'sh': 'shell'
@@ -15,9 +15,12 @@ var EditorPanel = function EditorPanel(_ref) {
15
15
  var onContentChange = _ref.onContentChange;
16
16
  var markers = _ref.markers;
17
17
  var gitAvailable = _ref.gitAvailable === true;
18
+ var onFormat = _ref.onFormat;
19
+ var editorPrefs = _ref.editorPrefs || {};
18
20
 
19
21
  var editorRef = useRef(null);
20
22
  var monacoRef = useRef(null);
23
+ var latestContentRef = useRef('');
21
24
 
22
25
  var _useState = useState('');
23
26
  var _useState2 = _slicedToArray(_useState, 2);
@@ -42,6 +45,9 @@ var EditorPanel = function EditorPanel(_ref) {
42
45
  var blameDecorationsRef = useRef([]);
43
46
  var blameZoneIdsRef = useRef([]);
44
47
 
48
+ var onFormatRef = useRef(onFormat);
49
+ onFormatRef.current = onFormat;
50
+
45
51
  var clearBlameZones = function clearBlameZones(editor) {
46
52
  if (!editor) return;
47
53
  if (blameZoneIdsRef.current.length === 0) return;
@@ -75,16 +81,23 @@ var EditorPanel = function EditorPanel(_ref) {
75
81
  window.MbeditorEditorPlugins.registerGlobalExtensions(window.monaco);
76
82
  }
77
83
 
78
- var parts = tab.path.split('.');
84
+ var fileName = tab.path.split('/').pop() || '';
85
+ var parts = fileName.split('.');
79
86
  var extension = parts.length > 1 ? parts.pop().toLowerCase() : '';
80
87
  var language = 'plaintext';
81
- switch (extension) {
82
- case 'rb':case 'ruby':case 'gemspec':case 'rakefile':
88
+ switch (fileName.toLowerCase()) {
89
+ case 'gemfile':
90
+ case 'gemfile.lock':
91
+ case 'rakefile':
83
92
  language = 'ruby';break;
93
+ default:
94
+ switch (extension) {
95
+ case 'rb':case 'ruby':case 'gemspec':
96
+ language = 'ruby';break;
84
97
  case 'js':case 'jsx':
85
98
  language = 'javascript';break;
86
99
  case 'ts':case 'tsx':
87
- language = 'javascript';break;
100
+ language = 'typescript';break;
88
101
  case 'css':case 'scss':case 'sass':
89
102
  language = 'css';break;
90
103
  case 'html':case 'erb':
@@ -101,6 +114,7 @@ var EditorPanel = function EditorPanel(_ref) {
101
114
  language = 'shell';break;
102
115
  case 'png':case 'jpg':case 'jpeg':case 'gif':case 'svg':case 'ico':case 'webp':case 'bmp':case 'avif':
103
116
  language = 'image';break;
117
+ }
104
118
  }
105
119
 
106
120
  if (language === 'image') return;
@@ -108,15 +122,15 @@ var EditorPanel = function EditorPanel(_ref) {
108
122
  var editor = window.monaco.editor.create(editorRef.current, {
109
123
  value: tab.content,
110
124
  language: language,
111
- theme: 'vs-dark',
125
+ theme: editorPrefs.theme || 'vs-dark',
112
126
  automaticLayout: true,
113
127
  minimap: { enabled: false },
114
128
  renderLineHighlight: 'none',
115
129
  bracketPairColorization: { enabled: true },
116
- fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
117
- fontSize: 13,
118
- tabSize: 4,
119
- insertSpaces: true,
130
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
131
+ fontSize: editorPrefs.fontSize || 13,
132
+ tabSize: editorPrefs.tabSize || 4,
133
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false,
120
134
  wordWrap: 'on',
121
135
  linkedEditing: true, // Enables Auto-Rename Tag natively!
122
136
  fixedOverflowWidgets: true,
@@ -135,7 +149,20 @@ var EditorPanel = function EditorPanel(_ref) {
135
149
  monacoRef.current = editor;
136
150
  window.__mbeditorActiveEditor = editor;
137
151
 
152
+ // Stash the workspace-relative path on the model so code-action providers
153
+ // can identify which file they are operating on without needing React state.
138
154
  var modelObj = editor.getModel();
155
+ if (modelObj) modelObj._mbeditorPath = tab.path;
156
+
157
+ var formatActionDisposable = editor.addAction({
158
+ id: 'mbeditor.formatDocument',
159
+ label: 'Format Document',
160
+ contextMenuGroupId: '1_modification',
161
+ contextMenuOrder: 1.5,
162
+ run: function() {
163
+ if (onFormatRef.current) onFormatRef.current();
164
+ }
165
+ });
139
166
 
140
167
  var editorPluginDisposable = null;
141
168
  if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.attachEditorFeatures) {
@@ -145,7 +172,7 @@ var EditorPanel = function EditorPanel(_ref) {
145
172
  // Change listener
146
173
  var contentDisposable = modelObj.onDidChangeContent(function (e) {
147
174
  var val = editor.getValue();
148
- var currentContent = monacoRef.current._latestContent || '';
175
+ var currentContent = latestContentRef.current;
149
176
 
150
177
  // Normalize before comparing to prevent false positive dirty edits
151
178
  var vNorm = val.replace(/\r\n/g, '\n');
@@ -163,6 +190,7 @@ var EditorPanel = function EditorPanel(_ref) {
163
190
  window.__mbeditorActiveEditor = null;
164
191
  }
165
192
  if (editorPluginDisposable) editorPluginDisposable.dispose();
193
+ formatActionDisposable.dispose();
166
194
  contentDisposable.dispose();
167
195
  editor.dispose();
168
196
  };
@@ -171,7 +199,7 @@ var EditorPanel = function EditorPanel(_ref) {
171
199
  // Listen for external content changes (e.g. after Format/Save/Load)
172
200
  useEffect(function () {
173
201
  var editor = monacoRef.current;
174
- if (editor) editor._latestContent = tab.content; // update ref for closure
202
+ if (editor) latestContentRef.current = tab.content; // keep ref in sync for closure
175
203
 
176
204
  if (editor && editor.getValue() !== tab.content) {
177
205
  if (typeof tab.content !== 'string') return;
@@ -199,6 +227,21 @@ var EditorPanel = function EditorPanel(_ref) {
199
227
  }
200
228
  }, [tab.content]);
201
229
 
230
+ // Apply editorPrefs changes to a running editor without remounting
231
+ useEffect(function () {
232
+ if (!window.monaco) return;
233
+ var theme = editorPrefs.theme || 'vs-dark';
234
+ window.monaco.editor.setTheme(theme);
235
+ if (monacoRef.current) {
236
+ monacoRef.current.updateOptions({
237
+ fontSize: editorPrefs.fontSize || 13,
238
+ fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace",
239
+ tabSize: editorPrefs.tabSize || 4,
240
+ insertSpaces: typeof editorPrefs.insertSpaces === 'boolean' ? editorPrefs.insertSpaces : false
241
+ });
242
+ }
243
+ }, [editorPrefs]);
244
+
202
245
  // Jump to line if specified
203
246
  useEffect(function () {
204
247
  if (tab.gotoLine && monacoRef.current) {
@@ -222,8 +265,13 @@ var EditorPanel = function EditorPanel(_ref) {
222
265
  var model = monacoRef.current.getModel();
223
266
  if (model) {
224
267
  var monacoMarkers = markers.map(function (m) {
268
+ var sev = m.severity === 'error'
269
+ ? window.monaco.MarkerSeverity.Error
270
+ : window.monaco.MarkerSeverity.Warning;
225
271
  return {
226
- severity: m.severity === 'error' ? window.monaco.MarkerSeverity.Error : window.monaco.MarkerSeverity.Warning,
272
+ severity: sev,
273
+ source: 'rubocop',
274
+ code: m.copName || '',
227
275
  message: m.message,
228
276
  startLineNumber: m.startLine,
229
277
  startColumn: m.startCol,
@@ -232,6 +280,11 @@ var EditorPanel = function EditorPanel(_ref) {
232
280
  };
233
281
  });
234
282
  window.monaco.editor.setModelMarkers(model, 'rubocop', monacoMarkers);
283
+ // Track which cops are autocorrectable so the quick-fix provider can
284
+ // skip lightbulbs for cops that can never be machine-fixed.
285
+ model._mbeditorCorrectableCops = new Set(
286
+ markers.filter(function(m) { return m.correctable && m.copName; }).map(function(m) { return m.copName; })
287
+ );
235
288
  }
236
289
  }
237
290
  }, [markers, tab.id]);
@@ -385,7 +438,7 @@ var EditorPanel = function EditorPanel(_ref) {
385
438
  }, [markdownContent, isMarkdown]);
386
439
 
387
440
  if (tab.isDiff) {
388
- var isDiffDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches || true;
441
+ var isDiffDark = (editorPrefs.theme || 'vs-dark') !== 'vs' && (editorPrefs.theme || 'vs-dark') !== 'hc-light';
389
442
  return React.createElement(window.DiffViewer || DiffViewer, {
390
443
  path: tab.repoPath || tab.path,
391
444
  original: tab.diffOriginal || "",
@@ -109,23 +109,6 @@ var FileTree = function FileTree(_ref) {
109
109
  return { badge: key || '?', cssKey: key || 'Q', title: titleMap[key] || 'Status' };
110
110
  };
111
111
 
112
- window.getFileIcon = function (name) {
113
- var ext = name.split('.').pop().toLowerCase();
114
- var lName = name.toLowerCase();
115
- if (lName === 'gemfile' || ext === 'gemspec' || ext === 'lock') return 'fas fa-gem ruby-icon';
116
- if (ext === 'rb' || ext === 'rake' || lName === 'rakefile') return 'far fa-gem ruby-icon';
117
- if (ext === 'jsx' || name.endsWith('.js.jsx')) return 'fas fa-atom react-icon';
118
- if (ext === 'js' || ext === 'mjs' || ext === 'cjs') return 'fa-brands fa-js js-icon';
119
- if (ext === 'html') return 'fa-brands fa-html5 html-icon';
120
- if (ext === 'erb') return 'fa-brands fa-html5 erb-icon';
121
- if (ext === 'css' || ext === 'scss' || ext === 'sass') return 'fa-brands fa-css3-alt css-icon';
122
- if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'avif'].includes(ext)) return 'far fa-file-image image-icon';
123
- if (ext === 'json') return 'fas fa-code json-icon';
124
- if (ext === 'md' || ext === 'txt') return 'fas fa-file-alt md-icon';
125
- if (ext === 'yml' || ext === 'yaml') return 'fas fa-cogs yml-icon';
126
- return 'far fa-file-code';
127
- };
128
-
129
112
  var handleInlineKeyDown = function handleInlineKeyDown(e) {
130
113
  var isRename = !!pendingRename;
131
114
  if (e.key === 'Enter') {
@@ -1,6 +1,7 @@
1
1
  var GitPanel = function GitPanel(_ref) {
2
2
  var gitInfo = _ref.gitInfo;
3
3
  var error = _ref.error;
4
+ var redmineEnabled = _ref.redmineEnabled;
4
5
  var onOpenFile = _ref.onOpenFile;
5
6
  var onOpenDiff = _ref.onOpenDiff;
6
7
  var onOpenAllChanges = _ref.onOpenAllChanges;
@@ -8,6 +9,7 @@ var GitPanel = function GitPanel(_ref) {
8
9
  var onClose = _ref.onClose;
9
10
 
10
11
  var useState = React.useState;
12
+ var useEffect = React.useEffect;
11
13
 
12
14
  var _s1 = useState(true); var localExpanded = _s1[0]; var setLocalExpanded = _s1[1];
13
15
  var _s2 = useState(true); var branchExpanded = _s2[0]; var setBranchExpanded = _s2[1];
@@ -16,6 +18,13 @@ var GitPanel = function GitPanel(_ref) {
16
18
  var _s4 = useState({}); var expandedCommits = _s4[0]; var setExpandedCommits = _s4[1];
17
19
  // { [hash]: { loading, files: [{status,path}], error } }
18
20
  var _s5 = useState({}); var commitFiles = _s5[0]; var setCommitFiles = _s5[1];
21
+ var _s6 = useState(false); var refreshing = _s6[0]; var setRefreshing = _s6[1];
22
+
23
+ // ── Redmine state ───────────────────────────────────────────────────────────
24
+ var _sr1 = useState(null); var redmineIssue = _sr1[0]; var setRedmineIssue = _sr1[1];
25
+ var _sr2 = useState(null); var redmineError = _sr2[0]; var setRedmineError = _sr2[1];
26
+ var _sr3 = useState(false); var redmineLoading = _sr3[0]; var setRedmineLoading = _sr3[1];
27
+ var _sr4 = useState(true); var redmineExpanded = _sr4[0]; var setRedmineExpanded = _sr4[1];
19
28
 
20
29
  var workingTree = gitInfo && gitInfo.workingTree || [];
21
30
  var unpushedFiles = gitInfo && gitInfo.unpushedFiles || [];
@@ -31,6 +40,39 @@ var GitPanel = function GitPanel(_ref) {
31
40
  return Object.assign({}, c, { isLocal: !!localHashes[c.hash] });
32
41
  });
33
42
 
43
+ // Extract the first Redmine ticket ID (#123) from recent branch commit messages
44
+ var redmineTicketId = null;
45
+ if (redmineEnabled) {
46
+ for (var ci = 0; ci < branchCommits.length; ci++) {
47
+ var titleMatch = branchCommits[ci] && branchCommits[ci].title && branchCommits[ci].title.match(/#(\d+)/);
48
+ if (titleMatch) { redmineTicketId = titleMatch[1]; break; }
49
+ }
50
+ }
51
+
52
+ // Fetch Redmine issue whenever the ticket ID changes
53
+ useEffect(function () {
54
+ if (!redmineEnabled) return;
55
+ if (!redmineTicketId) {
56
+ setRedmineIssue(null);
57
+ setRedmineError(null);
58
+ return;
59
+ }
60
+ setRedmineLoading(true);
61
+ setRedmineIssue(null);
62
+ setRedmineError(null);
63
+ var basePath = (window.MBEDITOR_BASE_PATH || '/mbeditor').replace(/\/$/, '');
64
+ axios.get(basePath + '/redmine/issue/' + redmineTicketId)
65
+ .then(function (res) {
66
+ setRedmineIssue(res.data);
67
+ setRedmineLoading(false);
68
+ })
69
+ .catch(function (err) {
70
+ var msg = (err.response && err.response.data && err.response.data.error) || err.message || 'Failed to load Redmine issue.';
71
+ setRedmineError(msg);
72
+ setRedmineLoading(false);
73
+ });
74
+ }, [redmineEnabled, redmineTicketId]);
75
+
34
76
  var statusMeta = function statusMeta(rawStatus) {
35
77
  var raw = (rawStatus || '').trim();
36
78
  if (raw === '??') return { badge: 'NEW', cssKey: 'A', description: 'Untracked' };
@@ -46,36 +88,7 @@ var GitPanel = function GitPanel(_ref) {
46
88
  };
47
89
 
48
90
  var fileIcon = function fileIcon(filename) {
49
- var ext = (filename || '').split('.').pop().toLowerCase();
50
- var iconMap = {
51
- 'rb': { cls: 'fas fa-gem', color: '#cc342d' },
52
- 'gemspec': { cls: 'fas fa-gem', color: '#cc342d' },
53
- 'js': { cls: 'fab fa-js', color: '#f0db4f' },
54
- 'ts': { cls: 'fas fa-code', color: '#3178c6' },
55
- 'jsx': { cls: 'fab fa-js', color: '#61dafb' },
56
- 'tsx': { cls: 'fas fa-code', color: '#61dafb' },
57
- 'html': { cls: 'fab fa-html5', color: '#e34f26' },
58
- 'htm': { cls: 'fab fa-html5', color: '#e34f26' },
59
- 'erb': { cls: 'fab fa-html5', color: '#cc342d' },
60
- 'css': { cls: 'fab fa-css3-alt', color: '#1572b6' },
61
- 'scss': { cls: 'fab fa-sass', color: '#cc6699' },
62
- 'sass': { cls: 'fab fa-sass', color: '#cc6699' },
63
- 'md': { cls: 'fab fa-markdown', color: '#7f8b97' },
64
- 'json': { cls: 'fas fa-code', color: '#ffe082' },
65
- 'yml': { cls: 'fas fa-cog', color: '#888' },
66
- 'yaml': { cls: 'fas fa-cog', color: '#888' },
67
- 'png': { cls: 'fas fa-image', color: '#aaa' },
68
- 'jpg': { cls: 'fas fa-image', color: '#aaa' },
69
- 'jpeg': { cls: 'fas fa-image', color: '#aaa' },
70
- 'gif': { cls: 'fas fa-image', color: '#aaa' },
71
- 'svg': { cls: 'fas fa-image', color: '#aaa' },
72
- 'txt': { cls: 'fas fa-file-alt', color: '#888' },
73
- 'sh': { cls: 'fas fa-terminal', color: '#89d185' },
74
- 'lock': { cls: 'fas fa-lock', color: '#888' },
75
- 'pdf': { cls: 'fas fa-file-pdf', color: '#f48771' },
76
- };
77
- var icon = iconMap[ext] || { cls: 'fas fa-file-code', color: '#7f8b97' };
78
- return React.createElement('i', { className: icon.cls + ' git-file-type-icon', style: { color: icon.color } });
91
+ return React.createElement('i', { className: (window.getFileIcon ? window.getFileIcon(filename) : 'far fa-file-code') + ' git-file-type-icon' });
79
92
  };
80
93
 
81
94
  // Renders a file row used in Local Changes and Changes in Branch sections
@@ -148,6 +161,23 @@ var GitPanel = function GitPanel(_ref) {
148
161
  }
149
162
  };
150
163
 
164
+ var handleRefresh = function handleRefresh() {
165
+ if (!onRefresh || refreshing) return;
166
+
167
+ var result;
168
+ try {
169
+ setRefreshing(true);
170
+ result = onRefresh();
171
+ } catch (err) {
172
+ setRefreshing(false);
173
+ throw err;
174
+ }
175
+
176
+ return Promise.resolve(result).finally(function () {
177
+ setRefreshing(false);
178
+ });
179
+ };
180
+
151
181
  var renderCommit = function renderCommit(commit, idx) {
152
182
  var isFirst = idx === 0;
153
183
  var isLast = idx === branchCommits.length - 1;
@@ -305,8 +335,8 @@ var GitPanel = function GitPanel(_ref) {
305
335
  { className: 'ide-git-panel-actions' },
306
336
  onRefresh && React.createElement(
307
337
  'button',
308
- { className: 'git-header-btn', onClick: onRefresh, title: 'Refresh' },
309
- React.createElement('i', { className: 'fas fa-sync-alt' })
338
+ { className: 'git-header-btn', onClick: handleRefresh, title: 'Refresh', disabled: refreshing, 'aria-busy': refreshing },
339
+ React.createElement('i', { className: 'fas fa-sync-alt' + (refreshing ? ' fa-spin' : '') })
310
340
  ),
311
341
  onClose && React.createElement(
312
342
  'button',
@@ -318,6 +348,59 @@ var GitPanel = function GitPanel(_ref) {
318
348
 
319
349
  error && React.createElement('div', { className: 'git-error' }, error),
320
350
 
351
+ // ── Redmine Issue Section ───────────────────────────────────────────────
352
+ redmineEnabled && React.createElement(
353
+ 'div',
354
+ { className: 'git-section ' + (redmineExpanded ? 'expanded' : '') + ' git-section--redmine' },
355
+ React.createElement(
356
+ 'div',
357
+ { className: 'git-section-title' },
358
+ React.createElement(
359
+ 'div',
360
+ { className: 'git-section-title-main hoverable', onClick: function () { setRedmineExpanded(!redmineExpanded); } },
361
+ React.createElement(
362
+ 'span',
363
+ { className: 'git-redmine-section-label' },
364
+ React.createElement('i', { className: 'fas ' + (redmineExpanded ? 'fa-chevron-down' : 'fa-chevron-right'), style: { width: '14px', fontSize: '10px' } }),
365
+ '\u2002Redmine',
366
+ redmineTicketId && React.createElement('span', { className: 'git-section-count', style: { marginLeft: '4px' } }, '#' + redmineTicketId)
367
+ ),
368
+ redmineIssue && React.createElement('span', { className: 'redmine-badge redmine-badge--section' }, redmineIssue.status)
369
+ )
370
+ ),
371
+ redmineExpanded && React.createElement(
372
+ 'div',
373
+ { className: 'git-redmine-content' },
374
+ redmineLoading
375
+ ? React.createElement('div', { className: 'git-empty' },
376
+ React.createElement('i', { className: 'fas fa-spinner fa-spin', style: { marginRight: '6px' } }),
377
+ 'Loading issue\u2026'
378
+ )
379
+ : !redmineTicketId
380
+ ? React.createElement('div', { className: 'git-empty' }, 'No matching ticket found in branch commits.')
381
+ : redmineError
382
+ ? React.createElement('div', { className: 'git-redmine-error' },
383
+ React.createElement('i', { className: 'fas fa-exclamation-circle', style: { marginRight: '6px', color: '#f48771' } }),
384
+ redmineError
385
+ )
386
+ : redmineIssue
387
+ ? React.createElement(
388
+ 'div',
389
+ { className: 'git-redmine-issue' },
390
+ React.createElement('div', { className: 'git-redmine-title' }, redmineIssue.title),
391
+ redmineIssue.description
392
+ ? React.createElement('div', { className: 'git-redmine-desc' }, redmineIssue.description)
393
+ : null,
394
+ React.createElement(
395
+ 'div',
396
+ { className: 'git-redmine-footer' },
397
+ redmineIssue.author || ''
398
+ )
399
+ )
400
+ : null
401
+ )
402
+ ),
403
+
321
404
  // ── Section 1: Local Changes ────────────────────────────────────────────
322
405
  React.createElement(
323
406
  'div',