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.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Clipboard utilities
3
+ */
4
+
5
+ export async function copyToClipboard(text, button) {
6
+ try {
7
+ await navigator.clipboard.writeText(text);
8
+ showCopySuccess(button);
9
+ } catch (err) {
10
+ fallbackCopy(text, button);
11
+ }
12
+ }
13
+
14
+ function showCopySuccess(button) {
15
+ const originalIcon = button.innerHTML;
16
+ const checkIcon = '<svg class="quick-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>';
17
+
18
+ button.innerHTML = checkIcon;
19
+ button.style.color = '#28a745';
20
+
21
+ setTimeout(() => {
22
+ button.innerHTML = originalIcon;
23
+ button.style.color = '';
24
+ }, 1000);
25
+ }
26
+
27
+ function fallbackCopy(text, button) {
28
+ const textArea = document.createElement('textarea');
29
+ textArea.value = text;
30
+ textArea.style.position = 'fixed';
31
+ textArea.style.left = '-999999px';
32
+ textArea.style.top = '-999999px';
33
+ document.body.appendChild(textArea);
34
+ textArea.focus();
35
+ textArea.select();
36
+
37
+ try {
38
+ document.execCommand('copy');
39
+ showCopySuccess(button);
40
+ } catch (fallbackErr) {
41
+ console.error('Could not copy text: ', fallbackErr);
42
+ }
43
+
44
+ document.body.removeChild(textArea);
45
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Application constants and configuration
3
+ */
4
+
5
+ export const CONFIG = {
6
+ MONACO_CDN: 'https://unpkg.com/monaco-editor@0.45.0/min/vs',
7
+ MONACO_VERSION: '0.45.0',
8
+ EDITOR_HEIGHT: 600,
9
+ DEFAULT_PORT: 5555,
10
+ NOTIFICATION_DURATION: 4000
11
+ };
12
+
13
+ export const THEME = {
14
+ DARK: 'dark',
15
+ LIGHT: 'light'
16
+ };
17
+
18
+ export const STORAGE_KEYS = {
19
+ THEME: 'gh-here-theme',
20
+ DRAFT_PREFIX: 'gh-here-draft-'
21
+ };
22
+
23
+ export const EDITOR_OPTIONS = {
24
+ minimap: { enabled: false },
25
+ lineNumbers: 'on',
26
+ wordWrap: 'off',
27
+ scrollBeyondLastLine: false,
28
+ fontSize: 12,
29
+ lineHeight: 20,
30
+ fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
31
+ padding: { top: 16, bottom: 16 },
32
+ renderLineHighlight: 'line',
33
+ selectOnLineNumbers: true,
34
+ automaticLayout: true,
35
+ folding: true,
36
+ foldingHighlight: true,
37
+ foldingStrategy: 'auto',
38
+ showFoldingControls: 'mouseover',
39
+ bracketPairColorization: { enabled: true },
40
+ guides: {
41
+ bracketPairs: true,
42
+ indentation: true
43
+ }
44
+ };
45
+
46
+ export const KEYBOARD_SHORTCUTS = {
47
+ SEARCH: ['/', 's'],
48
+ THEME_TOGGLE: 't',
49
+ HELP: '?',
50
+ ESCAPE: 'Escape',
51
+ GO_UP: 'h',
52
+ REFRESH: 'r',
53
+ CREATE_FILE: 'c',
54
+ EDIT_FILE: 'e',
55
+ SHOW_DIFF: 'd',
56
+ TOGGLE_GITIGNORE: 'i',
57
+ NAV_DOWN: 'j',
58
+ NAV_UP: 'k',
59
+ OPEN: 'o'
60
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Draft management for file editing
3
+ */
4
+
5
+ import { STORAGE_KEYS } from './constants.js';
6
+
7
+ export const DraftManager = {
8
+ saveDraft(filePath, content) {
9
+ localStorage.setItem(`${STORAGE_KEYS.DRAFT_PREFIX}${filePath}`, content);
10
+ },
11
+
12
+ loadDraft(filePath) {
13
+ return localStorage.getItem(`${STORAGE_KEYS.DRAFT_PREFIX}${filePath}`);
14
+ },
15
+
16
+ clearDraft(filePath) {
17
+ localStorage.removeItem(`${STORAGE_KEYS.DRAFT_PREFIX}${filePath}`);
18
+ },
19
+
20
+ hasDraftChanges(filePath, originalContent) {
21
+ const draft = this.loadDraft(filePath);
22
+ return draft !== null && draft !== originalContent;
23
+ },
24
+
25
+ getAllDrafts() {
26
+ const drafts = {};
27
+ for (let i = 0; i < localStorage.length; i++) {
28
+ const key = localStorage.key(i);
29
+ if (key.startsWith(STORAGE_KEYS.DRAFT_PREFIX)) {
30
+ const filePath = key.replace(STORAGE_KEYS.DRAFT_PREFIX, '');
31
+ drafts[filePath] = localStorage.getItem(key);
32
+ }
33
+ }
34
+ return drafts;
35
+ }
36
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Monaco Editor management
3
+ */
4
+
5
+ import { CONFIG, EDITOR_OPTIONS } from './constants.js';
6
+ import { getLanguageFromExtension } from './utils.js';
7
+ import { DraftManager } from './draft-manager.js';
8
+ import { showDraftDialog } from './modal-manager.js';
9
+ import { showNotification } from './notification.js';
10
+
11
+ export class EditorManager {
12
+ constructor(theme) {
13
+ this.fileEditor = null;
14
+ this.newFileEditor = null;
15
+ this.theme = theme;
16
+ this.ready = false;
17
+ this.init();
18
+ }
19
+
20
+ init() {
21
+ if (typeof require === 'undefined') {
22
+ return;
23
+ }
24
+
25
+ require.config({ paths: { vs: CONFIG.MONACO_CDN } });
26
+
27
+ require(['vs/editor/editor.main'], () => {
28
+ self.MonacoEnvironment = {
29
+ getWorker: () => undefined
30
+ };
31
+
32
+ const monacoTheme = this.theme === 'dark' ? 'vs-dark' : 'vs';
33
+ monaco.editor.setTheme(monacoTheme);
34
+
35
+ this.initializeNewFileEditor();
36
+ this.ready = true;
37
+ window.monacoReady = true;
38
+ });
39
+ }
40
+
41
+ initializeNewFileEditor() {
42
+ const container = document.getElementById('new-file-content');
43
+ if (!container) {
44
+ return;
45
+ }
46
+
47
+ const monacoTheme = this.theme === 'dark' ? 'vs-dark' : 'vs';
48
+ this.newFileEditor = monaco.editor.create(container, {
49
+ ...EDITOR_OPTIONS,
50
+ value: '',
51
+ language: 'plaintext',
52
+ theme: monacoTheme
53
+ });
54
+ }
55
+
56
+ async createFileEditor(container, filePath, originalContent) {
57
+ if (!this.ready) {
58
+ await this.waitForReady();
59
+ }
60
+
61
+ const filename = filePath.split('/').pop() || 'file.txt';
62
+ const language = getLanguageFromExtension(filename);
63
+ const availableLanguages = monaco.languages.getLanguages().map(lang => lang.id);
64
+ const validLanguage = availableLanguages.includes(language) ? language : 'plaintext';
65
+ const monacoTheme = this.theme === 'dark' ? 'vs-dark' : 'vs';
66
+
67
+ let contentToLoad = originalContent;
68
+
69
+ if (DraftManager.hasDraftChanges(filePath, originalContent)) {
70
+ const draftChoice = await showDraftDialog(filePath);
71
+ if (draftChoice === 'load') {
72
+ contentToLoad = DraftManager.loadDraft(filePath);
73
+ } else if (draftChoice === 'discard') {
74
+ DraftManager.clearDraft(filePath);
75
+ }
76
+ } else {
77
+ const draft = DraftManager.loadDraft(filePath);
78
+ if (draft && draft === originalContent) {
79
+ DraftManager.clearDraft(filePath);
80
+ }
81
+ }
82
+
83
+ if (!this.fileEditor) {
84
+ this.fileEditor = monaco.editor.create(container, {
85
+ ...EDITOR_OPTIONS,
86
+ value: contentToLoad,
87
+ language: validLanguage,
88
+ theme: monacoTheme
89
+ });
90
+
91
+ this.fileEditor.onDidChangeModelContent(() => {
92
+ DraftManager.saveDraft(filePath, this.fileEditor.getValue());
93
+ });
94
+ } else {
95
+ this.fileEditor.setValue(contentToLoad);
96
+ const model = this.fileEditor.getModel();
97
+ if (model) {
98
+ monaco.editor.setModelLanguage(model, validLanguage);
99
+ }
100
+ }
101
+
102
+ setTimeout(() => this.fileEditor.layout(), 50);
103
+
104
+ return this.fileEditor;
105
+ }
106
+
107
+ waitForReady() {
108
+ return new Promise(resolve => {
109
+ const check = () => {
110
+ if (this.ready) {
111
+ resolve();
112
+ } else {
113
+ setTimeout(check, 100);
114
+ }
115
+ };
116
+ check();
117
+ });
118
+ }
119
+
120
+ updateLanguage(filename) {
121
+ if (this.newFileEditor) {
122
+ const language = getLanguageFromExtension(filename);
123
+ const model = this.newFileEditor.getModel();
124
+ if (model) {
125
+ monaco.editor.setModelLanguage(model, language);
126
+ }
127
+ }
128
+ }
129
+
130
+ toggleWordWrap(editor) {
131
+ if (!editor) {
132
+ return false;
133
+ }
134
+
135
+ const currentWrap = editor.getOption(monaco.editor.EditorOption.wordWrap);
136
+ const newWrap = currentWrap === 'off' ? 'on' : 'off';
137
+ editor.updateOptions({ wordWrap: newWrap });
138
+ return newWrap === 'on';
139
+ }
140
+
141
+ getValue(editorType = 'file') {
142
+ const editor = editorType === 'file' ? this.fileEditor : this.newFileEditor;
143
+ return editor ? editor.getValue() : '';
144
+ }
145
+
146
+ focus(editorType = 'file') {
147
+ const editor = editorType === 'file' ? this.fileEditor : this.newFileEditor;
148
+ if (editor) {
149
+ editor.focus();
150
+ }
151
+ }
152
+
153
+ layout(editorType = 'file') {
154
+ const editor = editorType === 'file' ? this.fileEditor : this.newFileEditor;
155
+ if (editor) {
156
+ editor.layout();
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * File tree navigation sidebar
3
+ */
4
+
5
+ import { PathUtils } from './utils.js';
6
+
7
+ export class FileTreeNavigator {
8
+ constructor() {
9
+ this.treeContainer = document.getElementById('file-tree');
10
+ this.expandedFolders = new Set(this.loadExpandedState());
11
+ this.currentPath = PathUtils.getCurrentPath();
12
+ this.abortController = null;
13
+ this.isInitialized = false;
14
+
15
+ // Only initialize if container exists (sidebar is only shown when not at root)
16
+ if (this.treeContainer) {
17
+ this.init();
18
+ }
19
+ // Silently skip if container doesn't exist (expected at root directory)
20
+ }
21
+
22
+ async init() {
23
+ if (this.isInitialized) {
24
+ // Prevent double initialization
25
+ return;
26
+ }
27
+
28
+ this.isInitialized = true;
29
+
30
+ if (!this.treeContainer) {
31
+ this.isInitialized = false;
32
+ return;
33
+ }
34
+
35
+ this.showLoadingSkeleton();
36
+
37
+ try {
38
+ await this.loadFileTree();
39
+ this.hideLoadingSkeleton();
40
+ this.setupEventListeners();
41
+ this.highlightCurrentPath();
42
+ } catch (error) {
43
+ console.error('FileTreeNavigator init failed:', error);
44
+ this.hideLoadingSkeleton();
45
+ this.isInitialized = false;
46
+ }
47
+ }
48
+
49
+ showLoadingSkeleton() {
50
+ this.treeContainer.innerHTML = `
51
+ <div class="tree-skeleton">
52
+ <div class="skeleton-item"></div>
53
+ <div class="skeleton-item"></div>
54
+ <div class="skeleton-item skeleton-indent"></div>
55
+ <div class="skeleton-item skeleton-indent"></div>
56
+ <div class="skeleton-item"></div>
57
+ <div class="skeleton-item skeleton-indent"></div>
58
+ <div class="skeleton-item"></div>
59
+ </div>
60
+ `;
61
+ }
62
+
63
+ hideLoadingSkeleton() {
64
+ const skeleton = this.treeContainer.querySelector('.tree-skeleton');
65
+ if (skeleton) {
66
+ skeleton.remove();
67
+ }
68
+ }
69
+
70
+ async loadFileTree() {
71
+ // Cancel any in-flight request
72
+ if (this.abortController) {
73
+ this.abortController.abort();
74
+ }
75
+
76
+ this.abortController = new AbortController();
77
+
78
+ try {
79
+ // Check cache first
80
+ const cached = this.getCachedTree();
81
+ if (cached) {
82
+ this.renderTree(cached, this.treeContainer);
83
+ this.restoreExpandedState();
84
+ this.notifySearchHandler();
85
+ }
86
+
87
+ // Get gitignore state from localStorage
88
+ const showGitignored = localStorage.getItem('gh-here-show-gitignored') === 'true';
89
+ const apiUrl = showGitignored ? '/api/file-tree?showGitignored=true' : '/api/file-tree';
90
+
91
+ // Fetch fresh data with abort signal
92
+ const response = await fetch(apiUrl, {
93
+ signal: this.abortController.signal
94
+ });
95
+
96
+ if (!response.ok) {
97
+ throw new Error(`HTTP ${response.status}`);
98
+ }
99
+
100
+ const data = await response.json();
101
+
102
+ if (data.success) {
103
+ this.cacheTree(data.tree);
104
+ if (!cached) {
105
+ this.renderTree(data.tree, this.treeContainer);
106
+ this.restoreExpandedState();
107
+ this.notifySearchHandler();
108
+ }
109
+ }
110
+ } catch (error) {
111
+ if (error.name === 'AbortError') {
112
+ // Request was cancelled, expected behavior
113
+ return;
114
+ }
115
+ console.error('Failed to load file tree:', error);
116
+ this.hideLoadingSkeleton();
117
+ } finally {
118
+ this.abortController = null;
119
+ }
120
+ }
121
+
122
+ notifySearchHandler() {
123
+ // Dispatch a custom event so the search handler can update its tree items
124
+ const event = new CustomEvent('filetree-loaded');
125
+ document.dispatchEvent(event);
126
+ }
127
+
128
+ getCachedTree() {
129
+ try {
130
+ const cached = sessionStorage.getItem('gh-here-file-tree');
131
+ if (cached) {
132
+ return JSON.parse(cached);
133
+ }
134
+ } catch {
135
+ return null;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ cacheTree(tree) {
141
+ try {
142
+ sessionStorage.setItem('gh-here-file-tree', JSON.stringify(tree));
143
+ } catch {
144
+ // Ignore storage errors
145
+ }
146
+ }
147
+
148
+ renderTree(items, container, level = 0) {
149
+ items.forEach(item => {
150
+ const itemEl = this.createTreeItem(item, level);
151
+ container.appendChild(itemEl);
152
+
153
+ if (item.isDirectory && item.children) {
154
+ const childContainer = document.createElement('div');
155
+ childContainer.className = 'tree-children';
156
+ childContainer.dataset.path = item.path;
157
+
158
+ if (!this.expandedFolders.has(item.path)) {
159
+ childContainer.style.display = 'none';
160
+ }
161
+
162
+ this.renderTree(item.children, childContainer, level + 1);
163
+ container.appendChild(childContainer);
164
+ }
165
+ });
166
+ }
167
+
168
+ createTreeItem(item, level) {
169
+ const itemEl = document.createElement('div');
170
+ itemEl.className = 'tree-item';
171
+ itemEl.dataset.path = item.path;
172
+ itemEl.dataset.isDirectory = item.isDirectory;
173
+ itemEl.style.paddingLeft = `${level * 12 + 8}px`;
174
+
175
+ if (item.isDirectory) {
176
+ const isExpanded = this.expandedFolders.has(item.path);
177
+ itemEl.innerHTML = `
178
+ <span class="tree-toggle">${isExpanded ? '▼' : '▶'}</span>
179
+ <svg class="tree-icon" viewBox="0 0 16 16" width="16" height="16">
180
+ <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>
181
+ </svg>
182
+ <span class="tree-label">${item.name}</span>
183
+ `;
184
+ } else {
185
+ itemEl.innerHTML = `
186
+ <span class="tree-spacer"></span>
187
+ <svg class="tree-icon file-icon" viewBox="0 0 16 16" width="16" height="16">
188
+ <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>
189
+ </svg>
190
+ <span class="tree-label">${item.name}</span>
191
+ `;
192
+ }
193
+
194
+ return itemEl;
195
+ }
196
+
197
+ setupEventListeners() {
198
+ // Remove existing listener to prevent duplicates
199
+ if (this.treeClickHandler) {
200
+ this.treeContainer.removeEventListener('click', this.treeClickHandler);
201
+ }
202
+
203
+ this.treeClickHandler = (e) => {
204
+ const treeItem = e.target.closest('.tree-item');
205
+ if (!treeItem) {
206
+ return;
207
+ }
208
+
209
+ const isDirectory = treeItem.dataset.isDirectory === 'true';
210
+ const path = treeItem.dataset.path;
211
+
212
+ if (e.target.closest('.tree-toggle') && isDirectory) {
213
+ e.preventDefault();
214
+ e.stopPropagation();
215
+ this.toggleFolder(path);
216
+ } else {
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ const detail = { path, isDirectory };
220
+ document.dispatchEvent(new CustomEvent('navigate', { detail }));
221
+ }
222
+ };
223
+
224
+ this.treeContainer.addEventListener('click', this.treeClickHandler);
225
+ }
226
+
227
+ toggleFolder(path) {
228
+ const childContainer = this.treeContainer.querySelector(
229
+ `.tree-children[data-path="${path}"]`
230
+ );
231
+ const toggleIcon = this.treeContainer.querySelector(
232
+ `.tree-item[data-path="${path}"] .tree-toggle`
233
+ );
234
+
235
+ if (!childContainer || !toggleIcon) {
236
+ return;
237
+ }
238
+
239
+ const isExpanded = childContainer.style.display !== 'none';
240
+
241
+ if (isExpanded) {
242
+ childContainer.style.display = 'none';
243
+ toggleIcon.textContent = '▶';
244
+ this.expandedFolders.delete(path);
245
+ } else {
246
+ childContainer.style.display = 'block';
247
+ toggleIcon.textContent = '▼';
248
+ this.expandedFolders.add(path);
249
+ }
250
+
251
+ this.saveExpandedState();
252
+ }
253
+
254
+ highlightCurrentPath() {
255
+ const currentItems = this.treeContainer.querySelectorAll('.tree-item');
256
+ currentItems.forEach(item => {
257
+ item.classList.remove('active');
258
+ if (item.dataset.path === this.currentPath) {
259
+ item.classList.add('active');
260
+ this.expandParentFolders(this.currentPath);
261
+ }
262
+ });
263
+ }
264
+
265
+ expandParentFolders(path) {
266
+ const parts = path.split('/').filter(p => p);
267
+ let currentPath = '';
268
+
269
+ parts.forEach((part, index) => {
270
+ if (index < parts.length - 1) {
271
+ currentPath += (currentPath ? '/' : '') + part;
272
+ const childContainer = this.treeContainer.querySelector(
273
+ `.tree-children[data-path="${currentPath}"]`
274
+ );
275
+ const toggleIcon = this.treeContainer.querySelector(
276
+ `.tree-item[data-path="${currentPath}"] .tree-toggle`
277
+ );
278
+
279
+ if (childContainer && toggleIcon) {
280
+ childContainer.style.display = 'block';
281
+ toggleIcon.textContent = '▼';
282
+ this.expandedFolders.add(currentPath);
283
+ }
284
+ }
285
+ });
286
+
287
+ this.saveExpandedState();
288
+ }
289
+
290
+ restoreExpandedState() {
291
+ this.expandedFolders.forEach(path => {
292
+ const childContainer = this.treeContainer.querySelector(
293
+ `.tree-children[data-path="${path}"]`
294
+ );
295
+ const toggleIcon = this.treeContainer.querySelector(
296
+ `.tree-item[data-path="${path}"] .tree-toggle`
297
+ );
298
+
299
+ if (childContainer && toggleIcon) {
300
+ childContainer.style.display = 'block';
301
+ toggleIcon.textContent = '▼';
302
+ }
303
+ });
304
+ }
305
+
306
+ saveExpandedState() {
307
+ localStorage.setItem(
308
+ 'gh-here-expanded-folders',
309
+ JSON.stringify([...this.expandedFolders])
310
+ );
311
+ }
312
+
313
+ loadExpandedState() {
314
+ try {
315
+ const saved = localStorage.getItem('gh-here-expanded-folders');
316
+ return saved ? JSON.parse(saved) : [];
317
+ } catch {
318
+ return [];
319
+ }
320
+ }
321
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Minimal keyboard shortcuts
3
+ * - Escape: Close/clear search
4
+ * - Ctrl/Cmd + K: Focus search
5
+ */
6
+
7
+ export class KeyboardHandler {
8
+ constructor(searchHandler) {
9
+ this.searchHandler = searchHandler;
10
+ this.setupListeners();
11
+ }
12
+
13
+ setupListeners() {
14
+ document.addEventListener('keydown', e => this.handleGlobalKeydown(e));
15
+ }
16
+
17
+ handleGlobalKeydown(e) {
18
+ const searchActive = this.searchHandler?.searchInput &&
19
+ document.activeElement === this.searchHandler.searchInput;
20
+
21
+ // Escape: Clear and close search
22
+ if (e.key === 'Escape' && searchActive && this.searchHandler.searchInput) {
23
+ this.searchHandler.searchInput.blur();
24
+ this.searchHandler.searchInput.value = '';
25
+ this.searchHandler.hideResults();
26
+ if (this.searchHandler.isFileViewContext) {
27
+ this.searchHandler.clearTreeFilter();
28
+ }
29
+ return;
30
+ }
31
+
32
+ // Ctrl/Cmd + K: Focus search
33
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
34
+ e.preventDefault();
35
+ if (this.searchHandler) {
36
+ this.searchHandler.focusSearch();
37
+ }
38
+ return;
39
+ }
40
+ }
41
+ }