@agent-link/server 0.1.120 → 0.1.121
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 +103 -4
- package/web/modules/connection.js +76 -1
- package/web/modules/filePreview.js +187 -0
- package/web/style.css +203 -0
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -17,6 +17,7 @@ import { createStreaming } from './modules/streaming.js';
|
|
|
17
17
|
import { createSidebar } from './modules/sidebar.js';
|
|
18
18
|
import { createConnection } from './modules/connection.js';
|
|
19
19
|
import { createFileBrowser } from './modules/fileBrowser.js';
|
|
20
|
+
import { createFilePreview } from './modules/filePreview.js';
|
|
20
21
|
|
|
21
22
|
// ── App ─────────────────────────────────────────────────────────────────────
|
|
22
23
|
const App = {
|
|
@@ -102,10 +103,16 @@ const App = {
|
|
|
102
103
|
const fileTreeRoot = ref(null);
|
|
103
104
|
const fileTreeLoading = ref(false);
|
|
104
105
|
const fileContextMenu = ref(null);
|
|
105
|
-
const sidebarView = ref('sessions'); // 'sessions' | 'files' (mobile only)
|
|
106
|
+
const sidebarView = ref('sessions'); // 'sessions' | 'files' | 'preview' (mobile only)
|
|
106
107
|
const isMobile = ref(window.innerWidth <= 768);
|
|
107
108
|
const workdirMenuOpen = ref(false);
|
|
108
109
|
|
|
110
|
+
// File preview state
|
|
111
|
+
const previewPanelOpen = ref(false);
|
|
112
|
+
const previewPanelWidth = ref(parseInt(localStorage.getItem('agentlink-preview-panel-width'), 10) || 400);
|
|
113
|
+
const previewFile = ref(null);
|
|
114
|
+
const previewLoading = ref(false);
|
|
115
|
+
|
|
109
116
|
// ── switchConversation: save current → load target ──
|
|
110
117
|
// Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
|
|
111
118
|
// Needs access to streaming / connection which are created later, so we use late-binding refs.
|
|
@@ -248,7 +255,7 @@ const App = {
|
|
|
248
255
|
switchConversation,
|
|
249
256
|
});
|
|
250
257
|
|
|
251
|
-
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
258
|
+
const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
|
|
252
259
|
status, agentName, hostname, workDir, sessionId, error,
|
|
253
260
|
serverVersion, agentVersion, latency,
|
|
254
261
|
messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
|
|
@@ -277,6 +284,13 @@ const App = {
|
|
|
277
284
|
});
|
|
278
285
|
setFileBrowser(fileBrowser);
|
|
279
286
|
|
|
287
|
+
// File preview module
|
|
288
|
+
const filePreview = createFilePreview({
|
|
289
|
+
wsSend, previewPanelOpen, previewPanelWidth, previewFile, previewLoading,
|
|
290
|
+
sidebarView, sidebarOpen, isMobile,
|
|
291
|
+
});
|
|
292
|
+
setFilePreview(filePreview);
|
|
293
|
+
|
|
280
294
|
// Track mobile state on resize
|
|
281
295
|
let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
|
|
282
296
|
window.addEventListener('resize', _resizeHandler);
|
|
@@ -501,6 +515,8 @@ const App = {
|
|
|
501
515
|
filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
|
|
502
516
|
sidebarView, isMobile, fileBrowser,
|
|
503
517
|
flattenedTree: fileBrowser.flattenedTree,
|
|
518
|
+
// File preview
|
|
519
|
+
previewPanelOpen, previewPanelWidth, previewFile, previewLoading, filePreview,
|
|
504
520
|
workdirMenuOpen,
|
|
505
521
|
toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
|
|
506
522
|
workdirMenuBrowse() {
|
|
@@ -578,7 +594,8 @@ const App = {
|
|
|
578
594
|
class="file-tree-item"
|
|
579
595
|
:class="{ folder: item.node.type === 'directory' }"
|
|
580
596
|
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
581
|
-
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) :
|
|
597
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
598
|
+
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
582
599
|
>
|
|
583
600
|
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
584
601
|
<span v-else class="file-tree-file-icon">
|
|
@@ -593,6 +610,43 @@ const App = {
|
|
|
593
610
|
</div>
|
|
594
611
|
</div>
|
|
595
612
|
|
|
613
|
+
<!-- Mobile: file preview view -->
|
|
614
|
+
<div v-else-if="isMobile && sidebarView === 'preview'" class="file-preview-mobile">
|
|
615
|
+
<div class="file-preview-mobile-header">
|
|
616
|
+
<button class="file-panel-mobile-back" @click="filePreview.closePreview()">
|
|
617
|
+
<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>
|
|
618
|
+
Files
|
|
619
|
+
</button>
|
|
620
|
+
<span v-if="previewFile" class="file-preview-mobile-size">
|
|
621
|
+
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
622
|
+
</span>
|
|
623
|
+
</div>
|
|
624
|
+
<div class="file-preview-mobile-filename" :title="previewFile?.filePath">
|
|
625
|
+
{{ previewFile?.fileName || 'Preview' }}
|
|
626
|
+
</div>
|
|
627
|
+
<div class="preview-panel-body">
|
|
628
|
+
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
629
|
+
<div v-else-if="previewFile?.error" class="preview-error">
|
|
630
|
+
{{ previewFile.error }}
|
|
631
|
+
</div>
|
|
632
|
+
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
633
|
+
class="preview-image-container">
|
|
634
|
+
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
635
|
+
:alt="previewFile.fileName" class="preview-image" />
|
|
636
|
+
</div>
|
|
637
|
+
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
638
|
+
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
639
|
+
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
640
|
+
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
644
|
+
<p>Binary file — {{ previewFile.mimeType }}</p>
|
|
645
|
+
<p>{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
|
|
596
650
|
<!-- Normal sidebar content (sessions view) -->
|
|
597
651
|
<template v-else>
|
|
598
652
|
<div class="sidebar-section">
|
|
@@ -742,7 +796,8 @@ const App = {
|
|
|
742
796
|
class="file-tree-item"
|
|
743
797
|
:class="{ folder: item.node.type === 'directory' }"
|
|
744
798
|
:style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
|
|
745
|
-
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) :
|
|
799
|
+
@click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
|
|
800
|
+
@contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
|
|
746
801
|
>
|
|
747
802
|
<span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">▶</span>
|
|
748
803
|
<span v-else class="file-tree-file-icon">
|
|
@@ -970,6 +1025,50 @@ const App = {
|
|
|
970
1025
|
</div>
|
|
971
1026
|
</div>
|
|
972
1027
|
</div>
|
|
1028
|
+
|
|
1029
|
+
<!-- Preview Panel (desktop) -->
|
|
1030
|
+
<Transition name="file-panel">
|
|
1031
|
+
<div v-if="previewPanelOpen && !isMobile" class="preview-panel" :style="{ width: previewPanelWidth + 'px' }">
|
|
1032
|
+
<div class="preview-panel-resize-handle"
|
|
1033
|
+
@mousedown="filePreview.onResizeStart($event)"
|
|
1034
|
+
@touchstart="filePreview.onResizeStart($event)"></div>
|
|
1035
|
+
<div class="preview-panel-header">
|
|
1036
|
+
<span class="preview-panel-filename" :title="previewFile?.filePath">
|
|
1037
|
+
{{ previewFile?.fileName || 'Preview' }}
|
|
1038
|
+
</span>
|
|
1039
|
+
<span v-if="previewFile" class="preview-panel-size">
|
|
1040
|
+
{{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1041
|
+
</span>
|
|
1042
|
+
<button class="preview-panel-close" @click="filePreview.closePreview()" title="Close preview">×</button>
|
|
1043
|
+
</div>
|
|
1044
|
+
<div class="preview-panel-body">
|
|
1045
|
+
<div v-if="previewLoading" class="preview-loading">Loading...</div>
|
|
1046
|
+
<div v-else-if="previewFile?.error" class="preview-error">
|
|
1047
|
+
{{ previewFile.error }}
|
|
1048
|
+
</div>
|
|
1049
|
+
<div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
|
|
1050
|
+
class="preview-image-container">
|
|
1051
|
+
<img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
|
|
1052
|
+
:alt="previewFile.fileName" class="preview-image" />
|
|
1053
|
+
</div>
|
|
1054
|
+
<div v-else-if="previewFile?.content" class="preview-text-container">
|
|
1055
|
+
<pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
|
|
1056
|
+
<div v-if="previewFile.truncated" class="preview-truncated-notice">
|
|
1057
|
+
File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
<div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
|
|
1061
|
+
<div class="preview-binary-icon">
|
|
1062
|
+
<svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.4" 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>
|
|
1063
|
+
</div>
|
|
1064
|
+
<p>Binary file</p>
|
|
1065
|
+
<p class="preview-binary-meta">{{ previewFile.mimeType }}</p>
|
|
1066
|
+
<p class="preview-binary-meta">{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</Transition>
|
|
1071
|
+
|
|
973
1072
|
</div>
|
|
974
1073
|
|
|
975
1074
|
<!-- Folder Picker Modal -->
|
|
@@ -33,6 +33,10 @@ export function createConnection(deps) {
|
|
|
33
33
|
let fileBrowser = null;
|
|
34
34
|
function setFileBrowser(fb) { fileBrowser = fb; }
|
|
35
35
|
|
|
36
|
+
// File preview — set after creation to resolve circular dependency
|
|
37
|
+
let filePreview = null;
|
|
38
|
+
function setFilePreview(fp) { filePreview = fp; }
|
|
39
|
+
|
|
36
40
|
let ws = null;
|
|
37
41
|
let sessionKey = null;
|
|
38
42
|
let reconnectAttempts = 0;
|
|
@@ -137,6 +141,11 @@ export function createConnection(deps) {
|
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
if (msg.type === 'claude_output') {
|
|
144
|
+
// Safety net: restore processing state if output arrives after reconnect
|
|
145
|
+
if (!cache.isProcessing) {
|
|
146
|
+
cache.isProcessing = true;
|
|
147
|
+
processingConversations.value[convId] = true;
|
|
148
|
+
}
|
|
140
149
|
const data = msg.data;
|
|
141
150
|
if (!data) return;
|
|
142
151
|
if (data.type === 'content_block_delta' && data.delta) {
|
|
@@ -349,6 +358,15 @@ export function createConnection(deps) {
|
|
|
349
358
|
const data = msg.data;
|
|
350
359
|
if (!data) return;
|
|
351
360
|
|
|
361
|
+
// Safety net: if streaming output arrives but isProcessing is false
|
|
362
|
+
// (e.g. after reconnect before active_conversations response), self-correct
|
|
363
|
+
if (!isProcessing.value) {
|
|
364
|
+
isProcessing.value = true;
|
|
365
|
+
if (currentConversationId && currentConversationId.value) {
|
|
366
|
+
processingConversations.value[currentConversationId.value] = true;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
352
370
|
if (data.type === 'content_block_delta' && data.delta) {
|
|
353
371
|
streaming.appendPending(data.delta);
|
|
354
372
|
streaming.startReveal();
|
|
@@ -490,6 +508,7 @@ export function createConnection(deps) {
|
|
|
490
508
|
}
|
|
491
509
|
sidebar.requestSessionList();
|
|
492
510
|
startPing();
|
|
511
|
+
wsSend({ type: 'query_active_conversations' });
|
|
493
512
|
} else {
|
|
494
513
|
status.value = 'Waiting';
|
|
495
514
|
error.value = 'Agent is not connected yet.';
|
|
@@ -532,6 +551,59 @@ export function createConnection(deps) {
|
|
|
532
551
|
}
|
|
533
552
|
sidebar.requestSessionList();
|
|
534
553
|
startPing();
|
|
554
|
+
wsSend({ type: 'query_active_conversations' });
|
|
555
|
+
} else if (msg.type === 'active_conversations') {
|
|
556
|
+
// Agent's response is authoritative — first clear all processing state,
|
|
557
|
+
// then re-apply only for conversations the agent reports as active.
|
|
558
|
+
// This corrects any stale isProcessing=true left by the safety net or
|
|
559
|
+
// from turns that finished while the socket was down.
|
|
560
|
+
const activeSet = new Set();
|
|
561
|
+
const convs = msg.conversations || [];
|
|
562
|
+
for (const entry of convs) {
|
|
563
|
+
if (entry.conversationId) activeSet.add(entry.conversationId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Clear foreground
|
|
567
|
+
if (!activeSet.has(currentConversationId && currentConversationId.value)) {
|
|
568
|
+
isProcessing.value = false;
|
|
569
|
+
isCompacting.value = false;
|
|
570
|
+
}
|
|
571
|
+
// Clear all cached background conversations
|
|
572
|
+
if (conversationCache) {
|
|
573
|
+
for (const [convId, cached] of Object.entries(conversationCache.value)) {
|
|
574
|
+
if (!activeSet.has(convId)) {
|
|
575
|
+
cached.isProcessing = false;
|
|
576
|
+
cached.isCompacting = false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Clear processingConversations map
|
|
581
|
+
if (processingConversations) {
|
|
582
|
+
for (const convId of Object.keys(processingConversations.value)) {
|
|
583
|
+
if (!activeSet.has(convId)) {
|
|
584
|
+
processingConversations.value[convId] = false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Now set state for actually active conversations
|
|
590
|
+
for (const entry of convs) {
|
|
591
|
+
const convId = entry.conversationId;
|
|
592
|
+
if (!convId) continue;
|
|
593
|
+
if (currentConversationId && currentConversationId.value === convId) {
|
|
594
|
+
// Foreground conversation
|
|
595
|
+
isProcessing.value = true;
|
|
596
|
+
isCompacting.value = !!entry.isCompacting;
|
|
597
|
+
} else if (conversationCache && conversationCache.value[convId]) {
|
|
598
|
+
// Background conversation
|
|
599
|
+
const cached = conversationCache.value[convId];
|
|
600
|
+
cached.isProcessing = true;
|
|
601
|
+
cached.isCompacting = !!entry.isCompacting;
|
|
602
|
+
}
|
|
603
|
+
if (processingConversations) {
|
|
604
|
+
processingConversations.value[convId] = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
535
607
|
} else if (msg.type === 'error') {
|
|
536
608
|
streaming.flushReveal();
|
|
537
609
|
finalizeStreamingMsg(scheduleHighlight);
|
|
@@ -722,11 +794,14 @@ export function createConnection(deps) {
|
|
|
722
794
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
723
795
|
if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
|
|
724
796
|
}
|
|
797
|
+
} else if (msg.type === 'file_content') {
|
|
798
|
+
if (filePreview) filePreview.handleFileContent(msg);
|
|
725
799
|
} else if (msg.type === 'workdir_changed') {
|
|
726
800
|
workDir.value = msg.workDir;
|
|
727
801
|
localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
|
|
728
802
|
sidebar.addToWorkdirHistory(msg.workDir);
|
|
729
803
|
if (fileBrowser) fileBrowser.onWorkdirChanged();
|
|
804
|
+
if (filePreview) filePreview.onWorkdirChanged();
|
|
730
805
|
|
|
731
806
|
// Multi-session: switch to a new blank conversation for the new workdir.
|
|
732
807
|
// Background conversations keep running and receiving output in their cache.
|
|
@@ -801,5 +876,5 @@ export function createConnection(deps) {
|
|
|
801
876
|
ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
|
|
802
877
|
}
|
|
803
878
|
|
|
804
|
-
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
879
|
+
return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
|
|
805
880
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ── File Preview: panel state, content rendering, resize handle ────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates the file preview controller.
|
|
5
|
+
* @param {object} deps - Reactive state and callbacks
|
|
6
|
+
*/
|
|
7
|
+
export function createFilePreview(deps) {
|
|
8
|
+
const {
|
|
9
|
+
wsSend,
|
|
10
|
+
previewPanelOpen,
|
|
11
|
+
previewPanelWidth,
|
|
12
|
+
previewFile,
|
|
13
|
+
previewLoading,
|
|
14
|
+
sidebarView,
|
|
15
|
+
sidebarOpen,
|
|
16
|
+
isMobile,
|
|
17
|
+
} = deps;
|
|
18
|
+
|
|
19
|
+
// ── Open / Close ──
|
|
20
|
+
|
|
21
|
+
function openPreview(filePath) {
|
|
22
|
+
// Skip re-fetch if same file already loaded
|
|
23
|
+
if (previewFile.value && previewFile.value.filePath === filePath && !previewFile.value.error) {
|
|
24
|
+
if (isMobile.value) {
|
|
25
|
+
sidebarView.value = 'preview';
|
|
26
|
+
sidebarOpen.value = true;
|
|
27
|
+
} else {
|
|
28
|
+
previewPanelOpen.value = true;
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (isMobile.value) {
|
|
33
|
+
sidebarView.value = 'preview';
|
|
34
|
+
sidebarOpen.value = true;
|
|
35
|
+
} else {
|
|
36
|
+
previewPanelOpen.value = true;
|
|
37
|
+
}
|
|
38
|
+
previewLoading.value = true;
|
|
39
|
+
previewFile.value = null;
|
|
40
|
+
wsSend({ type: 'read_file', filePath });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function closePreview() {
|
|
44
|
+
if (isMobile.value) {
|
|
45
|
+
sidebarView.value = 'files';
|
|
46
|
+
} else {
|
|
47
|
+
previewPanelOpen.value = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Handle file_content response ──
|
|
52
|
+
|
|
53
|
+
function handleFileContent(msg) {
|
|
54
|
+
previewLoading.value = false;
|
|
55
|
+
previewFile.value = {
|
|
56
|
+
filePath: msg.filePath,
|
|
57
|
+
fileName: msg.fileName,
|
|
58
|
+
content: msg.content,
|
|
59
|
+
encoding: msg.encoding,
|
|
60
|
+
mimeType: msg.mimeType,
|
|
61
|
+
truncated: msg.truncated,
|
|
62
|
+
totalSize: msg.totalSize,
|
|
63
|
+
error: msg.error || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Workdir changed → close preview ──
|
|
68
|
+
|
|
69
|
+
function onWorkdirChanged() {
|
|
70
|
+
previewPanelOpen.value = false;
|
|
71
|
+
previewFile.value = null;
|
|
72
|
+
previewLoading.value = false;
|
|
73
|
+
if (sidebarView.value === 'preview') {
|
|
74
|
+
sidebarView.value = 'sessions';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Syntax highlighting helpers ──
|
|
79
|
+
|
|
80
|
+
const LANG_MAP = {
|
|
81
|
+
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
82
|
+
mjs: 'javascript', cjs: 'javascript', py: 'python', rb: 'ruby',
|
|
83
|
+
rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
|
|
84
|
+
cpp: 'cpp', hpp: 'cpp', cs: 'csharp', swift: 'swift', kt: 'kotlin',
|
|
85
|
+
lua: 'lua', r: 'r', sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
86
|
+
fish: 'bash', ps1: 'powershell', bat: 'dos', cmd: 'dos',
|
|
87
|
+
json: 'json', json5: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini',
|
|
88
|
+
xml: 'xml', html: 'xml', htm: 'xml', css: 'css', scss: 'scss', less: 'less',
|
|
89
|
+
md: 'markdown', txt: 'plaintext', log: 'plaintext', graphql: 'graphql',
|
|
90
|
+
proto: 'protobuf', vue: 'xml', svelte: 'xml', ini: 'ini', cfg: 'ini',
|
|
91
|
+
conf: 'ini', env: 'bash',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function detectLanguage(fileName) {
|
|
95
|
+
const ext = (fileName || '').split('.').pop()?.toLowerCase();
|
|
96
|
+
return LANG_MAP[ext] || ext || 'plaintext';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function highlightCode(code, fileName) {
|
|
100
|
+
if (!code) return '';
|
|
101
|
+
if (!window.hljs) return escapeHtml(code);
|
|
102
|
+
const lang = detectLanguage(fileName);
|
|
103
|
+
try {
|
|
104
|
+
return window.hljs.highlight(code, { language: lang }).value;
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
return window.hljs.highlightAuto(code).value;
|
|
108
|
+
} catch {
|
|
109
|
+
return escapeHtml(code);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function escapeHtml(str) {
|
|
115
|
+
return str
|
|
116
|
+
.replace(/&/g, '&')
|
|
117
|
+
.replace(/</g, '<')
|
|
118
|
+
.replace(/>/g, '>')
|
|
119
|
+
.replace(/"/g, '"');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── File size formatting ──
|
|
123
|
+
|
|
124
|
+
function formatFileSize(bytes) {
|
|
125
|
+
if (bytes == null) return '';
|
|
126
|
+
if (bytes < 1024) return bytes + ' B';
|
|
127
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
128
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Resize handle (mouse + touch) ──
|
|
132
|
+
|
|
133
|
+
let _resizing = false;
|
|
134
|
+
let _startX = 0;
|
|
135
|
+
let _startWidth = 0;
|
|
136
|
+
const MIN_WIDTH = 200;
|
|
137
|
+
const MAX_WIDTH = 800;
|
|
138
|
+
|
|
139
|
+
function onResizeStart(e) {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
_resizing = true;
|
|
142
|
+
_startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
|
143
|
+
_startWidth = previewPanelWidth.value;
|
|
144
|
+
document.body.style.cursor = 'col-resize';
|
|
145
|
+
document.body.style.userSelect = 'none';
|
|
146
|
+
if (e.type === 'touchstart') {
|
|
147
|
+
document.addEventListener('touchmove', onResizeMove, { passive: false });
|
|
148
|
+
document.addEventListener('touchend', onResizeEnd);
|
|
149
|
+
} else {
|
|
150
|
+
document.addEventListener('mousemove', onResizeMove);
|
|
151
|
+
document.addEventListener('mouseup', onResizeEnd);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onResizeMove(e) {
|
|
156
|
+
if (!_resizing) return;
|
|
157
|
+
if (e.type === 'touchmove') e.preventDefault();
|
|
158
|
+
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
|
159
|
+
// Left edge resize: dragging left = wider, dragging right = narrower
|
|
160
|
+
const delta = _startX - clientX;
|
|
161
|
+
const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, _startWidth + delta));
|
|
162
|
+
previewPanelWidth.value = newWidth;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onResizeEnd() {
|
|
166
|
+
if (!_resizing) return;
|
|
167
|
+
_resizing = false;
|
|
168
|
+
document.body.style.cursor = '';
|
|
169
|
+
document.body.style.userSelect = '';
|
|
170
|
+
document.removeEventListener('mousemove', onResizeMove);
|
|
171
|
+
document.removeEventListener('mouseup', onResizeEnd);
|
|
172
|
+
document.removeEventListener('touchmove', onResizeMove);
|
|
173
|
+
document.removeEventListener('touchend', onResizeEnd);
|
|
174
|
+
localStorage.setItem('agentlink-preview-panel-width', String(previewPanelWidth.value));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
openPreview,
|
|
179
|
+
closePreview,
|
|
180
|
+
handleFileContent,
|
|
181
|
+
onWorkdirChanged,
|
|
182
|
+
detectLanguage,
|
|
183
|
+
highlightCode,
|
|
184
|
+
formatFileSize,
|
|
185
|
+
onResizeStart,
|
|
186
|
+
};
|
|
187
|
+
}
|
package/web/style.css
CHANGED
|
@@ -2547,6 +2547,201 @@ body {
|
|
|
2547
2547
|
opacity: 0.8;
|
|
2548
2548
|
}
|
|
2549
2549
|
|
|
2550
|
+
/* ══════════════════════════════════════════
|
|
2551
|
+
File Preview Panel
|
|
2552
|
+
══════════════════════════════════════════ */
|
|
2553
|
+
.preview-panel {
|
|
2554
|
+
width: 400px;
|
|
2555
|
+
flex-shrink: 0;
|
|
2556
|
+
position: relative;
|
|
2557
|
+
display: flex;
|
|
2558
|
+
flex-direction: column;
|
|
2559
|
+
overflow: hidden;
|
|
2560
|
+
border-left: 1px solid var(--border);
|
|
2561
|
+
background: var(--bg);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
.preview-panel-resize-handle {
|
|
2565
|
+
position: absolute;
|
|
2566
|
+
top: 0;
|
|
2567
|
+
left: -3px;
|
|
2568
|
+
width: 6px;
|
|
2569
|
+
height: 100%;
|
|
2570
|
+
cursor: col-resize;
|
|
2571
|
+
z-index: 10;
|
|
2572
|
+
background: transparent;
|
|
2573
|
+
transition: background 0.15s;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
.preview-panel-resize-handle:hover,
|
|
2577
|
+
.preview-panel-resize-handle:active {
|
|
2578
|
+
background: var(--accent);
|
|
2579
|
+
opacity: 0.4;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
.preview-panel-header {
|
|
2583
|
+
display: flex;
|
|
2584
|
+
align-items: center;
|
|
2585
|
+
gap: 0.5rem;
|
|
2586
|
+
padding: 0.5rem 0.75rem;
|
|
2587
|
+
border-bottom: 1px solid var(--border);
|
|
2588
|
+
background: var(--bg-primary);
|
|
2589
|
+
flex-shrink: 0;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
.preview-panel-filename {
|
|
2593
|
+
flex: 1;
|
|
2594
|
+
font-weight: 600;
|
|
2595
|
+
font-size: 0.85rem;
|
|
2596
|
+
overflow: hidden;
|
|
2597
|
+
text-overflow: ellipsis;
|
|
2598
|
+
white-space: nowrap;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
.preview-panel-size {
|
|
2602
|
+
font-size: 0.75rem;
|
|
2603
|
+
color: var(--text-secondary);
|
|
2604
|
+
flex-shrink: 0;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
.preview-panel-close {
|
|
2608
|
+
background: none;
|
|
2609
|
+
border: none;
|
|
2610
|
+
font-size: 1.2rem;
|
|
2611
|
+
cursor: pointer;
|
|
2612
|
+
color: var(--text-secondary);
|
|
2613
|
+
padding: 0 0.25rem;
|
|
2614
|
+
line-height: 1;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
.preview-panel-close:hover {
|
|
2618
|
+
color: var(--text);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
.preview-panel-body {
|
|
2622
|
+
flex: 1;
|
|
2623
|
+
overflow: auto;
|
|
2624
|
+
padding: 0;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
.preview-loading {
|
|
2628
|
+
display: flex;
|
|
2629
|
+
align-items: center;
|
|
2630
|
+
justify-content: center;
|
|
2631
|
+
height: 100%;
|
|
2632
|
+
color: var(--text-secondary);
|
|
2633
|
+
font-size: 0.85rem;
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
.preview-error {
|
|
2637
|
+
padding: 1rem;
|
|
2638
|
+
color: var(--error, #ef4444);
|
|
2639
|
+
font-size: 0.85rem;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
.preview-text-container {
|
|
2643
|
+
overflow: auto;
|
|
2644
|
+
height: 100%;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
.preview-code {
|
|
2648
|
+
margin: 0;
|
|
2649
|
+
padding: 0.75rem;
|
|
2650
|
+
font-size: 0.8rem;
|
|
2651
|
+
line-height: 1.5;
|
|
2652
|
+
white-space: pre;
|
|
2653
|
+
overflow-x: auto;
|
|
2654
|
+
background: var(--bg);
|
|
2655
|
+
font-family: var(--font-mono, 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace);
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
.preview-code code {
|
|
2659
|
+
font-family: inherit;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
.preview-image-container {
|
|
2663
|
+
display: flex;
|
|
2664
|
+
align-items: center;
|
|
2665
|
+
justify-content: center;
|
|
2666
|
+
padding: 1rem;
|
|
2667
|
+
height: 100%;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
.preview-image {
|
|
2671
|
+
max-width: 100%;
|
|
2672
|
+
max-height: 100%;
|
|
2673
|
+
object-fit: contain;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
.preview-truncated-notice {
|
|
2677
|
+
padding: 0.5rem 0.75rem;
|
|
2678
|
+
font-size: 0.75rem;
|
|
2679
|
+
color: var(--text-secondary);
|
|
2680
|
+
border-top: 1px solid var(--border);
|
|
2681
|
+
background: var(--bg-secondary, var(--bg-primary));
|
|
2682
|
+
text-align: center;
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
.preview-binary-info {
|
|
2686
|
+
display: flex;
|
|
2687
|
+
flex-direction: column;
|
|
2688
|
+
align-items: center;
|
|
2689
|
+
justify-content: center;
|
|
2690
|
+
height: 100%;
|
|
2691
|
+
gap: 0.5rem;
|
|
2692
|
+
color: var(--text-secondary);
|
|
2693
|
+
font-size: 0.85rem;
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
.preview-binary-icon {
|
|
2697
|
+
opacity: 0.4;
|
|
2698
|
+
margin-bottom: 0.5rem;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
.preview-binary-meta {
|
|
2702
|
+
font-size: 0.75rem;
|
|
2703
|
+
color: var(--text-secondary);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
/* ── Mobile file preview in sidebar ── */
|
|
2707
|
+
.file-preview-mobile {
|
|
2708
|
+
display: flex;
|
|
2709
|
+
flex-direction: column;
|
|
2710
|
+
height: 100%;
|
|
2711
|
+
overflow: hidden;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
.file-preview-mobile-header {
|
|
2715
|
+
display: flex;
|
|
2716
|
+
align-items: center;
|
|
2717
|
+
justify-content: space-between;
|
|
2718
|
+
padding: 0.75rem;
|
|
2719
|
+
border-bottom: 1px solid var(--border);
|
|
2720
|
+
flex-shrink: 0;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
.file-preview-mobile-size {
|
|
2724
|
+
font-size: 0.75rem;
|
|
2725
|
+
color: var(--text-secondary);
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
.file-preview-mobile-filename {
|
|
2729
|
+
padding: 0.5rem 0.75rem;
|
|
2730
|
+
font-size: 0.8rem;
|
|
2731
|
+
font-weight: 600;
|
|
2732
|
+
color: var(--text);
|
|
2733
|
+
border-bottom: 1px solid var(--border);
|
|
2734
|
+
white-space: nowrap;
|
|
2735
|
+
overflow: hidden;
|
|
2736
|
+
text-overflow: ellipsis;
|
|
2737
|
+
flex-shrink: 0;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
.file-preview-mobile .preview-panel-body {
|
|
2741
|
+
flex: 1;
|
|
2742
|
+
overflow: auto;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2550
2745
|
/* ══════════════════════════════════════════
|
|
2551
2746
|
Medium screens — file panel narrower
|
|
2552
2747
|
══════════════════════════════════════════ */
|
|
@@ -2554,6 +2749,9 @@ body {
|
|
|
2554
2749
|
.file-panel {
|
|
2555
2750
|
max-width: clamp(200px, 20vw, 280px);
|
|
2556
2751
|
}
|
|
2752
|
+
.preview-panel {
|
|
2753
|
+
max-width: min(400px, 40vw);
|
|
2754
|
+
}
|
|
2557
2755
|
}
|
|
2558
2756
|
|
|
2559
2757
|
/* ══════════════════════════════════════════
|
|
@@ -2575,6 +2773,11 @@ body {
|
|
|
2575
2773
|
display: none;
|
|
2576
2774
|
}
|
|
2577
2775
|
|
|
2776
|
+
/* Preview panel hidden on mobile — shown inside sidebar instead */
|
|
2777
|
+
.preview-panel {
|
|
2778
|
+
display: none;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2578
2781
|
/* Sidebar as fixed overlay */
|
|
2579
2782
|
.sidebar {
|
|
2580
2783
|
position: fixed;
|