gh-here 2.0.0 → 3.0.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.
- package/.claude/settings.local.json +20 -11
- package/README.md +30 -101
- package/SAMPLE.md +287 -0
- package/lib/constants.js +38 -0
- package/lib/error-handler.js +55 -0
- package/lib/file-tree-builder.js +81 -0
- package/lib/file-utils.js +43 -12
- package/lib/renderers.js +440 -194
- package/lib/server.js +120 -32
- package/lib/validation.js +77 -0
- package/package.json +1 -1
- package/public/app.js +199 -1825
- package/public/app.js.backup +1902 -0
- package/public/js/clipboard-utils.js +45 -0
- package/public/js/constants.js +60 -0
- package/public/js/draft-manager.js +36 -0
- package/public/js/editor-manager.js +159 -0
- package/public/js/file-tree.js +321 -0
- package/public/js/keyboard-handler.js +41 -0
- package/public/js/modal-manager.js +70 -0
- package/public/js/navigation.js +254 -0
- package/public/js/notification.js +23 -0
- package/public/js/search-handler.js +238 -0
- package/public/js/theme-manager.js +108 -0
- package/public/js/utils.js +123 -0
- package/public/styles.css +874 -570
- package/.channels_cache_v2.json +0 -10882
- package/.users_cache.json +0 -16187
- package/blog-post.md +0 -100
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal management utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function createModal(content, className = '') {
|
|
6
|
+
const modal = document.createElement('div');
|
|
7
|
+
modal.className = `modal-overlay ${className}`;
|
|
8
|
+
modal.innerHTML = content;
|
|
9
|
+
|
|
10
|
+
document.body.appendChild(modal);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
element: modal,
|
|
14
|
+
close: () => modal.remove()
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function showDraftDialog(filePath) {
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
const modal = document.createElement('div');
|
|
21
|
+
modal.className = 'modal-overlay';
|
|
22
|
+
modal.innerHTML = `
|
|
23
|
+
<div class="modal-content draft-modal">
|
|
24
|
+
<h3>Unsaved Changes Found</h3>
|
|
25
|
+
<p>You have unsaved changes for this file. What would you like to do?</p>
|
|
26
|
+
<div class="draft-actions">
|
|
27
|
+
<button class="btn btn-primary" data-action="load">Load Draft</button>
|
|
28
|
+
<button class="btn btn-secondary" data-action="discard">Discard Draft</button>
|
|
29
|
+
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
modal.addEventListener('click', e => {
|
|
35
|
+
if (e.target.matches('[data-action]') || e.target === modal) {
|
|
36
|
+
const action = e.target.dataset?.action || 'cancel';
|
|
37
|
+
modal.remove();
|
|
38
|
+
resolve(action);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
document.body.appendChild(modal);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function showConfirmDialog(message, confirmText = 'Confirm', cancelText = 'Cancel') {
|
|
47
|
+
return new Promise(resolve => {
|
|
48
|
+
const modal = document.createElement('div');
|
|
49
|
+
modal.className = 'modal-overlay';
|
|
50
|
+
modal.innerHTML = `
|
|
51
|
+
<div class="modal-content">
|
|
52
|
+
<p>${message}</p>
|
|
53
|
+
<div class="modal-actions">
|
|
54
|
+
<button class="btn btn-secondary" data-action="cancel">${cancelText}</button>
|
|
55
|
+
<button class="btn btn-primary" data-action="confirm">${confirmText}</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
modal.addEventListener('click', e => {
|
|
61
|
+
if (e.target.matches('[data-action]') || e.target === modal) {
|
|
62
|
+
const confirmed = e.target.dataset?.action === 'confirm';
|
|
63
|
+
modal.remove();
|
|
64
|
+
resolve(confirmed);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
document.body.appendChild(modal);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side navigation handler for smooth page transitions
|
|
3
|
+
* Optimized with request cancellation and efficient DOM updates
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class NavigationHandler {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.mainContentWrapper = document.querySelector('.main-content-wrapper');
|
|
9
|
+
this.isNavigating = false;
|
|
10
|
+
this.abortController = null;
|
|
11
|
+
|
|
12
|
+
// Clear any stuck navigation state on initialization
|
|
13
|
+
this.removeNavigatingClass();
|
|
14
|
+
|
|
15
|
+
this.setupListeners();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
setupListeners() {
|
|
19
|
+
// Listen for navigation events from file tree and keyboard
|
|
20
|
+
document.addEventListener('navigate', this.handleNavigateEvent.bind(this));
|
|
21
|
+
|
|
22
|
+
// Intercept all internal link clicks (event delegation - no duplicates)
|
|
23
|
+
document.addEventListener('click', this.handleLinkClick.bind(this));
|
|
24
|
+
|
|
25
|
+
// Handle back/forward buttons
|
|
26
|
+
window.addEventListener('popstate', this.handlePopState.bind(this));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
handleNavigateEvent(e) {
|
|
30
|
+
const { path } = e.detail;
|
|
31
|
+
this.navigateTo(path);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
handleLinkClick(e) {
|
|
35
|
+
const link = e.target.closest('a');
|
|
36
|
+
if (!link) return;
|
|
37
|
+
|
|
38
|
+
// Skip if clicking inside quick actions or other interactive elements
|
|
39
|
+
if (e.target.closest('.quick-actions, button, .file-action-btn')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Only intercept left clicks on same-origin links without modifier keys
|
|
44
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey ||
|
|
45
|
+
e.button !== 0 || link.target === '_blank' || link.download) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!this.isSameOrigin(link.href)) return;
|
|
50
|
+
|
|
51
|
+
const url = new URL(link.href);
|
|
52
|
+
// Only intercept navigation within our app
|
|
53
|
+
if (url.origin === window.location.origin &&
|
|
54
|
+
(url.pathname === '/' || url.pathname === window.location.pathname)) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
const path = url.searchParams.get('path') || '';
|
|
58
|
+
const view = url.searchParams.get('view') || '';
|
|
59
|
+
this.navigateTo(path, true, view);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
handlePopState(e) {
|
|
64
|
+
const url = new URL(window.location.href);
|
|
65
|
+
const path = e.state?.path ?? (url.searchParams.get('path') || '');
|
|
66
|
+
const view = e.state?.view ?? (url.searchParams.get('view') || '');
|
|
67
|
+
this.navigateTo(path, false, view);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isSameOrigin(href) {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(href, window.location.origin).origin === window.location.origin;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Helper to safely remove navigating class
|
|
79
|
+
removeNavigatingClass() {
|
|
80
|
+
// Re-find the element in case reference is stale
|
|
81
|
+
const wrapper = document.querySelector('.main-content-wrapper');
|
|
82
|
+
if (wrapper) {
|
|
83
|
+
wrapper.classList.remove('navigating');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async navigateTo(path, pushState = true, view = '') {
|
|
88
|
+
// Cancel any in-flight request
|
|
89
|
+
if (this.abortController) {
|
|
90
|
+
this.abortController.abort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.isNavigating) return;
|
|
94
|
+
this.isNavigating = true;
|
|
95
|
+
|
|
96
|
+
// Preserve existing query parameters (like showGitignored) while updating path and view
|
|
97
|
+
let url = '/';
|
|
98
|
+
const currentParams = new URLSearchParams(window.location.search);
|
|
99
|
+
const params = new URLSearchParams(currentParams); // Start with existing params
|
|
100
|
+
|
|
101
|
+
// Update path parameter
|
|
102
|
+
if (path) {
|
|
103
|
+
params.set('path', path);
|
|
104
|
+
} else {
|
|
105
|
+
params.delete('path');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Update view parameter
|
|
109
|
+
if (view) {
|
|
110
|
+
params.set('view', view);
|
|
111
|
+
} else {
|
|
112
|
+
params.delete('view');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const queryString = params.toString();
|
|
116
|
+
if (queryString) {
|
|
117
|
+
url += `?${queryString}`;
|
|
118
|
+
}
|
|
119
|
+
this.abortController = new AbortController();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Re-find element in case reference is stale
|
|
123
|
+
this.mainContentWrapper = document.querySelector('.main-content-wrapper');
|
|
124
|
+
|
|
125
|
+
// Optimize: Use class instead of inline style
|
|
126
|
+
if (this.mainContentWrapper) {
|
|
127
|
+
this.mainContentWrapper.classList.add('navigating');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fetch with abort signal
|
|
131
|
+
const response = await fetch(url, { signal: this.abortController.signal });
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`HTTP ${response.status}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const html = await response.text();
|
|
137
|
+
const parser = new DOMParser();
|
|
138
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
139
|
+
|
|
140
|
+
// Extract the new content - update main-content-wrapper and sidebar if needed
|
|
141
|
+
const newMainContent = doc.querySelector('.main-content-wrapper');
|
|
142
|
+
const newBreadcrumb = doc.querySelector('.breadcrumb-section');
|
|
143
|
+
const newSidebar = doc.querySelector('.file-tree-sidebar');
|
|
144
|
+
|
|
145
|
+
if (!newMainContent) {
|
|
146
|
+
throw new Error('Content not found: missing .main-content-wrapper in response');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// If current wrapper doesn't exist, create it (shouldn't happen, but handle gracefully)
|
|
150
|
+
if (!this.mainContentWrapper) {
|
|
151
|
+
const main = document.querySelector('main');
|
|
152
|
+
if (!main) {
|
|
153
|
+
throw new Error('Content not found: missing <main> element');
|
|
154
|
+
}
|
|
155
|
+
// Create wrapper if it doesn't exist (edge case)
|
|
156
|
+
const wrapper = document.createElement('div');
|
|
157
|
+
wrapper.className = 'main-content-wrapper';
|
|
158
|
+
main.appendChild(wrapper);
|
|
159
|
+
this.mainContentWrapper = wrapper;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Batch DOM updates
|
|
163
|
+
requestAnimationFrame(() => {
|
|
164
|
+
// Re-find in case reference changed
|
|
165
|
+
const wrapper = document.querySelector('.main-content-wrapper');
|
|
166
|
+
const main = document.querySelector('main');
|
|
167
|
+
|
|
168
|
+
if (!wrapper) {
|
|
169
|
+
this.removeNavigatingClass();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Update sidebar visibility based on path (always check, even if not in new content)
|
|
174
|
+
if (main) {
|
|
175
|
+
const currentSidebar = main.querySelector('.file-tree-sidebar');
|
|
176
|
+
const hasPath = path && path !== '';
|
|
177
|
+
|
|
178
|
+
if (currentSidebar) {
|
|
179
|
+
// Always update visibility based on path
|
|
180
|
+
if (hasPath) {
|
|
181
|
+
currentSidebar.classList.remove('hidden');
|
|
182
|
+
// Update sidebar content if new sidebar exists and structure changed
|
|
183
|
+
if (newSidebar) {
|
|
184
|
+
const newSidebarContent = newSidebar.innerHTML;
|
|
185
|
+
if (newSidebarContent !== currentSidebar.innerHTML) {
|
|
186
|
+
currentSidebar.innerHTML = newSidebarContent;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// Hide sidebar when navigating to root
|
|
191
|
+
currentSidebar.classList.add('hidden');
|
|
192
|
+
}
|
|
193
|
+
} else if (hasPath && newSidebar) {
|
|
194
|
+
// Sidebar doesn't exist but should - insert it before main-content-wrapper
|
|
195
|
+
const sidebarClone = newSidebar.cloneNode(true);
|
|
196
|
+
sidebarClone.classList.remove('hidden');
|
|
197
|
+
main.insertBefore(sidebarClone, wrapper);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Update breadcrumbs if they exist
|
|
202
|
+
if (newBreadcrumb) {
|
|
203
|
+
const oldBreadcrumb = wrapper.parentElement?.querySelector('.breadcrumb-section');
|
|
204
|
+
if (oldBreadcrumb) {
|
|
205
|
+
oldBreadcrumb.replaceWith(newBreadcrumb.cloneNode(true));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Update main content wrapper content
|
|
210
|
+
wrapper.innerHTML = newMainContent.innerHTML;
|
|
211
|
+
wrapper.className = newMainContent.className;
|
|
212
|
+
|
|
213
|
+
// Update browser URL
|
|
214
|
+
if (pushState) {
|
|
215
|
+
window.history.pushState({ path, view }, '', url);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Fade in new content - always remove navigating class
|
|
219
|
+
wrapper.classList.remove('navigating');
|
|
220
|
+
this.mainContentWrapper = wrapper; // Update reference
|
|
221
|
+
|
|
222
|
+
// Notify that content has changed
|
|
223
|
+
document.dispatchEvent(new CustomEvent('content-loaded'));
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error.name === 'AbortError') {
|
|
227
|
+
// Request was cancelled - still need to clean up
|
|
228
|
+
this.removeNavigatingClass();
|
|
229
|
+
this.isNavigating = false;
|
|
230
|
+
this.abortController = null;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Log more detailed error info
|
|
235
|
+
console.error('Navigation failed:', {
|
|
236
|
+
error: error.message,
|
|
237
|
+
url,
|
|
238
|
+
path,
|
|
239
|
+
view,
|
|
240
|
+
hasWrapper: !!this.mainContentWrapper,
|
|
241
|
+
timestamp: new Date().toISOString()
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Always restore state on error
|
|
245
|
+
this.removeNavigatingClass();
|
|
246
|
+
|
|
247
|
+
// Fall back to full page load
|
|
248
|
+
window.location.href = url;
|
|
249
|
+
} finally {
|
|
250
|
+
this.isNavigating = false;
|
|
251
|
+
this.abortController = null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification system
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CONFIG } from './constants.js';
|
|
6
|
+
|
|
7
|
+
export function showNotification(message, type = 'info') {
|
|
8
|
+
const existingNotifications = document.querySelectorAll('.notification');
|
|
9
|
+
existingNotifications.forEach(n => n.remove());
|
|
10
|
+
|
|
11
|
+
const notification = document.createElement('div');
|
|
12
|
+
notification.className = `notification notification-${type}`;
|
|
13
|
+
notification.textContent = message;
|
|
14
|
+
|
|
15
|
+
document.body.appendChild(notification);
|
|
16
|
+
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
if (notification.parentNode) {
|
|
19
|
+
notification.style.opacity = '0';
|
|
20
|
+
setTimeout(() => notification.remove(), 300);
|
|
21
|
+
}
|
|
22
|
+
}, CONFIG.NOTIFICATION_DURATION);
|
|
23
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-aware file search
|
|
3
|
+
* - Directory pages: Global repository search
|
|
4
|
+
* - File view pages: Filter sidebar tree
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class SearchHandler {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.fileTree = document.getElementById('file-tree');
|
|
10
|
+
this.fileTable = document.getElementById('file-table');
|
|
11
|
+
this.debounceTimer = null;
|
|
12
|
+
this.searchOverlay = null;
|
|
13
|
+
this.treeItems = [];
|
|
14
|
+
|
|
15
|
+
// Determine context based on page elements
|
|
16
|
+
this.isFileViewContext = !!this.fileTree && !this.fileTable;
|
|
17
|
+
|
|
18
|
+
// Use correct search input for context
|
|
19
|
+
const searchId = this.isFileViewContext ? 'file-search' : 'root-file-search';
|
|
20
|
+
this.searchInput = document.getElementById(searchId);
|
|
21
|
+
|
|
22
|
+
this.init();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
init() {
|
|
26
|
+
if (!this.searchInput) return;
|
|
27
|
+
|
|
28
|
+
if (this.isFileViewContext) {
|
|
29
|
+
this.updateTreeItems();
|
|
30
|
+
document.addEventListener('filetree-loaded', () => this.updateTreeItems());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.setupListeners();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setupListeners() {
|
|
37
|
+
if (!this.searchInput) return;
|
|
38
|
+
|
|
39
|
+
this.searchInput.addEventListener('input', () => this.handleSearchInput());
|
|
40
|
+
|
|
41
|
+
if (!this.isFileViewContext) {
|
|
42
|
+
// Global search: show results on focus
|
|
43
|
+
this.searchInput.addEventListener('focus', () => this.handleSearchFocus());
|
|
44
|
+
|
|
45
|
+
// Close results on click outside
|
|
46
|
+
document.addEventListener('click', (e) => {
|
|
47
|
+
if (this.searchOverlay &&
|
|
48
|
+
!e.target.closest('.search-container') &&
|
|
49
|
+
!e.target.closest('.search-results-overlay')) {
|
|
50
|
+
this.hideResults();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
updateTreeItems() {
|
|
57
|
+
if (this.fileTree) {
|
|
58
|
+
this.treeItems = Array.from(this.fileTree.querySelectorAll('.tree-item'));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleSearchFocus() {
|
|
63
|
+
if (this.isFileViewContext) return;
|
|
64
|
+
|
|
65
|
+
const query = this.searchInput.value.trim();
|
|
66
|
+
if (query) {
|
|
67
|
+
this.performGlobalSearch(query);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
handleSearchInput() {
|
|
72
|
+
if (this.debounceTimer) {
|
|
73
|
+
clearTimeout(this.debounceTimer);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const query = this.searchInput.value.trim();
|
|
77
|
+
|
|
78
|
+
if (!query) {
|
|
79
|
+
if (this.isFileViewContext) {
|
|
80
|
+
this.clearTreeFilter();
|
|
81
|
+
} else {
|
|
82
|
+
this.hideResults();
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.debounceTimer = setTimeout(() => {
|
|
88
|
+
if (this.isFileViewContext) {
|
|
89
|
+
this.filterTree(query);
|
|
90
|
+
} else {
|
|
91
|
+
this.performGlobalSearch(query);
|
|
92
|
+
}
|
|
93
|
+
}, 200);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// File view context: Filter sidebar tree
|
|
97
|
+
filterTree(query) {
|
|
98
|
+
if (!this.fileTree || this.treeItems.length === 0) return;
|
|
99
|
+
|
|
100
|
+
const queryLower = query.toLowerCase();
|
|
101
|
+
const containers = this.fileTree.querySelectorAll('.tree-children');
|
|
102
|
+
|
|
103
|
+
// Hide all items first
|
|
104
|
+
this.treeItems.forEach(item => item.style.display = 'none');
|
|
105
|
+
containers.forEach(container => container.style.display = 'none');
|
|
106
|
+
|
|
107
|
+
// Show matching items and their parent folders
|
|
108
|
+
this.treeItems.forEach(item => {
|
|
109
|
+
const label = item.querySelector('.tree-label');
|
|
110
|
+
const fileName = label?.textContent?.toLowerCase() || '';
|
|
111
|
+
|
|
112
|
+
if (fileName.includes(queryLower)) {
|
|
113
|
+
item.style.display = '';
|
|
114
|
+
|
|
115
|
+
// Show all parent folders
|
|
116
|
+
let parent = item.parentElement;
|
|
117
|
+
while (parent && parent !== this.fileTree) {
|
|
118
|
+
if (parent.classList.contains('tree-children')) {
|
|
119
|
+
parent.style.display = '';
|
|
120
|
+
}
|
|
121
|
+
const parentItem = parent.previousElementSibling;
|
|
122
|
+
if (parentItem?.classList.contains('tree-item')) {
|
|
123
|
+
parentItem.style.display = '';
|
|
124
|
+
}
|
|
125
|
+
parent = parent.parentElement;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clearTreeFilter() {
|
|
132
|
+
if (!this.fileTree) return;
|
|
133
|
+
|
|
134
|
+
this.treeItems.forEach(item => item.style.display = '');
|
|
135
|
+
this.fileTree.querySelectorAll('.tree-children').forEach(container => {
|
|
136
|
+
container.style.display = '';
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Directory context: Global repository search
|
|
141
|
+
async performGlobalSearch(query) {
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
|
144
|
+
const data = await response.json();
|
|
145
|
+
|
|
146
|
+
if (data.success) {
|
|
147
|
+
this.showResults(data.results, query);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error('Search failed:', error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
showResults(results, query) {
|
|
155
|
+
this.hideResults();
|
|
156
|
+
|
|
157
|
+
this.searchOverlay = document.createElement('div');
|
|
158
|
+
this.searchOverlay.className = 'search-results-overlay';
|
|
159
|
+
|
|
160
|
+
if (results.length === 0) {
|
|
161
|
+
this.searchOverlay.innerHTML = `
|
|
162
|
+
<div class="search-results-container">
|
|
163
|
+
<div class="search-results-header">
|
|
164
|
+
<span class="search-results-count">No results for "${query}"</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
`;
|
|
168
|
+
} else {
|
|
169
|
+
const resultsList = results.map(result => {
|
|
170
|
+
const icon = result.isDirectory
|
|
171
|
+
? '<svg class="result-icon" viewBox="0 0 16 16" width="16" height="16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path></svg>'
|
|
172
|
+
: '<svg class="result-icon file-icon" viewBox="0 0 16 16" width="16" height="16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"></path></svg>';
|
|
173
|
+
|
|
174
|
+
return `
|
|
175
|
+
<a href="/?path=${encodeURIComponent(result.path)}" class="search-result-item" data-path="${result.path}">
|
|
176
|
+
${icon}
|
|
177
|
+
<div class="search-result-content">
|
|
178
|
+
<div class="search-result-path">${this.highlightMatch(result.path, query)}</div>
|
|
179
|
+
</div>
|
|
180
|
+
</a>
|
|
181
|
+
`;
|
|
182
|
+
}).join('');
|
|
183
|
+
|
|
184
|
+
this.searchOverlay.innerHTML = `
|
|
185
|
+
<div class="search-results-container">
|
|
186
|
+
<div class="search-results-header">
|
|
187
|
+
<span class="search-results-count">${results.length} result${results.length === 1 ? '' : 's'}</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="search-results-list">
|
|
190
|
+
${resultsList}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const searchContainer = this.searchInput.closest('.search-container');
|
|
197
|
+
if (searchContainer) {
|
|
198
|
+
searchContainer.appendChild(this.searchOverlay);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.searchOverlay.querySelectorAll('.search-result-item').forEach(item => {
|
|
202
|
+
item.addEventListener('click', (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
window.location.href = item.getAttribute('href');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
highlightMatch(text, query) {
|
|
210
|
+
if (!query) return text;
|
|
211
|
+
|
|
212
|
+
const lowerText = text.toLowerCase();
|
|
213
|
+
const lowerQuery = query.toLowerCase();
|
|
214
|
+
const index = lowerText.indexOf(lowerQuery);
|
|
215
|
+
|
|
216
|
+
if (index === -1) return text;
|
|
217
|
+
|
|
218
|
+
const before = text.substring(0, index);
|
|
219
|
+
const match = text.substring(index, index + query.length);
|
|
220
|
+
const after = text.substring(index + query.length);
|
|
221
|
+
|
|
222
|
+
return `${before}<mark class="search-highlight">${match}</mark>${after}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
hideResults() {
|
|
226
|
+
if (this.searchOverlay) {
|
|
227
|
+
this.searchOverlay.remove();
|
|
228
|
+
this.searchOverlay = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
focusSearch() {
|
|
233
|
+
if (this.searchInput) {
|
|
234
|
+
this.searchInput.focus();
|
|
235
|
+
this.searchInput.select();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { THEME, STORAGE_KEYS } from './constants.js';
|
|
6
|
+
|
|
7
|
+
export class ThemeManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.html = document.documentElement;
|
|
10
|
+
this.themeToggle = null;
|
|
11
|
+
this.currentTheme = THEME.DARK;
|
|
12
|
+
this.init();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
init() {
|
|
16
|
+
const systemPrefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
|
|
17
|
+
const systemTheme = systemPrefersDark ? THEME.DARK : THEME.LIGHT;
|
|
18
|
+
const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME) || systemTheme;
|
|
19
|
+
|
|
20
|
+
this.currentTheme = savedTheme;
|
|
21
|
+
this.setTheme(savedTheme);
|
|
22
|
+
|
|
23
|
+
// Setup listeners - try immediately, then retry after DOM is ready
|
|
24
|
+
this.setupListeners();
|
|
25
|
+
|
|
26
|
+
if (document.readyState === 'loading') {
|
|
27
|
+
document.addEventListener('DOMContentLoaded', () => this.setupListeners());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Also re-setup after content changes (navigation)
|
|
31
|
+
document.addEventListener('content-loaded', () => this.setupListeners());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setupListeners() {
|
|
35
|
+
// Re-find the theme toggle button (it might be replaced after navigation)
|
|
36
|
+
const themeToggle = document.getElementById('theme-toggle');
|
|
37
|
+
|
|
38
|
+
if (themeToggle) {
|
|
39
|
+
// Remove old listener if button was replaced
|
|
40
|
+
if (this.themeToggle && this.themeToggle !== themeToggle && this.themeToggleClickHandler) {
|
|
41
|
+
this.themeToggle.removeEventListener('click', this.themeToggleClickHandler);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Set up new listener if we don't have one yet or button changed
|
|
45
|
+
if (!this.themeToggle || this.themeToggle !== themeToggle || !this.themeToggleClickHandler) {
|
|
46
|
+
this.themeToggle = themeToggle;
|
|
47
|
+
this.themeToggleClickHandler = () => this.toggleTheme();
|
|
48
|
+
this.themeToggle.addEventListener('click', this.themeToggleClickHandler);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Update icon for current theme
|
|
52
|
+
this.updateThemeIcon(this.currentTheme);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Setup system theme listener (only once)
|
|
56
|
+
if (window.matchMedia && !this.mediaQueryListener) {
|
|
57
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
58
|
+
this.mediaQueryListener = (e) => {
|
|
59
|
+
if (!localStorage.getItem(STORAGE_KEYS.THEME)) {
|
|
60
|
+
const newTheme = e.matches ? THEME.DARK : THEME.LIGHT;
|
|
61
|
+
this.setTheme(newTheme);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Use addEventListener if available (modern), fallback to addListener
|
|
65
|
+
if (mediaQuery.addEventListener) {
|
|
66
|
+
mediaQuery.addEventListener('change', this.mediaQueryListener);
|
|
67
|
+
} else {
|
|
68
|
+
mediaQuery.addListener(this.mediaQueryListener);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
toggleTheme() {
|
|
74
|
+
const newTheme = this.currentTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK;
|
|
75
|
+
|
|
76
|
+
this.currentTheme = newTheme;
|
|
77
|
+
this.setTheme(newTheme);
|
|
78
|
+
localStorage.setItem(STORAGE_KEYS.THEME, newTheme);
|
|
79
|
+
|
|
80
|
+
if (typeof monaco !== 'undefined') {
|
|
81
|
+
const monacoTheme = newTheme === THEME.DARK ? 'vs-dark' : 'vs';
|
|
82
|
+
monaco.editor.setTheme(monacoTheme);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setTheme(theme) {
|
|
87
|
+
this.currentTheme = theme;
|
|
88
|
+
this.html.setAttribute('data-theme', theme);
|
|
89
|
+
this.updateThemeIcon(theme);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
updateThemeIcon(theme) {
|
|
93
|
+
const iconSvg = this.themeToggle?.querySelector('.theme-icon');
|
|
94
|
+
if (!iconSvg) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (theme === THEME.DARK) {
|
|
99
|
+
iconSvg.innerHTML = '<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13zM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.061zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061zM16 8a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8zM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8zm10.657-5.657a.75.75 0 0 1 0 1.061l-1.061 1.06a.75.75 0 1 1-1.06-1.06l1.06-1.06a.75.75 0 0 1 1.061 0zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0z"></path>';
|
|
100
|
+
} else {
|
|
101
|
+
iconSvg.innerHTML = '<path d="M9.598 1.591a.75.75 0 0 1 .785-.175 7 7 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.5 5.5 0 1 0 7.678-7.678z"></path>';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getCurrentTheme() {
|
|
106
|
+
return this.currentTheme;
|
|
107
|
+
}
|
|
108
|
+
}
|