claude-mpm 4.2.13__py3-none-any.whl → 4.2.14__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_mpm/core/constants.py +65 -0
- claude_mpm/core/error_handler.py +625 -0
- claude_mpm/core/file_utils.py +770 -0
- claude_mpm/core/logging_utils.py +502 -0
- claude_mpm/dashboard/static/dist/components/code-tree.js +1 -1
- claude_mpm/dashboard/static/dist/components/file-viewer.js +1 -1
- claude_mpm/dashboard/static/dist/dashboard.js +1 -1
- claude_mpm/dashboard/static/dist/socket-client.js +1 -1
- claude_mpm/dashboard/static/js/components/code-simple.js +44 -1
- claude_mpm/dashboard/static/js/components/code-tree/tree-breadcrumb.js +353 -0
- claude_mpm/dashboard/static/js/components/code-tree/tree-constants.js +235 -0
- claude_mpm/dashboard/static/js/components/code-tree/tree-search.js +409 -0
- claude_mpm/dashboard/static/js/components/code-tree/tree-utils.js +435 -0
- claude_mpm/dashboard/static/js/components/code-tree.js +29 -5
- claude_mpm/dashboard/static/js/components/file-viewer.js +69 -27
- claude_mpm/dashboard/static/js/components/session-manager.js +1 -1
- claude_mpm/dashboard/static/js/components/working-directory.js +18 -9
- claude_mpm/dashboard/static/js/dashboard.js +55 -9
- claude_mpm/dashboard/static/js/shared/dom-helpers.js +396 -0
- claude_mpm/dashboard/static/js/shared/event-bus.js +330 -0
- claude_mpm/dashboard/static/js/shared/logger.js +385 -0
- claude_mpm/dashboard/static/js/shared/tooltip-service.js +253 -0
- claude_mpm/dashboard/static/js/socket-client.js +18 -0
- claude_mpm/dashboard/templates/index.html +21 -8
- claude_mpm/services/monitor/handlers/__init__.py +2 -1
- claude_mpm/services/monitor/handlers/file.py +263 -0
- claude_mpm/services/monitor/server.py +81 -1
- claude_mpm/services/socketio/handlers/file.py +40 -5
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/METADATA +1 -1
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/RECORD +34 -22
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/WHEEL +0 -0
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.2.13.dist-info → claude_mpm-4.2.14.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Tooltip Service
|
|
3
|
+
*
|
|
4
|
+
* Provides a consistent tooltip implementation for all dashboard components.
|
|
5
|
+
* Supports different tooltip types and behaviors.
|
|
6
|
+
*
|
|
7
|
+
* @module tooltip-service
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class TooltipService {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.tooltips = new Map();
|
|
13
|
+
this.defaultOptions = {
|
|
14
|
+
className: 'dashboard-tooltip',
|
|
15
|
+
duration: 200,
|
|
16
|
+
offset: { x: 10, y: -28 },
|
|
17
|
+
hideDelay: 500
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a new tooltip instance
|
|
23
|
+
* @param {string} id - Unique identifier for the tooltip
|
|
24
|
+
* @param {Object} options - Configuration options
|
|
25
|
+
* @returns {Object} Tooltip instance
|
|
26
|
+
*/
|
|
27
|
+
create(id, options = {}) {
|
|
28
|
+
const config = { ...this.defaultOptions, ...options };
|
|
29
|
+
|
|
30
|
+
// Check if tooltip already exists
|
|
31
|
+
if (this.tooltips.has(id)) {
|
|
32
|
+
return this.tooltips.get(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create tooltip element
|
|
36
|
+
const tooltip = d3.select('body').append('div')
|
|
37
|
+
.attr('class', config.className)
|
|
38
|
+
.style('opacity', 0)
|
|
39
|
+
.style('position', 'absolute')
|
|
40
|
+
.style('pointer-events', 'none')
|
|
41
|
+
.style('background', 'rgba(0, 0, 0, 0.8)')
|
|
42
|
+
.style('color', '#fff')
|
|
43
|
+
.style('padding', '8px 12px')
|
|
44
|
+
.style('border-radius', '4px')
|
|
45
|
+
.style('font-size', '12px')
|
|
46
|
+
.style('font-family', 'system-ui, -apple-system, sans-serif')
|
|
47
|
+
.style('z-index', '10000');
|
|
48
|
+
|
|
49
|
+
const instance = {
|
|
50
|
+
element: tooltip,
|
|
51
|
+
config,
|
|
52
|
+
show: (event, content) => this.show(tooltip, event, content, config),
|
|
53
|
+
hide: () => this.hide(tooltip, config),
|
|
54
|
+
update: (content) => this.update(tooltip, content),
|
|
55
|
+
destroy: () => this.destroy(id)
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
this.tooltips.set(id, instance);
|
|
59
|
+
return instance;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Show a tooltip
|
|
64
|
+
* @param {Object} tooltip - D3 selection of tooltip element
|
|
65
|
+
* @param {Event} event - Mouse event
|
|
66
|
+
* @param {string|Array|Object} content - Content to display
|
|
67
|
+
* @param {Object} config - Configuration options
|
|
68
|
+
*/
|
|
69
|
+
show(tooltip, event, content, config) {
|
|
70
|
+
// Cancel any pending hide transition
|
|
71
|
+
tooltip.interrupt();
|
|
72
|
+
|
|
73
|
+
// Format content
|
|
74
|
+
const html = this.formatContent(content);
|
|
75
|
+
|
|
76
|
+
tooltip.html(html)
|
|
77
|
+
.style('left', (event.pageX + config.offset.x) + 'px')
|
|
78
|
+
.style('top', (event.pageY + config.offset.y) + 'px');
|
|
79
|
+
|
|
80
|
+
tooltip.transition()
|
|
81
|
+
.duration(config.duration)
|
|
82
|
+
.style('opacity', 0.9);
|
|
83
|
+
|
|
84
|
+
// Ensure tooltip stays within viewport
|
|
85
|
+
this.adjustPosition(tooltip, event, config);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Hide a tooltip
|
|
90
|
+
* @param {Object} tooltip - D3 selection of tooltip element
|
|
91
|
+
* @param {Object} config - Configuration options
|
|
92
|
+
*/
|
|
93
|
+
hide(tooltip, config) {
|
|
94
|
+
tooltip.transition()
|
|
95
|
+
.duration(config.hideDelay)
|
|
96
|
+
.style('opacity', 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Update tooltip content without changing position
|
|
101
|
+
* @param {Object} tooltip - D3 selection of tooltip element
|
|
102
|
+
* @param {string|Array|Object} content - New content
|
|
103
|
+
*/
|
|
104
|
+
update(tooltip, content) {
|
|
105
|
+
const html = this.formatContent(content);
|
|
106
|
+
tooltip.html(html);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format content for display
|
|
111
|
+
* @param {string|Array|Object} content - Content to format
|
|
112
|
+
* @returns {string} HTML string
|
|
113
|
+
*/
|
|
114
|
+
formatContent(content) {
|
|
115
|
+
if (typeof content === 'string') {
|
|
116
|
+
return content;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (Array.isArray(content)) {
|
|
120
|
+
return content.join('<br>');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (typeof content === 'object' && content !== null) {
|
|
124
|
+
const lines = [];
|
|
125
|
+
|
|
126
|
+
// Handle title
|
|
127
|
+
if (content.title) {
|
|
128
|
+
lines.push(`<strong>${this.escapeHtml(content.title)}</strong>`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle fields
|
|
132
|
+
if (content.fields) {
|
|
133
|
+
for (const [key, value] of Object.entries(content.fields)) {
|
|
134
|
+
if (value !== undefined && value !== null) {
|
|
135
|
+
lines.push(`${this.escapeHtml(key)}: ${this.escapeHtml(String(value))}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle description
|
|
141
|
+
if (content.description) {
|
|
142
|
+
lines.push(`<em>${this.escapeHtml(content.description)}</em>`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Handle raw HTML (trusted content only)
|
|
146
|
+
if (content.html) {
|
|
147
|
+
lines.push(content.html);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join('<br>');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return String(content);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Adjust tooltip position to stay within viewport
|
|
158
|
+
* @param {Object} tooltip - D3 selection of tooltip element
|
|
159
|
+
* @param {Event} event - Mouse event
|
|
160
|
+
* @param {Object} config - Configuration options
|
|
161
|
+
*/
|
|
162
|
+
adjustPosition(tooltip, event, config) {
|
|
163
|
+
const node = tooltip.node();
|
|
164
|
+
if (!node) return;
|
|
165
|
+
|
|
166
|
+
const rect = node.getBoundingClientRect();
|
|
167
|
+
const viewportWidth = window.innerWidth;
|
|
168
|
+
const viewportHeight = window.innerHeight;
|
|
169
|
+
|
|
170
|
+
let left = event.pageX + config.offset.x;
|
|
171
|
+
let top = event.pageY + config.offset.y;
|
|
172
|
+
|
|
173
|
+
// Adjust horizontal position
|
|
174
|
+
if (rect.right > viewportWidth) {
|
|
175
|
+
left = event.pageX - rect.width - config.offset.x;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Adjust vertical position
|
|
179
|
+
if (rect.bottom > viewportHeight) {
|
|
180
|
+
top = event.pageY - rect.height - Math.abs(config.offset.y);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
tooltip
|
|
184
|
+
.style('left', left + 'px')
|
|
185
|
+
.style('top', top + 'px');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Destroy a tooltip instance
|
|
190
|
+
* @param {string} id - Tooltip identifier
|
|
191
|
+
*/
|
|
192
|
+
destroy(id) {
|
|
193
|
+
const instance = this.tooltips.get(id);
|
|
194
|
+
if (instance) {
|
|
195
|
+
instance.element.remove();
|
|
196
|
+
this.tooltips.delete(id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Destroy all tooltips
|
|
202
|
+
*/
|
|
203
|
+
destroyAll() {
|
|
204
|
+
for (const [id] of this.tooltips) {
|
|
205
|
+
this.destroy(id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Escape HTML special characters
|
|
211
|
+
* @param {string} text - Text to escape
|
|
212
|
+
* @returns {string} Escaped text
|
|
213
|
+
*/
|
|
214
|
+
escapeHtml(text) {
|
|
215
|
+
const div = document.createElement('div');
|
|
216
|
+
div.textContent = text;
|
|
217
|
+
return div.innerHTML;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create a simple tooltip helper for basic use cases
|
|
222
|
+
* @param {string} selector - CSS selector for target elements
|
|
223
|
+
* @param {Function} contentFn - Function to generate content from data
|
|
224
|
+
* @param {Object} options - Configuration options
|
|
225
|
+
*/
|
|
226
|
+
attachToElements(selector, contentFn, options = {}) {
|
|
227
|
+
const id = `tooltip-${selector.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
228
|
+
const tooltip = this.create(id, options);
|
|
229
|
+
|
|
230
|
+
d3.selectAll(selector)
|
|
231
|
+
.on('mouseenter', function(event, d) {
|
|
232
|
+
const content = contentFn(d, this);
|
|
233
|
+
tooltip.show(event, content);
|
|
234
|
+
})
|
|
235
|
+
.on('mouseleave', function() {
|
|
236
|
+
tooltip.hide();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return tooltip;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Export as singleton
|
|
244
|
+
const tooltipService = new TooltipService();
|
|
245
|
+
|
|
246
|
+
// Support both module and global usage
|
|
247
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
248
|
+
module.exports = tooltipService;
|
|
249
|
+
} else if (typeof define === 'function' && define.amd) {
|
|
250
|
+
define([], () => tooltipService);
|
|
251
|
+
} else {
|
|
252
|
+
window.tooltipService = tooltipService;
|
|
253
|
+
}
|
|
@@ -505,6 +505,24 @@ class SocketClient {
|
|
|
505
505
|
// console.log('Received pong from server');
|
|
506
506
|
});
|
|
507
507
|
|
|
508
|
+
// Listen for heartbeat events from server (every 3 minutes)
|
|
509
|
+
this.socket.on('heartbeat', (data) => {
|
|
510
|
+
console.log('🫀 Received server heartbeat:', data);
|
|
511
|
+
// Add heartbeat to event list for visibility
|
|
512
|
+
this.addEvent({
|
|
513
|
+
type: 'system',
|
|
514
|
+
subtype: 'heartbeat',
|
|
515
|
+
timestamp: data.timestamp || new Date().toISOString(),
|
|
516
|
+
data: data
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Update last ping time to indicate server is alive
|
|
520
|
+
this.lastPingTime = Date.now();
|
|
521
|
+
|
|
522
|
+
// Log to console for debugging
|
|
523
|
+
console.log(`Server heartbeat #${data.heartbeat_number}: ${data.server_uptime_formatted} uptime, ${data.connected_clients} clients connected`);
|
|
524
|
+
});
|
|
525
|
+
|
|
508
526
|
// Session and event handlers (legacy/fallback)
|
|
509
527
|
this.socket.on('session.started', (data) => {
|
|
510
528
|
this.addEvent({ type: 'session', subtype: 'started', timestamp: new Date().toISOString(), data });
|
|
@@ -530,14 +530,27 @@
|
|
|
530
530
|
});
|
|
531
531
|
};
|
|
532
532
|
|
|
533
|
-
// Load
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
loadModule('/static/
|
|
537
|
-
loadModule('/static/js/
|
|
538
|
-
loadModule('/static/
|
|
539
|
-
loadModule('/static/
|
|
540
|
-
|
|
533
|
+
// Load shared services first, then tree modules, then components
|
|
534
|
+
// Load services sequentially to ensure dependencies are available
|
|
535
|
+
loadModule('/static/js/shared/tooltip-service.js')
|
|
536
|
+
.then(() => loadModule('/static/js/shared/dom-helpers.js'))
|
|
537
|
+
.then(() => loadModule('/static/js/shared/event-bus.js'))
|
|
538
|
+
.then(() => loadModule('/static/js/shared/logger.js'))
|
|
539
|
+
.then(() => loadModule('/static/js/components/code-tree/tree-utils.js'))
|
|
540
|
+
.then(() => loadModule('/static/js/components/code-tree/tree-constants.js'))
|
|
541
|
+
.then(() => loadModule('/static/js/components/code-tree/tree-search.js'))
|
|
542
|
+
.then(() => loadModule('/static/js/components/code-tree/tree-breadcrumb.js'))
|
|
543
|
+
.then(() => {
|
|
544
|
+
// Now load main components in parallel
|
|
545
|
+
return Promise.all([
|
|
546
|
+
loadModule('/static/dist/dashboard.js'),
|
|
547
|
+
loadModule('/static/dist/components/activity-tree.js'),
|
|
548
|
+
loadModule('/static/js/components/code-tree.js'), // TEMPORARY: Direct source for debugging
|
|
549
|
+
loadModule('/static/dist/components/code-viewer.js'),
|
|
550
|
+
loadModule('/static/dist/components/file-viewer.js') // File viewer for viewing file contents
|
|
551
|
+
]);
|
|
552
|
+
})
|
|
553
|
+
.then(() => {
|
|
541
554
|
console.log('All dashboard modules loaded successfully');
|
|
542
555
|
|
|
543
556
|
// Give modules time to execute and initialize dashboard
|
|
@@ -15,6 +15,7 @@ DESIGN DECISIONS:
|
|
|
15
15
|
|
|
16
16
|
from .code_analysis import CodeAnalysisHandler
|
|
17
17
|
from .dashboard import DashboardHandler
|
|
18
|
+
from .file import FileHandler
|
|
18
19
|
from .hooks import HookHandler
|
|
19
20
|
|
|
20
|
-
__all__ = ["CodeAnalysisHandler", "DashboardHandler", "HookHandler"]
|
|
21
|
+
__all__ = ["CodeAnalysisHandler", "DashboardHandler", "FileHandler", "HookHandler"]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File Handler for Unified Monitor Server
|
|
3
|
+
========================================
|
|
4
|
+
|
|
5
|
+
WHY: Provides file reading capabilities for the dashboard file viewer.
|
|
6
|
+
This handler allows the dashboard to display file contents when users
|
|
7
|
+
click on files in the code tree or other file references.
|
|
8
|
+
|
|
9
|
+
DESIGN DECISIONS:
|
|
10
|
+
- Secure file reading with path validation
|
|
11
|
+
- Size limits to prevent memory issues
|
|
12
|
+
- Compatible with UnifiedMonitorServer architecture
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Optional
|
|
18
|
+
|
|
19
|
+
import socketio
|
|
20
|
+
|
|
21
|
+
from ....core.logging_config import get_logger
|
|
22
|
+
from ....core.unified_paths import get_project_root
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileHandler:
|
|
26
|
+
"""Socket.IO handler for file operations in the unified monitor server.
|
|
27
|
+
|
|
28
|
+
WHY: The dashboard needs to display file contents when users click on files,
|
|
29
|
+
but we must ensure secure file access with proper validation and size limits.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, sio: socketio.AsyncServer):
|
|
33
|
+
"""Initialize the file handler.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
sio: Socket.IO server instance
|
|
37
|
+
"""
|
|
38
|
+
self.sio = sio
|
|
39
|
+
self.logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
def register(self):
|
|
42
|
+
"""Register Socket.IO event handlers for file operations."""
|
|
43
|
+
self.logger.info("[FileHandler] Registering file event handlers")
|
|
44
|
+
|
|
45
|
+
@self.sio.event
|
|
46
|
+
async def read_file(sid, data):
|
|
47
|
+
"""Handle file read requests from dashboard clients.
|
|
48
|
+
|
|
49
|
+
WHY: The dashboard needs to display file contents when users
|
|
50
|
+
click on files, but we must ensure secure file access with
|
|
51
|
+
proper validation and size limits.
|
|
52
|
+
"""
|
|
53
|
+
self.logger.info(
|
|
54
|
+
f"[FileHandler] Received read_file event from {sid} with data: {data}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
file_path = data.get("file_path")
|
|
59
|
+
working_dir = data.get("working_dir", os.getcwd())
|
|
60
|
+
max_size = data.get("max_size", 1024 * 1024) # 1MB default limit
|
|
61
|
+
|
|
62
|
+
if not file_path:
|
|
63
|
+
self.logger.warning(
|
|
64
|
+
f"[FileHandler] Missing file_path in request from {sid}"
|
|
65
|
+
)
|
|
66
|
+
await self.sio.emit(
|
|
67
|
+
"file_content_response",
|
|
68
|
+
{
|
|
69
|
+
"success": False,
|
|
70
|
+
"error": "file_path is required",
|
|
71
|
+
"file_path": file_path,
|
|
72
|
+
},
|
|
73
|
+
room=sid,
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
# Read the file safely
|
|
78
|
+
self.logger.info(
|
|
79
|
+
f"[FileHandler] Reading file: {file_path} from working_dir: {working_dir}"
|
|
80
|
+
)
|
|
81
|
+
result = await self._read_file_safely(file_path, working_dir, max_size)
|
|
82
|
+
|
|
83
|
+
# Send the result back to the client
|
|
84
|
+
self.logger.info(
|
|
85
|
+
f"[FileHandler] Sending file_content_response to {sid}, success: {result.get('success', False)}"
|
|
86
|
+
)
|
|
87
|
+
await self.sio.emit("file_content_response", result, room=sid)
|
|
88
|
+
self.logger.info(f"[FileHandler] Response sent successfully to {sid}")
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.error(
|
|
92
|
+
f"[FileHandler] Exception in read_file handler: {e}", exc_info=True
|
|
93
|
+
)
|
|
94
|
+
await self.sio.emit(
|
|
95
|
+
"file_content_response",
|
|
96
|
+
{
|
|
97
|
+
"success": False,
|
|
98
|
+
"error": str(e),
|
|
99
|
+
"file_path": data.get("file_path", "unknown"),
|
|
100
|
+
},
|
|
101
|
+
room=sid,
|
|
102
|
+
)
|
|
103
|
+
self.logger.info(f"[FileHandler] Error response sent to {sid}")
|
|
104
|
+
|
|
105
|
+
self.logger.info("[FileHandler] File event handlers registered successfully")
|
|
106
|
+
|
|
107
|
+
async def _read_file_safely(
|
|
108
|
+
self,
|
|
109
|
+
file_path: str,
|
|
110
|
+
working_dir: Optional[str] = None,
|
|
111
|
+
max_size: int = 1024 * 1024,
|
|
112
|
+
) -> Dict[str, Any]:
|
|
113
|
+
"""Safely read file content with security checks.
|
|
114
|
+
|
|
115
|
+
WHY: File reading must be secure to prevent directory traversal attacks
|
|
116
|
+
and resource exhaustion. This method centralizes all security checks
|
|
117
|
+
and provides consistent error handling.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
file_path: Path to the file to read
|
|
121
|
+
working_dir: Working directory (defaults to current directory)
|
|
122
|
+
max_size: Maximum file size in bytes
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
dict: Response with success status, content, and metadata
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
if working_dir is None:
|
|
129
|
+
working_dir = os.getcwd()
|
|
130
|
+
|
|
131
|
+
# Resolve absolute path based on working directory
|
|
132
|
+
file_path_obj = Path(file_path)
|
|
133
|
+
if not file_path_obj.is_absolute():
|
|
134
|
+
full_path = Path(working_dir) / file_path
|
|
135
|
+
else:
|
|
136
|
+
full_path = file_path_obj
|
|
137
|
+
|
|
138
|
+
# Security check: ensure file is within working directory or project
|
|
139
|
+
try:
|
|
140
|
+
real_path = full_path.resolve()
|
|
141
|
+
real_working_dir = Path(working_dir).resolve()
|
|
142
|
+
|
|
143
|
+
# Allow access to files within working directory or the project root
|
|
144
|
+
project_root = Path(get_project_root()).resolve()
|
|
145
|
+
allowed_paths = [real_working_dir, project_root]
|
|
146
|
+
|
|
147
|
+
is_allowed = any(
|
|
148
|
+
str(real_path).startswith(str(allowed_path))
|
|
149
|
+
for allowed_path in allowed_paths
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if not is_allowed:
|
|
153
|
+
return {
|
|
154
|
+
"success": False,
|
|
155
|
+
"error": "Access denied: file is outside allowed directories",
|
|
156
|
+
"file_path": file_path,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
except Exception as path_error:
|
|
160
|
+
self.logger.error(f"Path validation error: {path_error}")
|
|
161
|
+
return {
|
|
162
|
+
"success": False,
|
|
163
|
+
"error": "Invalid file path",
|
|
164
|
+
"file_path": file_path,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Check if file exists
|
|
168
|
+
if not real_path.exists():
|
|
169
|
+
return {
|
|
170
|
+
"success": False,
|
|
171
|
+
"error": "File does not exist",
|
|
172
|
+
"file_path": file_path,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Check if it's a file (not directory)
|
|
176
|
+
if not real_path.is_file():
|
|
177
|
+
return {
|
|
178
|
+
"success": False,
|
|
179
|
+
"error": "Path is not a file",
|
|
180
|
+
"file_path": file_path,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Check file size
|
|
184
|
+
file_size = real_path.stat().st_size
|
|
185
|
+
if file_size > max_size:
|
|
186
|
+
return {
|
|
187
|
+
"success": False,
|
|
188
|
+
"error": f"File too large ({file_size} bytes). Maximum allowed: {max_size} bytes",
|
|
189
|
+
"file_path": file_path,
|
|
190
|
+
"file_size": file_size,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Read file content
|
|
194
|
+
try:
|
|
195
|
+
with open(real_path, encoding="utf-8") as f:
|
|
196
|
+
content = f.read()
|
|
197
|
+
|
|
198
|
+
# Get file extension for syntax highlighting hint
|
|
199
|
+
ext = real_path.suffix
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"success": True,
|
|
203
|
+
"file_path": file_path,
|
|
204
|
+
"content": content,
|
|
205
|
+
"file_size": file_size,
|
|
206
|
+
"extension": ext.lower(),
|
|
207
|
+
"encoding": "utf-8",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
except UnicodeDecodeError:
|
|
211
|
+
# Try reading as binary if UTF-8 fails
|
|
212
|
+
return self._read_binary_file(real_path, file_path, file_size)
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
self.logger.error(f"Error in _read_file_safely: {e}")
|
|
216
|
+
return {"success": False, "error": str(e), "file_path": file_path}
|
|
217
|
+
|
|
218
|
+
def _read_binary_file(
|
|
219
|
+
self, real_path: Path, file_path: str, file_size: int
|
|
220
|
+
) -> Dict[str, Any]:
|
|
221
|
+
"""Handle binary or non-UTF8 files.
|
|
222
|
+
|
|
223
|
+
WHY: Not all files are UTF-8 encoded. We need to handle other
|
|
224
|
+
encodings gracefully and detect binary files that shouldn't
|
|
225
|
+
be displayed as text.
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
with open(real_path, "rb") as f:
|
|
229
|
+
binary_content = f.read()
|
|
230
|
+
|
|
231
|
+
# Check if it's a text file by looking for common text patterns
|
|
232
|
+
try:
|
|
233
|
+
text_content = binary_content.decode("latin-1")
|
|
234
|
+
if "\x00" in text_content:
|
|
235
|
+
# Binary file
|
|
236
|
+
return {
|
|
237
|
+
"success": False,
|
|
238
|
+
"error": "File appears to be binary and cannot be displayed as text",
|
|
239
|
+
"file_path": file_path,
|
|
240
|
+
"file_size": file_size,
|
|
241
|
+
}
|
|
242
|
+
# Text file with different encoding
|
|
243
|
+
ext = real_path.suffix
|
|
244
|
+
return {
|
|
245
|
+
"success": True,
|
|
246
|
+
"file_path": file_path,
|
|
247
|
+
"content": text_content,
|
|
248
|
+
"file_size": file_size,
|
|
249
|
+
"extension": ext.lower(),
|
|
250
|
+
"encoding": "latin-1",
|
|
251
|
+
}
|
|
252
|
+
except Exception:
|
|
253
|
+
return {
|
|
254
|
+
"success": False,
|
|
255
|
+
"error": "File encoding not supported",
|
|
256
|
+
"file_path": file_path,
|
|
257
|
+
}
|
|
258
|
+
except Exception as read_error:
|
|
259
|
+
return {
|
|
260
|
+
"success": False,
|
|
261
|
+
"error": f"Failed to read file: {read_error!s}",
|
|
262
|
+
"file_path": file_path,
|
|
263
|
+
}
|