@agent-link/server 0.1.115 → 0.1.116
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/package.json +1 -1
- package/web/app.js +179 -7
- package/web/favicon.svg +7 -1
- package/web/modules/connection.js +15 -6
- package/web/modules/fileBrowser.js +379 -0
- package/web/style.css +329 -13
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { createStreaming } from './modules/streaming.js';
|
|
17
17
|
import { createSidebar } from './modules/sidebar.js';
|
|
18
18
|
import { createConnection } from './modules/connection.js';
|
|
19
|
+
import { createFileBrowser } from './modules/fileBrowser.js';
|
|
19
20
|
|
|
20
21
|
// ── App ─────────────────────────────────────────────────────────────────────
|
|
21
22
|
const App = {
|
|
@@ -94,6 +95,16 @@ const App = {
|
|
|
94
95
|
const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
|
|
95
96
|
const processingConversations = ref({}); // conversationId → boolean
|
|
96
97
|
|
|
98
|
+
// File browser state
|
|
99
|
+
const filePanelOpen = ref(false);
|
|
100
|
+
const filePanelWidth = ref(parseInt(localStorage.getItem('agentlink-file-panel-width'), 10) || 280);
|
|
101
|
+
const fileTreeRoot = ref(null);
|
|
102
|
+
const fileTreeLoading = ref(false);
|
|
103
|
+
const fileContextMenu = ref(null);
|
|
104
|
+
const sidebarView = ref('sessions'); // 'sessions' | 'files' (mobile only)
|
|
105
|
+
const isMobile = ref(window.innerWidth <= 768);
|
|
106
|
+
const workdirMenuOpen = ref(false);
|
|
107
|
+
|
|
97
108
|
// ── switchConversation: save current → load target ──
|
|
98
109
|
// Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
|
|
99
110
|
// Needs access to streaming / connection which are created later, so we use late-binding refs.
|
|
@@ -233,7 +244,7 @@ const App = {
|
|
|
233
244
|
switchConversation,
|
|
234
245
|
});
|
|
235
246
|
|
|
236
|
-
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
247
|
+
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
237
248
|
status, agentName, hostname, workDir, sessionId, error,
|
|
238
249
|
serverVersion, agentVersion, latency,
|
|
239
250
|
messages, isProcessing, isCompacting, visibleLimit, queuedMessages,
|
|
@@ -254,6 +265,32 @@ const App = {
|
|
|
254
265
|
_restoreToolMsgMap = restoreToolMsgMap;
|
|
255
266
|
_clearToolMsgMap = clearToolMsgMap;
|
|
256
267
|
|
|
268
|
+
// File browser module
|
|
269
|
+
const fileBrowser = createFileBrowser({
|
|
270
|
+
wsSend, workDir, inputText, inputRef, sendMessage,
|
|
271
|
+
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
272
|
+
sidebarOpen, sidebarView,
|
|
273
|
+
});
|
|
274
|
+
setFileBrowser(fileBrowser);
|
|
275
|
+
|
|
276
|
+
// Track mobile state on resize
|
|
277
|
+
let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
|
|
278
|
+
window.addEventListener('resize', _resizeHandler);
|
|
279
|
+
|
|
280
|
+
// Close workdir menu on outside click or Escape
|
|
281
|
+
let _workdirMenuClickHandler = (e) => {
|
|
282
|
+
if (!workdirMenuOpen.value) return;
|
|
283
|
+
const row = document.querySelector('.sidebar-workdir-path-row');
|
|
284
|
+
const menu = document.querySelector('.workdir-menu');
|
|
285
|
+
if ((row && row.contains(e.target)) || (menu && menu.contains(e.target))) return;
|
|
286
|
+
workdirMenuOpen.value = false;
|
|
287
|
+
};
|
|
288
|
+
let _workdirMenuKeyHandler = (e) => {
|
|
289
|
+
if (e.key === 'Escape' && workdirMenuOpen.value) workdirMenuOpen.value = false;
|
|
290
|
+
};
|
|
291
|
+
document.addEventListener('click', _workdirMenuClickHandler);
|
|
292
|
+
document.addEventListener('keydown', _workdirMenuKeyHandler);
|
|
293
|
+
|
|
257
294
|
// ── Computed ──
|
|
258
295
|
const hasInput = computed(() => !!(inputText.value.trim() || attachments.value.length > 0));
|
|
259
296
|
const canSend = computed(() =>
|
|
@@ -377,7 +414,7 @@ const App = {
|
|
|
377
414
|
|
|
378
415
|
// ── Lifecycle ──
|
|
379
416
|
onMounted(() => { connect(scheduleHighlight); });
|
|
380
|
-
onUnmounted(() => { closeWs(); streaming.cleanup(); });
|
|
417
|
+
onUnmounted(() => { closeWs(); streaming.cleanup(); window.removeEventListener('resize', _resizeHandler); document.removeEventListener('click', _workdirMenuClickHandler); document.removeEventListener('keydown', _workdirMenuKeyHandler); });
|
|
381
418
|
|
|
382
419
|
return {
|
|
383
420
|
status, agentName, hostname, workDir, sessionId, error,
|
|
@@ -441,6 +478,25 @@ const App = {
|
|
|
441
478
|
handleDragLeave: fileAttach.handleDragLeave,
|
|
442
479
|
handleDrop: fileAttach.handleDrop,
|
|
443
480
|
handlePaste: fileAttach.handlePaste,
|
|
481
|
+
// File browser
|
|
482
|
+
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
483
|
+
sidebarView, isMobile, fileBrowser,
|
|
484
|
+
flattenedTree: fileBrowser.flattenedTree,
|
|
485
|
+
workdirMenuOpen,
|
|
486
|
+
toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
|
|
487
|
+
workdirMenuBrowse() {
|
|
488
|
+
workdirMenuOpen.value = false;
|
|
489
|
+
if (isMobile.value) { sidebarView.value = 'files'; fileBrowser.openPanel(); }
|
|
490
|
+
else { fileBrowser.togglePanel(); }
|
|
491
|
+
},
|
|
492
|
+
workdirMenuChangeDir() {
|
|
493
|
+
workdirMenuOpen.value = false;
|
|
494
|
+
sidebar.openFolderPicker();
|
|
495
|
+
},
|
|
496
|
+
workdirMenuCopyPath() {
|
|
497
|
+
workdirMenuOpen.value = false;
|
|
498
|
+
navigator.clipboard.writeText(workDir.value);
|
|
499
|
+
},
|
|
444
500
|
};
|
|
445
501
|
},
|
|
446
502
|
template: `
|
|
@@ -478,9 +534,48 @@ const App = {
|
|
|
478
534
|
|
|
479
535
|
<div v-else class="main-body">
|
|
480
536
|
<!-- Sidebar backdrop (mobile) -->
|
|
481
|
-
<div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar"></div>
|
|
537
|
+
<div v-if="sidebarOpen" class="sidebar-backdrop" @click="toggleSidebar(); sidebarView = 'sessions'"></div>
|
|
482
538
|
<!-- Sidebar -->
|
|
483
539
|
<aside v-if="sidebarOpen" class="sidebar">
|
|
540
|
+
<!-- Mobile: file browser view -->
|
|
541
|
+
<div v-if="isMobile && sidebarView === 'files'" class="file-panel-mobile">
|
|
542
|
+
<div class="file-panel-mobile-header">
|
|
543
|
+
<button class="file-panel-mobile-back" @click="sidebarView = 'sessions'">
|
|
544
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
|
545
|
+
Sessions
|
|
546
|
+
</button>
|
|
547
|
+
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
548
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
552
|
+
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
553
|
+
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
554
|
+
No files found.
|
|
555
|
+
</div>
|
|
556
|
+
<div v-else class="file-tree">
|
|
557
|
+
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
558
|
+
<div
|
|
559
|
+
class="file-tree-item"
|
|
560
|
+
:class="{ folder: item.node.type === 'directory' }"
|
|
561
|
+
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
562
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : fileBrowser.onFileClick($event, item.node)"
|
|
563
|
+
>
|
|
564
|
+
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
565
|
+
<span v-else class="file-tree-file-icon">
|
|
566
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
567
|
+
</span>
|
|
568
|
+
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
569
|
+
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
570
|
+
</div>
|
|
571
|
+
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
572
|
+
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
573
|
+
</template>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
|
|
577
|
+
<!-- Normal sidebar content (sessions view) -->
|
|
578
|
+
<template v-else>
|
|
484
579
|
<div class="sidebar-section">
|
|
485
580
|
<div class="sidebar-workdir">
|
|
486
581
|
<div v-if="hostname" class="sidebar-hostname">
|
|
@@ -489,11 +584,25 @@ const App = {
|
|
|
489
584
|
</div>
|
|
490
585
|
<div class="sidebar-workdir-header">
|
|
491
586
|
<div class="sidebar-workdir-label">Working Directory</div>
|
|
492
|
-
<button class="sidebar-change-dir-btn" @click="openFolderPicker" title="Change working directory">
|
|
493
|
-
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
|
494
|
-
</button>
|
|
495
587
|
</div>
|
|
496
|
-
<div class="sidebar-workdir-path"
|
|
588
|
+
<div class="sidebar-workdir-path-row" @click.stop="toggleWorkdirMenu()">
|
|
589
|
+
<div class="sidebar-workdir-path" :title="workDir">{{ workDir }}</div>
|
|
590
|
+
<svg class="sidebar-workdir-chevron" :class="{ open: workdirMenuOpen }" viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M7 10l5 5 5-5z"/></svg>
|
|
591
|
+
</div>
|
|
592
|
+
<div v-if="workdirMenuOpen" class="workdir-menu">
|
|
593
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuBrowse()">
|
|
594
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10zM8 13h8v2H8v-2z"/></svg>
|
|
595
|
+
<span>Browse files</span>
|
|
596
|
+
</div>
|
|
597
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuChangeDir()">
|
|
598
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
|
599
|
+
<span>Change directory</span>
|
|
600
|
+
</div>
|
|
601
|
+
<div class="workdir-menu-item" @click.stop="workdirMenuCopyPath()">
|
|
602
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
603
|
+
<span>Copy path</span>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
497
606
|
<div v-if="filteredWorkdirHistory.length > 0" class="workdir-history">
|
|
498
607
|
<div class="workdir-history-label">Recent Directories</div>
|
|
499
608
|
<div class="workdir-history-list">
|
|
@@ -585,8 +694,51 @@ const App = {
|
|
|
585
694
|
<span v-if="serverVersion && agentVersion" class="sidebar-version-sep">/</span>
|
|
586
695
|
<span v-if="agentVersion">agent {{ agentVersion }}</span>
|
|
587
696
|
</div>
|
|
697
|
+
</template>
|
|
588
698
|
</aside>
|
|
589
699
|
|
|
700
|
+
<!-- File browser panel (desktop) -->
|
|
701
|
+
<Transition name="file-panel">
|
|
702
|
+
<div v-if="filePanelOpen && !isMobile" class="file-panel" :style="{ width: filePanelWidth + 'px' }">
|
|
703
|
+
<div class="file-panel-resize-handle" @mousedown="fileBrowser.onResizeStart($event)" @touchstart="fileBrowser.onResizeStart($event)"></div>
|
|
704
|
+
<div class="file-panel-header">
|
|
705
|
+
<span class="file-panel-title">Files</span>
|
|
706
|
+
<div class="file-panel-actions">
|
|
707
|
+
<button class="file-panel-btn" @click="fileBrowser.refreshTree()" title="Refresh">
|
|
708
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
|
709
|
+
</button>
|
|
710
|
+
<button class="file-panel-btn" @click="filePanelOpen = false" title="Close">
|
|
711
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
|
712
|
+
</button>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
<div class="file-panel-breadcrumb" :title="workDir">{{ workDir }}</div>
|
|
716
|
+
<div v-if="fileTreeLoading" class="file-panel-loading">Loading...</div>
|
|
717
|
+
<div v-else-if="!fileTreeRoot || !fileTreeRoot.children || fileTreeRoot.children.length === 0" class="file-panel-empty">
|
|
718
|
+
No files found.
|
|
719
|
+
</div>
|
|
720
|
+
<div v-else class="file-tree">
|
|
721
|
+
<template v-for="item in flattenedTree" :key="item.node.path">
|
|
722
|
+
<div
|
|
723
|
+
class="file-tree-item"
|
|
724
|
+
:class="{ folder: item.node.type === 'directory' }"
|
|
725
|
+
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
726
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : fileBrowser.onFileClick($event, item.node)"
|
|
727
|
+
>
|
|
728
|
+
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
729
|
+
<span v-else class="file-tree-file-icon">
|
|
730
|
+
<svg viewBox="0 0 24 24" width="12" height="12"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
|
|
731
|
+
</span>
|
|
732
|
+
<span class="file-tree-name" :title="item.node.path">{{ item.node.name }}</span>
|
|
733
|
+
<span v-if="item.node.loading" class="file-tree-spinner"></span>
|
|
734
|
+
</div>
|
|
735
|
+
<div v-if="item.node.type === 'directory' && item.node.expanded && item.node.children && item.node.children.length === 0 && !item.node.loading" class="file-tree-empty" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">(empty)</div>
|
|
736
|
+
<div v-if="item.node.error" class="file-tree-error" :style="{ paddingLeft: ((item.depth + 1) * 16 + 8) + 'px' }">{{ item.node.error }}</div>
|
|
737
|
+
</template>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
</Transition>
|
|
741
|
+
|
|
590
742
|
<!-- Chat area -->
|
|
591
743
|
<div class="chat-area">
|
|
592
744
|
<div class="message-list" @scroll="onMessageListScroll">
|
|
@@ -893,6 +1045,26 @@ const App = {
|
|
|
893
1045
|
</div>
|
|
894
1046
|
</div>
|
|
895
1047
|
</div>
|
|
1048
|
+
|
|
1049
|
+
<!-- File context menu -->
|
|
1050
|
+
<div
|
|
1051
|
+
v-if="fileContextMenu"
|
|
1052
|
+
class="file-context-menu"
|
|
1053
|
+
:style="{ left: fileContextMenu.x + 'px', top: fileContextMenu.y + 'px' }"
|
|
1054
|
+
>
|
|
1055
|
+
<div class="file-context-item" @click="fileBrowser.askClaudeRead()">
|
|
1056
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14zM5 15h14v2H5zm0-4h14v2H5zm0-4h14v2H5z"/></svg>
|
|
1057
|
+
Ask Claude to read
|
|
1058
|
+
</div>
|
|
1059
|
+
<div class="file-context-item" @click="fileBrowser.copyPath()">
|
|
1060
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
1061
|
+
{{ fileContextMenu.copied ? 'Copied!' : 'Copy path' }}
|
|
1062
|
+
</div>
|
|
1063
|
+
<div class="file-context-item" @click="fileBrowser.insertPath()">
|
|
1064
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
|
|
1065
|
+
Insert path to input
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
896
1068
|
</div>
|
|
897
1069
|
`
|
|
898
1070
|
};
|
package/web/favicon.svg
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
-
<
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#3b82f6"/>
|
|
5
|
+
<stop offset="100%" stop-color="#f59e0b"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="32" height="32" rx="6" fill="url(#g)"/>
|
|
3
9
|
<text x="16" y="23" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="22" font-weight="800" fill="#fff">A</text>
|
|
4
10
|
</svg>
|
|
@@ -29,6 +29,10 @@ export function createConnection(deps) {
|
|
|
29
29
|
let _dequeueNext = () => {};
|
|
30
30
|
function setDequeueNext(fn) { _dequeueNext = fn; }
|
|
31
31
|
|
|
32
|
+
// File browser — set after creation to resolve circular dependency
|
|
33
|
+
let fileBrowser = null;
|
|
34
|
+
function setFileBrowser(fb) { fileBrowser = fb; }
|
|
35
|
+
|
|
32
36
|
let ws = null;
|
|
33
37
|
let sessionKey = null;
|
|
34
38
|
let reconnectAttempts = 0;
|
|
@@ -705,15 +709,20 @@ export function createConnection(deps) {
|
|
|
705
709
|
}
|
|
706
710
|
scrollToBottom();
|
|
707
711
|
} else if (msg.type === 'directory_listing') {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
.
|
|
712
|
-
|
|
712
|
+
if (msg.source === 'file_browser' && fileBrowser) {
|
|
713
|
+
fileBrowser.handleDirectoryListing(msg);
|
|
714
|
+
} else {
|
|
715
|
+
folderPickerLoading.value = false;
|
|
716
|
+
folderPickerEntries.value = (msg.entries || [])
|
|
717
|
+
.filter(e => e.type === 'directory')
|
|
718
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
719
|
+
if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
|
|
720
|
+
}
|
|
713
721
|
} else if (msg.type === 'workdir_changed') {
|
|
714
722
|
workDir.value = msg.workDir;
|
|
715
723
|
localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
|
|
716
724
|
sidebar.addToWorkdirHistory(msg.workDir);
|
|
725
|
+
if (fileBrowser) fileBrowser.onWorkdirChanged();
|
|
717
726
|
|
|
718
727
|
// Multi-session: switch to a new blank conversation for the new workdir.
|
|
719
728
|
// Background conversations keep running and receiving output in their cache.
|
|
@@ -788,5 +797,5 @@ export function createConnection(deps) {
|
|
|
788
797
|
ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
|
|
789
798
|
}
|
|
790
799
|
|
|
791
|
-
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
800
|
+
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
792
801
|
}
|
|
@@ -0,0 +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
|
+
}
|
package/web/style.css
CHANGED
|
@@ -259,6 +259,7 @@ body {
|
|
|
259
259
|
|
|
260
260
|
.sidebar-workdir {
|
|
261
261
|
overflow: hidden;
|
|
262
|
+
position: relative;
|
|
262
263
|
}
|
|
263
264
|
|
|
264
265
|
.sidebar-hostname {
|
|
@@ -1864,28 +1865,69 @@ body {
|
|
|
1864
1865
|
justify-content: space-between;
|
|
1865
1866
|
}
|
|
1866
1867
|
|
|
1867
|
-
|
|
1868
|
+
/* ── Workdir path row (clickable) ── */
|
|
1869
|
+
.sidebar-workdir-path-row {
|
|
1868
1870
|
display: flex;
|
|
1869
|
-
align-items:
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
border: 1px solid var(--border);
|
|
1871
|
+
align-items: flex-start;
|
|
1872
|
+
gap: 4px;
|
|
1873
|
+
cursor: pointer;
|
|
1874
|
+
padding: 4px 6px;
|
|
1875
|
+
margin: 0 -6px;
|
|
1875
1876
|
border-radius: 4px;
|
|
1877
|
+
transition: background 0.15s;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
.sidebar-workdir-path-row:hover {
|
|
1881
|
+
background: var(--bg-tertiary);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
.sidebar-workdir-path-row .sidebar-workdir-path {
|
|
1885
|
+
flex: 1;
|
|
1886
|
+
min-width: 0;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
.sidebar-workdir-chevron {
|
|
1890
|
+
flex-shrink: 0;
|
|
1891
|
+
margin-top: 2px;
|
|
1876
1892
|
color: var(--text-secondary);
|
|
1877
|
-
|
|
1878
|
-
|
|
1893
|
+
transition: transform 0.15s ease;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
.sidebar-workdir-chevron.open {
|
|
1897
|
+
transform: rotate(180deg);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/* ── Workdir dropdown menu ── */
|
|
1901
|
+
.workdir-menu {
|
|
1902
|
+
position: absolute;
|
|
1903
|
+
left: -6px;
|
|
1904
|
+
right: -6px;
|
|
1905
|
+
z-index: 50;
|
|
1906
|
+
margin-top: 4px;
|
|
1907
|
+
background: var(--bg-secondary);
|
|
1908
|
+
border: 1px solid var(--border);
|
|
1909
|
+
border-radius: 6px;
|
|
1910
|
+
padding: 4px 0;
|
|
1911
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
1879
1912
|
}
|
|
1880
1913
|
|
|
1881
|
-
.
|
|
1914
|
+
.workdir-menu-item {
|
|
1915
|
+
display: flex;
|
|
1916
|
+
align-items: center;
|
|
1917
|
+
gap: 8px;
|
|
1918
|
+
padding: 7px 12px;
|
|
1919
|
+
font-size: 0.8rem;
|
|
1920
|
+
cursor: pointer;
|
|
1882
1921
|
color: var(--text-primary);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
.workdir-menu-item:hover {
|
|
1883
1925
|
background: var(--bg-tertiary);
|
|
1884
1926
|
}
|
|
1885
1927
|
|
|
1886
|
-
.
|
|
1887
|
-
|
|
1888
|
-
|
|
1928
|
+
.workdir-menu-item svg {
|
|
1929
|
+
flex-shrink: 0;
|
|
1930
|
+
color: var(--text-secondary);
|
|
1889
1931
|
}
|
|
1890
1932
|
|
|
1891
1933
|
/* ── Folder Picker Modal ── */
|
|
@@ -2236,6 +2278,275 @@ body {
|
|
|
2236
2278
|
display: none;
|
|
2237
2279
|
}
|
|
2238
2280
|
|
|
2281
|
+
/* ══════════════════════════════════════════
|
|
2282
|
+
File Browser Panel
|
|
2283
|
+
══════════════════════════════════════════ */
|
|
2284
|
+
.file-panel {
|
|
2285
|
+
width: 280px;
|
|
2286
|
+
flex-shrink: 0;
|
|
2287
|
+
background: var(--bg-primary);
|
|
2288
|
+
border-right: 1px solid var(--border);
|
|
2289
|
+
display: flex;
|
|
2290
|
+
flex-direction: column;
|
|
2291
|
+
overflow: hidden;
|
|
2292
|
+
position: relative;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
.file-panel-resize-handle {
|
|
2296
|
+
position: absolute;
|
|
2297
|
+
top: 0;
|
|
2298
|
+
right: -3px;
|
|
2299
|
+
width: 6px;
|
|
2300
|
+
height: 100%;
|
|
2301
|
+
cursor: col-resize;
|
|
2302
|
+
z-index: 10;
|
|
2303
|
+
background: transparent;
|
|
2304
|
+
transition: background 0.15s;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
.file-panel-resize-handle:hover,
|
|
2308
|
+
.file-panel-resize-handle:active {
|
|
2309
|
+
background: var(--accent);
|
|
2310
|
+
opacity: 0.4;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
.file-panel-header {
|
|
2314
|
+
display: flex;
|
|
2315
|
+
align-items: center;
|
|
2316
|
+
justify-content: space-between;
|
|
2317
|
+
padding: 0.75rem;
|
|
2318
|
+
border-bottom: 1px solid var(--border);
|
|
2319
|
+
background: var(--bg-primary);
|
|
2320
|
+
flex-shrink: 0;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
.file-panel-title {
|
|
2324
|
+
font-size: 0.8rem;
|
|
2325
|
+
font-weight: 600;
|
|
2326
|
+
text-transform: uppercase;
|
|
2327
|
+
letter-spacing: 0.05em;
|
|
2328
|
+
color: var(--text-secondary);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
.file-panel-actions {
|
|
2332
|
+
display: flex;
|
|
2333
|
+
gap: 4px;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
.file-panel-btn {
|
|
2337
|
+
display: flex;
|
|
2338
|
+
align-items: center;
|
|
2339
|
+
justify-content: center;
|
|
2340
|
+
width: 24px;
|
|
2341
|
+
height: 24px;
|
|
2342
|
+
background: none;
|
|
2343
|
+
border: none;
|
|
2344
|
+
border-radius: 4px;
|
|
2345
|
+
color: var(--text-secondary);
|
|
2346
|
+
cursor: pointer;
|
|
2347
|
+
padding: 0;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
.file-panel-btn:hover {
|
|
2351
|
+
color: var(--text-primary);
|
|
2352
|
+
background: var(--bg-tertiary);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
.file-panel-breadcrumb {
|
|
2356
|
+
padding: 0.5rem 0.75rem;
|
|
2357
|
+
font-size: 0.75rem;
|
|
2358
|
+
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
|
2359
|
+
color: var(--text-secondary);
|
|
2360
|
+
border-bottom: 1px solid var(--border);
|
|
2361
|
+
white-space: nowrap;
|
|
2362
|
+
overflow: hidden;
|
|
2363
|
+
text-overflow: ellipsis;
|
|
2364
|
+
flex-shrink: 0;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
.file-tree {
|
|
2368
|
+
flex: 1;
|
|
2369
|
+
overflow-y: auto;
|
|
2370
|
+
overflow-x: hidden;
|
|
2371
|
+
padding: 0.25rem 0;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
.file-tree-item {
|
|
2375
|
+
display: flex;
|
|
2376
|
+
align-items: center;
|
|
2377
|
+
gap: 6px;
|
|
2378
|
+
padding: 3px 8px;
|
|
2379
|
+
font-size: 0.8rem;
|
|
2380
|
+
font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
|
2381
|
+
color: var(--text-primary);
|
|
2382
|
+
cursor: pointer;
|
|
2383
|
+
user-select: none;
|
|
2384
|
+
white-space: nowrap;
|
|
2385
|
+
overflow: hidden;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
.file-tree-item:hover {
|
|
2389
|
+
background: var(--bg-tertiary);
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
.file-tree-arrow {
|
|
2393
|
+
flex-shrink: 0;
|
|
2394
|
+
width: 14px;
|
|
2395
|
+
text-align: center;
|
|
2396
|
+
font-size: 0.65rem;
|
|
2397
|
+
color: var(--text-secondary);
|
|
2398
|
+
transition: transform 0.15s ease;
|
|
2399
|
+
display: inline-block;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
.file-tree-arrow.expanded {
|
|
2403
|
+
transform: rotate(90deg);
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
.file-tree-file-icon {
|
|
2407
|
+
flex-shrink: 0;
|
|
2408
|
+
width: 14px;
|
|
2409
|
+
text-align: center;
|
|
2410
|
+
color: var(--text-secondary);
|
|
2411
|
+
display: flex;
|
|
2412
|
+
align-items: center;
|
|
2413
|
+
justify-content: center;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
.file-tree-name {
|
|
2417
|
+
flex: 1;
|
|
2418
|
+
min-width: 0;
|
|
2419
|
+
overflow: hidden;
|
|
2420
|
+
text-overflow: ellipsis;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
.file-tree-spinner {
|
|
2424
|
+
width: 12px;
|
|
2425
|
+
height: 12px;
|
|
2426
|
+
border: 2px solid var(--border);
|
|
2427
|
+
border-top-color: var(--accent);
|
|
2428
|
+
border-radius: 50%;
|
|
2429
|
+
animation: spin 0.6s linear infinite;
|
|
2430
|
+
flex-shrink: 0;
|
|
2431
|
+
margin-left: auto;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
@keyframes spin {
|
|
2435
|
+
to { transform: rotate(360deg); }
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
.file-tree-empty {
|
|
2439
|
+
padding: 4px 8px;
|
|
2440
|
+
font-size: 0.75rem;
|
|
2441
|
+
color: var(--text-secondary);
|
|
2442
|
+
font-style: italic;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
.file-tree-error {
|
|
2446
|
+
padding: 4px 8px;
|
|
2447
|
+
font-size: 0.75rem;
|
|
2448
|
+
color: var(--error, #e74c3c);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
.file-panel-loading {
|
|
2452
|
+
padding: 1rem;
|
|
2453
|
+
text-align: center;
|
|
2454
|
+
color: var(--text-secondary);
|
|
2455
|
+
font-size: 0.85rem;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
.file-panel-empty {
|
|
2459
|
+
padding: 1rem;
|
|
2460
|
+
text-align: center;
|
|
2461
|
+
color: var(--text-secondary);
|
|
2462
|
+
font-size: 0.85rem;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/* ── File Context Menu ── */
|
|
2466
|
+
.file-context-menu {
|
|
2467
|
+
position: fixed;
|
|
2468
|
+
z-index: 200;
|
|
2469
|
+
background: var(--bg-secondary);
|
|
2470
|
+
border: 1px solid var(--border);
|
|
2471
|
+
border-radius: 6px;
|
|
2472
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
2473
|
+
padding: 4px 0;
|
|
2474
|
+
min-width: 200px;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
.file-context-item {
|
|
2478
|
+
display: flex;
|
|
2479
|
+
align-items: center;
|
|
2480
|
+
gap: 8px;
|
|
2481
|
+
padding: 8px 12px;
|
|
2482
|
+
font-size: 0.8rem;
|
|
2483
|
+
cursor: pointer;
|
|
2484
|
+
color: var(--text-primary);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
.file-context-item:hover {
|
|
2488
|
+
background: var(--bg-tertiary);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
.file-context-item svg {
|
|
2492
|
+
flex-shrink: 0;
|
|
2493
|
+
color: var(--text-secondary);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/* ── File panel transition (Vue <Transition>) ── */
|
|
2497
|
+
.file-panel-enter-active,
|
|
2498
|
+
.file-panel-leave-active {
|
|
2499
|
+
transition: width 0.2s ease, opacity 0.15s ease;
|
|
2500
|
+
overflow: hidden;
|
|
2501
|
+
}
|
|
2502
|
+
.file-panel-enter-from,
|
|
2503
|
+
.file-panel-leave-to {
|
|
2504
|
+
width: 0 !important;
|
|
2505
|
+
opacity: 0;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/* ── Mobile file browser in sidebar ── */
|
|
2509
|
+
.file-panel-mobile {
|
|
2510
|
+
display: flex;
|
|
2511
|
+
flex-direction: column;
|
|
2512
|
+
height: 100%;
|
|
2513
|
+
overflow: hidden;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
.file-panel-mobile-header {
|
|
2517
|
+
display: flex;
|
|
2518
|
+
align-items: center;
|
|
2519
|
+
justify-content: space-between;
|
|
2520
|
+
padding: 0.75rem;
|
|
2521
|
+
border-bottom: 1px solid var(--border);
|
|
2522
|
+
flex-shrink: 0;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
.file-panel-mobile-back {
|
|
2526
|
+
display: flex;
|
|
2527
|
+
align-items: center;
|
|
2528
|
+
gap: 4px;
|
|
2529
|
+
background: none;
|
|
2530
|
+
border: none;
|
|
2531
|
+
color: var(--accent);
|
|
2532
|
+
cursor: pointer;
|
|
2533
|
+
font-size: 0.85rem;
|
|
2534
|
+
padding: 4px 0;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
.file-panel-mobile-back:hover {
|
|
2538
|
+
opacity: 0.8;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
/* ══════════════════════════════════════════
|
|
2542
|
+
Medium screens — file panel narrower
|
|
2543
|
+
══════════════════════════════════════════ */
|
|
2544
|
+
@media (max-width: 1200px) and (min-width: 769px) {
|
|
2545
|
+
.file-panel {
|
|
2546
|
+
max-width: clamp(200px, 20vw, 280px);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2239
2550
|
/* ══════════════════════════════════════════
|
|
2240
2551
|
Mobile responsive — max-width: 768px
|
|
2241
2552
|
══════════════════════════════════════════ */
|
|
@@ -2250,6 +2561,11 @@ body {
|
|
|
2250
2561
|
max-width: 100vw;
|
|
2251
2562
|
}
|
|
2252
2563
|
|
|
2564
|
+
/* File panel hidden on mobile — shown inside sidebar instead */
|
|
2565
|
+
.file-panel {
|
|
2566
|
+
display: none;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2253
2569
|
/* Sidebar as fixed overlay */
|
|
2254
2570
|
.sidebar {
|
|
2255
2571
|
position: fixed;
|