@agent-link/server 0.1.124 → 0.1.126
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/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +54 -54
- package/web/app.js +1192 -1192
- package/web/favicon.svg +10 -10
- package/web/landing.html +1262 -1241
- package/web/landing.zh.html +1261 -0
- package/web/modules/connection.js +880 -880
- package/web/modules/fileBrowser.js +379 -379
- package/web/modules/filePreview.js +187 -187
- package/web/modules/sidebar.js +376 -376
- package/web/modules/streaming.js +110 -110
- package/web/style.css +2941 -2941
|
@@ -1,379 +1,379 @@
|
|
|
1
|
-
// ── File Browser: tree state, lazy loading, context menu, file actions ────────
|
|
2
|
-
const { computed, nextTick } = Vue;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Creates the file browser controller.
|
|
6
|
-
* @param {object} deps - Reactive state and callbacks
|
|
7
|
-
*/
|
|
8
|
-
export function createFileBrowser(deps) {
|
|
9
|
-
const {
|
|
10
|
-
wsSend, workDir, inputText, inputRef,
|
|
11
|
-
filePanelOpen, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
12
|
-
sidebarOpen, sidebarView, filePanelWidth,
|
|
13
|
-
} = deps;
|
|
14
|
-
|
|
15
|
-
// Map of dirPath → TreeNode awaiting directory_listing response
|
|
16
|
-
const pendingRequests = new Map();
|
|
17
|
-
|
|
18
|
-
// ── Tree helpers ──
|
|
19
|
-
|
|
20
|
-
function buildPath(parentPath, name) {
|
|
21
|
-
if (!parentPath) return name;
|
|
22
|
-
const sep = parentPath.includes('\\') ? '\\' : '/';
|
|
23
|
-
return parentPath.replace(/[/\\]$/, '') + sep + name;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function makeNode(entry, parentPath) {
|
|
27
|
-
return {
|
|
28
|
-
path: buildPath(parentPath, entry.name),
|
|
29
|
-
name: entry.name,
|
|
30
|
-
type: entry.type,
|
|
31
|
-
expanded: false,
|
|
32
|
-
children: entry.type === 'directory' ? null : undefined,
|
|
33
|
-
loading: false,
|
|
34
|
-
error: null,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── Flattened tree for rendering ──
|
|
39
|
-
|
|
40
|
-
const flattenedTree = computed(() => {
|
|
41
|
-
const root = fileTreeRoot.value;
|
|
42
|
-
if (!root || !root.children) return [];
|
|
43
|
-
const result = [];
|
|
44
|
-
function walk(children, depth) {
|
|
45
|
-
for (const node of children) {
|
|
46
|
-
result.push({ node, depth });
|
|
47
|
-
if (node.type === 'directory' && node.expanded && node.children) {
|
|
48
|
-
walk(node.children, depth + 1);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
walk(root.children, 0);
|
|
53
|
-
return result;
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ── Panel open/close ──
|
|
57
|
-
|
|
58
|
-
function openPanel() {
|
|
59
|
-
filePanelOpen.value = true;
|
|
60
|
-
if (!fileTreeRoot.value || fileTreeRoot.value.path !== workDir.value) {
|
|
61
|
-
loadRoot();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function closePanel() {
|
|
66
|
-
filePanelOpen.value = false;
|
|
67
|
-
closeContextMenu();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function togglePanel() {
|
|
71
|
-
if (filePanelOpen.value) {
|
|
72
|
-
closePanel();
|
|
73
|
-
} else {
|
|
74
|
-
openPanel();
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ── Loading ──
|
|
79
|
-
|
|
80
|
-
function loadRoot() {
|
|
81
|
-
const dir = workDir.value;
|
|
82
|
-
if (!dir) return;
|
|
83
|
-
fileTreeLoading.value = true;
|
|
84
|
-
fileTreeRoot.value = {
|
|
85
|
-
path: dir,
|
|
86
|
-
name: dir,
|
|
87
|
-
type: 'directory',
|
|
88
|
-
expanded: true,
|
|
89
|
-
children: null,
|
|
90
|
-
loading: true,
|
|
91
|
-
error: null,
|
|
92
|
-
};
|
|
93
|
-
pendingRequests.set(dir, fileTreeRoot.value);
|
|
94
|
-
wsSend({ type: 'list_directory', dirPath: dir, source: 'file_browser' });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function loadDirectory(node) {
|
|
98
|
-
if (node.loading) return;
|
|
99
|
-
node.loading = true;
|
|
100
|
-
node.error = null;
|
|
101
|
-
pendingRequests.set(node.path, node);
|
|
102
|
-
wsSend({ type: 'list_directory', dirPath: node.path, source: 'file_browser' });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── Folder expand/collapse ──
|
|
106
|
-
|
|
107
|
-
function toggleFolder(node) {
|
|
108
|
-
if (node.type !== 'directory') return;
|
|
109
|
-
if (node.expanded) {
|
|
110
|
-
node.expanded = false;
|
|
111
|
-
closeContextMenu();
|
|
112
|
-
} else {
|
|
113
|
-
node.expanded = true;
|
|
114
|
-
if (node.children === null) {
|
|
115
|
-
loadDirectory(node);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ── Handle directory_listing response ──
|
|
121
|
-
|
|
122
|
-
function handleDirectoryListing(msg) {
|
|
123
|
-
const dirPath = msg.dirPath;
|
|
124
|
-
const node = pendingRequests.get(dirPath);
|
|
125
|
-
pendingRequests.delete(dirPath);
|
|
126
|
-
|
|
127
|
-
// Check if this is the root loading
|
|
128
|
-
if (fileTreeRoot.value && fileTreeRoot.value.path === dirPath) {
|
|
129
|
-
fileTreeLoading.value = false;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (!node) {
|
|
133
|
-
// No pending request for this path — could be a stale response after
|
|
134
|
-
// workdir change. Try to find the node in the tree by path.
|
|
135
|
-
const found = findNodeByPath(dirPath);
|
|
136
|
-
if (found) {
|
|
137
|
-
applyListing(found, msg);
|
|
138
|
-
}
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
applyListing(node, msg);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function applyListing(node, msg) {
|
|
146
|
-
node.loading = false;
|
|
147
|
-
if (msg.error) {
|
|
148
|
-
node.error = msg.error;
|
|
149
|
-
node.children = [];
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const entries = msg.entries || [];
|
|
153
|
-
node.children = entries.map(e => makeNode(e, node.path));
|
|
154
|
-
node.expanded = true;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function findNodeByPath(targetPath) {
|
|
158
|
-
const root = fileTreeRoot.value;
|
|
159
|
-
if (!root) return null;
|
|
160
|
-
if (root.path === targetPath) return root;
|
|
161
|
-
if (!root.children) return null;
|
|
162
|
-
function search(children) {
|
|
163
|
-
for (const node of children) {
|
|
164
|
-
if (node.path === targetPath) return node;
|
|
165
|
-
if (node.type === 'directory' && node.children) {
|
|
166
|
-
const found = search(node.children);
|
|
167
|
-
if (found) return found;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
return search(root.children);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// ── Refresh ──
|
|
176
|
-
|
|
177
|
-
function refreshTree() {
|
|
178
|
-
if (!fileTreeRoot.value) {
|
|
179
|
-
loadRoot();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
// Re-fetch all expanded directories
|
|
183
|
-
refreshNode(fileTreeRoot.value);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function refreshNode(node) {
|
|
187
|
-
if (node.type !== 'directory') return;
|
|
188
|
-
if (node.expanded && node.children !== null) {
|
|
189
|
-
// Re-fetch this directory
|
|
190
|
-
node.children = null;
|
|
191
|
-
loadDirectory(node);
|
|
192
|
-
}
|
|
193
|
-
// Note: children of this node will be re-fetched when loadDirectory
|
|
194
|
-
// completes and rebuilds the children array. No need to recurse.
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ── Workdir changed ──
|
|
198
|
-
|
|
199
|
-
function onWorkdirChanged() {
|
|
200
|
-
pendingRequests.clear();
|
|
201
|
-
fileTreeRoot.value = null;
|
|
202
|
-
fileTreeLoading.value = false;
|
|
203
|
-
closeContextMenu();
|
|
204
|
-
if (filePanelOpen.value) {
|
|
205
|
-
loadRoot();
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ── Context menu ──
|
|
210
|
-
|
|
211
|
-
function onFileClick(event, node) {
|
|
212
|
-
if (node.type === 'directory') return;
|
|
213
|
-
event.stopPropagation();
|
|
214
|
-
|
|
215
|
-
// Position the menu near the click, adjusting for viewport edges
|
|
216
|
-
let x = event.clientX;
|
|
217
|
-
let y = event.clientY;
|
|
218
|
-
const menuWidth = 220;
|
|
219
|
-
const menuHeight = 120; // approx 3 items
|
|
220
|
-
if (x + menuWidth > window.innerWidth) {
|
|
221
|
-
x = window.innerWidth - menuWidth - 8;
|
|
222
|
-
}
|
|
223
|
-
if (y + menuHeight > window.innerHeight) {
|
|
224
|
-
y = y - menuHeight;
|
|
225
|
-
}
|
|
226
|
-
if (x < 0) x = 8;
|
|
227
|
-
if (y < 0) y = 8;
|
|
228
|
-
|
|
229
|
-
fileContextMenu.value = {
|
|
230
|
-
x,
|
|
231
|
-
y,
|
|
232
|
-
path: node.path,
|
|
233
|
-
name: node.name,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function closeContextMenu() {
|
|
238
|
-
fileContextMenu.value = null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ── File actions ──
|
|
242
|
-
|
|
243
|
-
function askClaudeRead() {
|
|
244
|
-
const menu = fileContextMenu.value;
|
|
245
|
-
if (!menu) return;
|
|
246
|
-
const path = menu.path;
|
|
247
|
-
closeContextMenu();
|
|
248
|
-
inputText.value = `Read the file ${path}`;
|
|
249
|
-
// Close sidebar on mobile
|
|
250
|
-
if (window.innerWidth <= 768) {
|
|
251
|
-
sidebarOpen.value = false;
|
|
252
|
-
sidebarView.value = 'sessions';
|
|
253
|
-
}
|
|
254
|
-
nextTick(() => {
|
|
255
|
-
if (inputRef.value) inputRef.value.focus();
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function copyPath() {
|
|
260
|
-
const menu = fileContextMenu.value;
|
|
261
|
-
if (!menu) return;
|
|
262
|
-
const path = menu.path;
|
|
263
|
-
navigator.clipboard.writeText(path).catch(() => {
|
|
264
|
-
// Fallback: some browsers block clipboard in non-secure contexts
|
|
265
|
-
});
|
|
266
|
-
// Brief "Copied!" feedback — store temporarily in menu state
|
|
267
|
-
fileContextMenu.value = { ...menu, copied: true };
|
|
268
|
-
setTimeout(() => {
|
|
269
|
-
closeContextMenu();
|
|
270
|
-
}, 1000);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function insertPath() {
|
|
274
|
-
const menu = fileContextMenu.value;
|
|
275
|
-
if (!menu) return;
|
|
276
|
-
const path = menu.path;
|
|
277
|
-
closeContextMenu();
|
|
278
|
-
const textarea = inputRef.value;
|
|
279
|
-
if (textarea) {
|
|
280
|
-
const start = textarea.selectionStart || inputText.value.length;
|
|
281
|
-
const end = textarea.selectionEnd || inputText.value.length;
|
|
282
|
-
const text = inputText.value;
|
|
283
|
-
inputText.value = text.slice(0, start) + path + text.slice(end);
|
|
284
|
-
nextTick(() => {
|
|
285
|
-
const newPos = start + path.length;
|
|
286
|
-
textarea.setSelectionRange(newPos, newPos);
|
|
287
|
-
textarea.focus();
|
|
288
|
-
});
|
|
289
|
-
} else {
|
|
290
|
-
inputText.value += path;
|
|
291
|
-
}
|
|
292
|
-
// Close sidebar on mobile
|
|
293
|
-
if (window.innerWidth <= 768) {
|
|
294
|
-
sidebarOpen.value = false;
|
|
295
|
-
sidebarView.value = 'sessions';
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ── Global click handler for dismissing context menu ──
|
|
300
|
-
|
|
301
|
-
function setupGlobalListeners() {
|
|
302
|
-
document.addEventListener('click', (e) => {
|
|
303
|
-
if (!fileContextMenu.value) return;
|
|
304
|
-
const menuEl = document.querySelector('.file-context-menu');
|
|
305
|
-
if (menuEl && menuEl.contains(e.target)) return;
|
|
306
|
-
closeContextMenu();
|
|
307
|
-
});
|
|
308
|
-
document.addEventListener('keydown', (e) => {
|
|
309
|
-
if (e.key === 'Escape' && fileContextMenu.value) {
|
|
310
|
-
closeContextMenu();
|
|
311
|
-
}
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ── Resize handle (mouse + touch) ──
|
|
316
|
-
|
|
317
|
-
let _resizing = false;
|
|
318
|
-
let _startX = 0;
|
|
319
|
-
let _startWidth = 0;
|
|
320
|
-
const MIN_WIDTH = 160;
|
|
321
|
-
const MAX_WIDTH = 600;
|
|
322
|
-
|
|
323
|
-
function onResizeStart(e) {
|
|
324
|
-
e.preventDefault();
|
|
325
|
-
_resizing = true;
|
|
326
|
-
_startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
|
327
|
-
_startWidth = filePanelWidth.value;
|
|
328
|
-
document.body.style.cursor = 'col-resize';
|
|
329
|
-
document.body.style.userSelect = 'none';
|
|
330
|
-
if (e.type === 'touchstart') {
|
|
331
|
-
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
|
332
|
-
document.addEventListener('touchend', onResizeEnd);
|
|
333
|
-
} else {
|
|
334
|
-
document.addEventListener('mousemove', onResizeMove);
|
|
335
|
-
document.addEventListener('mouseup', onResizeEnd);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function onResizeMove(e) {
|
|
340
|
-
if (!_resizing) return;
|
|
341
|
-
if (e.type === 'touchmove') e.preventDefault();
|
|
342
|
-
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
|
343
|
-
const delta = clientX - _startX;
|
|
344
|
-
const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, _startWidth + delta));
|
|
345
|
-
filePanelWidth.value = newWidth;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function onResizeEnd() {
|
|
349
|
-
if (!_resizing) return;
|
|
350
|
-
_resizing = false;
|
|
351
|
-
document.body.style.cursor = '';
|
|
352
|
-
document.body.style.userSelect = '';
|
|
353
|
-
document.removeEventListener('mousemove', onResizeMove);
|
|
354
|
-
document.removeEventListener('mouseup', onResizeEnd);
|
|
355
|
-
document.removeEventListener('touchmove', onResizeMove);
|
|
356
|
-
document.removeEventListener('touchend', onResizeEnd);
|
|
357
|
-
localStorage.setItem('agentlink-file-panel-width', String(filePanelWidth.value));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Set up listeners immediately
|
|
361
|
-
setupGlobalListeners();
|
|
362
|
-
|
|
363
|
-
return {
|
|
364
|
-
openPanel,
|
|
365
|
-
closePanel,
|
|
366
|
-
togglePanel,
|
|
367
|
-
toggleFolder,
|
|
368
|
-
onFileClick,
|
|
369
|
-
closeContextMenu,
|
|
370
|
-
askClaudeRead,
|
|
371
|
-
copyPath,
|
|
372
|
-
insertPath,
|
|
373
|
-
refreshTree,
|
|
374
|
-
handleDirectoryListing,
|
|
375
|
-
onWorkdirChanged,
|
|
376
|
-
flattenedTree,
|
|
377
|
-
onResizeStart,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
1
|
+
// ── File Browser: tree state, lazy loading, context menu, file actions ────────
|
|
2
|
+
const { computed, nextTick } = Vue;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the file browser controller.
|
|
6
|
+
* @param {object} deps - Reactive state and callbacks
|
|
7
|
+
*/
|
|
8
|
+
export function createFileBrowser(deps) {
|
|
9
|
+
const {
|
|
10
|
+
wsSend, workDir, inputText, inputRef,
|
|
11
|
+
filePanelOpen, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
12
|
+
sidebarOpen, sidebarView, filePanelWidth,
|
|
13
|
+
} = deps;
|
|
14
|
+
|
|
15
|
+
// Map of dirPath → TreeNode awaiting directory_listing response
|
|
16
|
+
const pendingRequests = new Map();
|
|
17
|
+
|
|
18
|
+
// ── Tree helpers ──
|
|
19
|
+
|
|
20
|
+
function buildPath(parentPath, name) {
|
|
21
|
+
if (!parentPath) return name;
|
|
22
|
+
const sep = parentPath.includes('\\') ? '\\' : '/';
|
|
23
|
+
return parentPath.replace(/[/\\]$/, '') + sep + name;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeNode(entry, parentPath) {
|
|
27
|
+
return {
|
|
28
|
+
path: buildPath(parentPath, entry.name),
|
|
29
|
+
name: entry.name,
|
|
30
|
+
type: entry.type,
|
|
31
|
+
expanded: false,
|
|
32
|
+
children: entry.type === 'directory' ? null : undefined,
|
|
33
|
+
loading: false,
|
|
34
|
+
error: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Flattened tree for rendering ──
|
|
39
|
+
|
|
40
|
+
const flattenedTree = computed(() => {
|
|
41
|
+
const root = fileTreeRoot.value;
|
|
42
|
+
if (!root || !root.children) return [];
|
|
43
|
+
const result = [];
|
|
44
|
+
function walk(children, depth) {
|
|
45
|
+
for (const node of children) {
|
|
46
|
+
result.push({ node, depth });
|
|
47
|
+
if (node.type === 'directory' && node.expanded && node.children) {
|
|
48
|
+
walk(node.children, depth + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
walk(root.children, 0);
|
|
53
|
+
return result;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Panel open/close ──
|
|
57
|
+
|
|
58
|
+
function openPanel() {
|
|
59
|
+
filePanelOpen.value = true;
|
|
60
|
+
if (!fileTreeRoot.value || fileTreeRoot.value.path !== workDir.value) {
|
|
61
|
+
loadRoot();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function closePanel() {
|
|
66
|
+
filePanelOpen.value = false;
|
|
67
|
+
closeContextMenu();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function togglePanel() {
|
|
71
|
+
if (filePanelOpen.value) {
|
|
72
|
+
closePanel();
|
|
73
|
+
} else {
|
|
74
|
+
openPanel();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Loading ──
|
|
79
|
+
|
|
80
|
+
function loadRoot() {
|
|
81
|
+
const dir = workDir.value;
|
|
82
|
+
if (!dir) return;
|
|
83
|
+
fileTreeLoading.value = true;
|
|
84
|
+
fileTreeRoot.value = {
|
|
85
|
+
path: dir,
|
|
86
|
+
name: dir,
|
|
87
|
+
type: 'directory',
|
|
88
|
+
expanded: true,
|
|
89
|
+
children: null,
|
|
90
|
+
loading: true,
|
|
91
|
+
error: null,
|
|
92
|
+
};
|
|
93
|
+
pendingRequests.set(dir, fileTreeRoot.value);
|
|
94
|
+
wsSend({ type: 'list_directory', dirPath: dir, source: 'file_browser' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadDirectory(node) {
|
|
98
|
+
if (node.loading) return;
|
|
99
|
+
node.loading = true;
|
|
100
|
+
node.error = null;
|
|
101
|
+
pendingRequests.set(node.path, node);
|
|
102
|
+
wsSend({ type: 'list_directory', dirPath: node.path, source: 'file_browser' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Folder expand/collapse ──
|
|
106
|
+
|
|
107
|
+
function toggleFolder(node) {
|
|
108
|
+
if (node.type !== 'directory') return;
|
|
109
|
+
if (node.expanded) {
|
|
110
|
+
node.expanded = false;
|
|
111
|
+
closeContextMenu();
|
|
112
|
+
} else {
|
|
113
|
+
node.expanded = true;
|
|
114
|
+
if (node.children === null) {
|
|
115
|
+
loadDirectory(node);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Handle directory_listing response ──
|
|
121
|
+
|
|
122
|
+
function handleDirectoryListing(msg) {
|
|
123
|
+
const dirPath = msg.dirPath;
|
|
124
|
+
const node = pendingRequests.get(dirPath);
|
|
125
|
+
pendingRequests.delete(dirPath);
|
|
126
|
+
|
|
127
|
+
// Check if this is the root loading
|
|
128
|
+
if (fileTreeRoot.value && fileTreeRoot.value.path === dirPath) {
|
|
129
|
+
fileTreeLoading.value = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!node) {
|
|
133
|
+
// No pending request for this path — could be a stale response after
|
|
134
|
+
// workdir change. Try to find the node in the tree by path.
|
|
135
|
+
const found = findNodeByPath(dirPath);
|
|
136
|
+
if (found) {
|
|
137
|
+
applyListing(found, msg);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
applyListing(node, msg);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyListing(node, msg) {
|
|
146
|
+
node.loading = false;
|
|
147
|
+
if (msg.error) {
|
|
148
|
+
node.error = msg.error;
|
|
149
|
+
node.children = [];
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const entries = msg.entries || [];
|
|
153
|
+
node.children = entries.map(e => makeNode(e, node.path));
|
|
154
|
+
node.expanded = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findNodeByPath(targetPath) {
|
|
158
|
+
const root = fileTreeRoot.value;
|
|
159
|
+
if (!root) return null;
|
|
160
|
+
if (root.path === targetPath) return root;
|
|
161
|
+
if (!root.children) return null;
|
|
162
|
+
function search(children) {
|
|
163
|
+
for (const node of children) {
|
|
164
|
+
if (node.path === targetPath) return node;
|
|
165
|
+
if (node.type === 'directory' && node.children) {
|
|
166
|
+
const found = search(node.children);
|
|
167
|
+
if (found) return found;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return search(root.children);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Refresh ──
|
|
176
|
+
|
|
177
|
+
function refreshTree() {
|
|
178
|
+
if (!fileTreeRoot.value) {
|
|
179
|
+
loadRoot();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Re-fetch all expanded directories
|
|
183
|
+
refreshNode(fileTreeRoot.value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function refreshNode(node) {
|
|
187
|
+
if (node.type !== 'directory') return;
|
|
188
|
+
if (node.expanded && node.children !== null) {
|
|
189
|
+
// Re-fetch this directory
|
|
190
|
+
node.children = null;
|
|
191
|
+
loadDirectory(node);
|
|
192
|
+
}
|
|
193
|
+
// Note: children of this node will be re-fetched when loadDirectory
|
|
194
|
+
// completes and rebuilds the children array. No need to recurse.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Workdir changed ──
|
|
198
|
+
|
|
199
|
+
function onWorkdirChanged() {
|
|
200
|
+
pendingRequests.clear();
|
|
201
|
+
fileTreeRoot.value = null;
|
|
202
|
+
fileTreeLoading.value = false;
|
|
203
|
+
closeContextMenu();
|
|
204
|
+
if (filePanelOpen.value) {
|
|
205
|
+
loadRoot();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Context menu ──
|
|
210
|
+
|
|
211
|
+
function onFileClick(event, node) {
|
|
212
|
+
if (node.type === 'directory') return;
|
|
213
|
+
event.stopPropagation();
|
|
214
|
+
|
|
215
|
+
// Position the menu near the click, adjusting for viewport edges
|
|
216
|
+
let x = event.clientX;
|
|
217
|
+
let y = event.clientY;
|
|
218
|
+
const menuWidth = 220;
|
|
219
|
+
const menuHeight = 120; // approx 3 items
|
|
220
|
+
if (x + menuWidth > window.innerWidth) {
|
|
221
|
+
x = window.innerWidth - menuWidth - 8;
|
|
222
|
+
}
|
|
223
|
+
if (y + menuHeight > window.innerHeight) {
|
|
224
|
+
y = y - menuHeight;
|
|
225
|
+
}
|
|
226
|
+
if (x < 0) x = 8;
|
|
227
|
+
if (y < 0) y = 8;
|
|
228
|
+
|
|
229
|
+
fileContextMenu.value = {
|
|
230
|
+
x,
|
|
231
|
+
y,
|
|
232
|
+
path: node.path,
|
|
233
|
+
name: node.name,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function closeContextMenu() {
|
|
238
|
+
fileContextMenu.value = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── File actions ──
|
|
242
|
+
|
|
243
|
+
function askClaudeRead() {
|
|
244
|
+
const menu = fileContextMenu.value;
|
|
245
|
+
if (!menu) return;
|
|
246
|
+
const path = menu.path;
|
|
247
|
+
closeContextMenu();
|
|
248
|
+
inputText.value = `Read the file ${path}`;
|
|
249
|
+
// Close sidebar on mobile
|
|
250
|
+
if (window.innerWidth <= 768) {
|
|
251
|
+
sidebarOpen.value = false;
|
|
252
|
+
sidebarView.value = 'sessions';
|
|
253
|
+
}
|
|
254
|
+
nextTick(() => {
|
|
255
|
+
if (inputRef.value) inputRef.value.focus();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function copyPath() {
|
|
260
|
+
const menu = fileContextMenu.value;
|
|
261
|
+
if (!menu) return;
|
|
262
|
+
const path = menu.path;
|
|
263
|
+
navigator.clipboard.writeText(path).catch(() => {
|
|
264
|
+
// Fallback: some browsers block clipboard in non-secure contexts
|
|
265
|
+
});
|
|
266
|
+
// Brief "Copied!" feedback — store temporarily in menu state
|
|
267
|
+
fileContextMenu.value = { ...menu, copied: true };
|
|
268
|
+
setTimeout(() => {
|
|
269
|
+
closeContextMenu();
|
|
270
|
+
}, 1000);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function insertPath() {
|
|
274
|
+
const menu = fileContextMenu.value;
|
|
275
|
+
if (!menu) return;
|
|
276
|
+
const path = menu.path;
|
|
277
|
+
closeContextMenu();
|
|
278
|
+
const textarea = inputRef.value;
|
|
279
|
+
if (textarea) {
|
|
280
|
+
const start = textarea.selectionStart || inputText.value.length;
|
|
281
|
+
const end = textarea.selectionEnd || inputText.value.length;
|
|
282
|
+
const text = inputText.value;
|
|
283
|
+
inputText.value = text.slice(0, start) + path + text.slice(end);
|
|
284
|
+
nextTick(() => {
|
|
285
|
+
const newPos = start + path.length;
|
|
286
|
+
textarea.setSelectionRange(newPos, newPos);
|
|
287
|
+
textarea.focus();
|
|
288
|
+
});
|
|
289
|
+
} else {
|
|
290
|
+
inputText.value += path;
|
|
291
|
+
}
|
|
292
|
+
// Close sidebar on mobile
|
|
293
|
+
if (window.innerWidth <= 768) {
|
|
294
|
+
sidebarOpen.value = false;
|
|
295
|
+
sidebarView.value = 'sessions';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Global click handler for dismissing context menu ──
|
|
300
|
+
|
|
301
|
+
function setupGlobalListeners() {
|
|
302
|
+
document.addEventListener('click', (e) => {
|
|
303
|
+
if (!fileContextMenu.value) return;
|
|
304
|
+
const menuEl = document.querySelector('.file-context-menu');
|
|
305
|
+
if (menuEl && menuEl.contains(e.target)) return;
|
|
306
|
+
closeContextMenu();
|
|
307
|
+
});
|
|
308
|
+
document.addEventListener('keydown', (e) => {
|
|
309
|
+
if (e.key === 'Escape' && fileContextMenu.value) {
|
|
310
|
+
closeContextMenu();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Resize handle (mouse + touch) ──
|
|
316
|
+
|
|
317
|
+
let _resizing = false;
|
|
318
|
+
let _startX = 0;
|
|
319
|
+
let _startWidth = 0;
|
|
320
|
+
const MIN_WIDTH = 160;
|
|
321
|
+
const MAX_WIDTH = 600;
|
|
322
|
+
|
|
323
|
+
function onResizeStart(e) {
|
|
324
|
+
e.preventDefault();
|
|
325
|
+
_resizing = true;
|
|
326
|
+
_startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
|
327
|
+
_startWidth = filePanelWidth.value;
|
|
328
|
+
document.body.style.cursor = 'col-resize';
|
|
329
|
+
document.body.style.userSelect = 'none';
|
|
330
|
+
if (e.type === 'touchstart') {
|
|
331
|
+
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
|
332
|
+
document.addEventListener('touchend', onResizeEnd);
|
|
333
|
+
} else {
|
|
334
|
+
document.addEventListener('mousemove', onResizeMove);
|
|
335
|
+
document.addEventListener('mouseup', onResizeEnd);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function onResizeMove(e) {
|
|
340
|
+
if (!_resizing) return;
|
|
341
|
+
if (e.type === 'touchmove') e.preventDefault();
|
|
342
|
+
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
|
343
|
+
const delta = clientX - _startX;
|
|
344
|
+
const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, _startWidth + delta));
|
|
345
|
+
filePanelWidth.value = newWidth;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function onResizeEnd() {
|
|
349
|
+
if (!_resizing) return;
|
|
350
|
+
_resizing = false;
|
|
351
|
+
document.body.style.cursor = '';
|
|
352
|
+
document.body.style.userSelect = '';
|
|
353
|
+
document.removeEventListener('mousemove', onResizeMove);
|
|
354
|
+
document.removeEventListener('mouseup', onResizeEnd);
|
|
355
|
+
document.removeEventListener('touchmove', onResizeMove);
|
|
356
|
+
document.removeEventListener('touchend', onResizeEnd);
|
|
357
|
+
localStorage.setItem('agentlink-file-panel-width', String(filePanelWidth.value));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Set up listeners immediately
|
|
361
|
+
setupGlobalListeners();
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
openPanel,
|
|
365
|
+
closePanel,
|
|
366
|
+
togglePanel,
|
|
367
|
+
toggleFolder,
|
|
368
|
+
onFileClick,
|
|
369
|
+
closeContextMenu,
|
|
370
|
+
askClaudeRead,
|
|
371
|
+
copyPath,
|
|
372
|
+
insertPath,
|
|
373
|
+
refreshTree,
|
|
374
|
+
handleDirectoryListing,
|
|
375
|
+
onWorkdirChanged,
|
|
376
|
+
flattenedTree,
|
|
377
|
+
onResizeStart,
|
|
378
|
+
};
|
|
379
|
+
}
|