gh-here 2.1.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/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 +423 -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,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
|
+
}
|