@poncho-ai/cli 0.38.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +167 -0
- package/dist/{chunk-U643TWFX.js → chunk-XCDN62XL.js} +1983 -135
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +1 -1
- package/dist/{run-interactive-ink-CE7U47S5.js → run-interactive-ink-W5YJS7UH.js} +1 -1
- package/package.json +4 -4
- package/src/cron-helpers.ts +13 -4
- package/src/index.ts +441 -21
- package/src/vfs-zip.ts +94 -0
- package/src/web-ui-client.ts +1028 -26
- package/src/web-ui-styles.ts +413 -15
- package/src/web-ui.ts +6 -1
package/src/web-ui-client.ts
CHANGED
|
@@ -31,6 +31,11 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
31
31
|
conversations: [],
|
|
32
32
|
activeConversationId: null,
|
|
33
33
|
activeMessages: [],
|
|
34
|
+
// Verbose dev (-v) only: mirror of conversation._harnessMessages plus
|
|
35
|
+
// the current view mode the user toggled to.
|
|
36
|
+
verboseDev: false,
|
|
37
|
+
viewMode: "user", // "user" | "harness"
|
|
38
|
+
harnessMessages: null,
|
|
34
39
|
isStreaming: false,
|
|
35
40
|
activeStreamAbortController: null,
|
|
36
41
|
activeStreamConversationId: null,
|
|
@@ -59,12 +64,19 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
59
64
|
open: false,
|
|
60
65
|
threadId: null,
|
|
61
66
|
parentMessageId: null,
|
|
62
|
-
parentMessage: null,
|
|
63
67
|
messages: [],
|
|
64
68
|
isStreaming: false,
|
|
65
69
|
abortController: null,
|
|
66
70
|
pendingFiles: [],
|
|
67
71
|
},
|
|
72
|
+
sidebarMode: "conversations",
|
|
73
|
+
expandedDirs: new Set(["/"]),
|
|
74
|
+
dirCache: new Map(),
|
|
75
|
+
activeFilePath: null,
|
|
76
|
+
pendingUploads: 0,
|
|
77
|
+
fileExplorerError: null,
|
|
78
|
+
fileExplorerUsage: null,
|
|
79
|
+
confirmDeletePath: null,
|
|
68
80
|
};
|
|
69
81
|
|
|
70
82
|
const agentInitial = document.body.dataset.agentInitial || "A";
|
|
@@ -80,6 +92,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
80
92
|
topbarNewChat: $("topbar-new-chat"),
|
|
81
93
|
messages: $("messages"),
|
|
82
94
|
chatTitle: $("chat-title"),
|
|
95
|
+
viewToggle: $("view-toggle"),
|
|
83
96
|
logout: $("logout"),
|
|
84
97
|
composer: $("composer"),
|
|
85
98
|
prompt: $("prompt"),
|
|
@@ -106,7 +119,6 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
106
119
|
threadPanel: $("thread-panel"),
|
|
107
120
|
threadPanelResize: $("thread-panel-resize"),
|
|
108
121
|
threadPanelClose: $("thread-panel-close"),
|
|
109
|
-
threadPanelParent: $("thread-panel-parent"),
|
|
110
122
|
threadPanelMessages: $("thread-panel-messages"),
|
|
111
123
|
threadComposer: $("thread-composer"),
|
|
112
124
|
threadAttachBtn: $("thread-attach-btn"),
|
|
@@ -114,6 +126,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
114
126
|
threadAttachmentPreview: $("thread-attachment-preview"),
|
|
115
127
|
threadPrompt: $("thread-prompt"),
|
|
116
128
|
threadSend: $("thread-send"),
|
|
129
|
+
fileExplorer: $("file-explorer"),
|
|
117
130
|
};
|
|
118
131
|
const sendIconMarkup =
|
|
119
132
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 12V4M4 7l4-4 4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
@@ -165,6 +178,25 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
165
178
|
return match ? decodeURIComponent(match[1]) : null;
|
|
166
179
|
};
|
|
167
180
|
|
|
181
|
+
const pushFileUrl = (filePath) => {
|
|
182
|
+
const target = filePath ? "/f/" + encodeURIComponent(filePath) : "/";
|
|
183
|
+
if (window.location.pathname !== target) {
|
|
184
|
+
history.pushState({ filePath: filePath || null }, "", target);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const replaceFileUrl = (filePath) => {
|
|
189
|
+
const target = filePath ? "/f/" + encodeURIComponent(filePath) : "/";
|
|
190
|
+
if (window.location.pathname !== target) {
|
|
191
|
+
history.replaceState({ filePath: filePath || null }, "", target);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const getFilePathFromUrl = () => {
|
|
196
|
+
const match = window.location.pathname.match(/^\\/f\\/(.+)/);
|
|
197
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
198
|
+
};
|
|
199
|
+
|
|
168
200
|
const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
169
201
|
|
|
170
202
|
const api = async (path, options = {}) => {
|
|
@@ -833,6 +865,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
833
865
|
}
|
|
834
866
|
var switchingFamily = state.subagentsParentId !== c.conversationId;
|
|
835
867
|
state.activeConversationId = c.conversationId;
|
|
868
|
+
state.activeFilePath = null;
|
|
869
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
836
870
|
state.viewingSubagentId = null;
|
|
837
871
|
state.parentConversationId = null;
|
|
838
872
|
if (switchingFamily) {
|
|
@@ -1437,24 +1471,6 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1437
1471
|
root.scrollTop = root.scrollHeight;
|
|
1438
1472
|
};
|
|
1439
1473
|
|
|
1440
|
-
const renderThreadPanelParent = () => {
|
|
1441
|
-
const root = elements.threadPanelParent;
|
|
1442
|
-
if (!root) return;
|
|
1443
|
-
root.innerHTML = "";
|
|
1444
|
-
const parent = state.threadPanel.parentMessage;
|
|
1445
|
-
if (!parent) {
|
|
1446
|
-
const empty = document.createElement("div");
|
|
1447
|
-
empty.className = "thread-panel-parent-empty";
|
|
1448
|
-
empty.textContent = "No parent context";
|
|
1449
|
-
root.appendChild(empty);
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
const col = document.createElement("div");
|
|
1453
|
-
col.className = "messages-column";
|
|
1454
|
-
col.appendChild(buildSimpleMessageRow(parent));
|
|
1455
|
-
root.appendChild(col);
|
|
1456
|
-
};
|
|
1457
|
-
|
|
1458
1474
|
const closeThreadPanel = () => {
|
|
1459
1475
|
if (state.threadPanel.abortController) {
|
|
1460
1476
|
try { state.threadPanel.abortController.abort(); } catch (e) {}
|
|
@@ -1462,7 +1478,6 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1462
1478
|
state.threadPanel.open = false;
|
|
1463
1479
|
state.threadPanel.threadId = null;
|
|
1464
1480
|
state.threadPanel.parentMessageId = null;
|
|
1465
|
-
state.threadPanel.parentMessage = null;
|
|
1466
1481
|
state.threadPanel.messages = [];
|
|
1467
1482
|
state.threadPanel.isStreaming = false;
|
|
1468
1483
|
state.threadPanel.abortController = null;
|
|
@@ -1491,14 +1506,15 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1491
1506
|
const renderActiveTopForThreadPanel = (payload) => {
|
|
1492
1507
|
const conv = payload.conversation || {};
|
|
1493
1508
|
const allMsgs = Array.isArray(conv.messages) ? conv.messages : [];
|
|
1509
|
+
// Show the anchor message + replies. The earlier snapshot is still
|
|
1510
|
+
// part of the thread's context server-side, but the panel only
|
|
1511
|
+
// displays what's relevant: the message you forked on, plus what
|
|
1512
|
+
// came after.
|
|
1494
1513
|
const snapshotLength = (conv.threadMeta && typeof conv.threadMeta.snapshotLength === "number")
|
|
1495
1514
|
? conv.threadMeta.snapshotLength
|
|
1496
1515
|
: allMsgs.length;
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
state.threadPanel.parentMessage = parent;
|
|
1500
|
-
state.threadPanel.messages = replies;
|
|
1501
|
-
renderThreadPanelParent();
|
|
1516
|
+
const startIdx = Math.max(0, snapshotLength - 1);
|
|
1517
|
+
state.threadPanel.messages = allMsgs.slice(startIdx);
|
|
1502
1518
|
renderThreadPanelMessages();
|
|
1503
1519
|
};
|
|
1504
1520
|
|
|
@@ -1757,7 +1773,66 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1757
1773
|
}
|
|
1758
1774
|
};
|
|
1759
1775
|
|
|
1776
|
+
const renderHarnessView = () => {
|
|
1777
|
+
const msgs = Array.isArray(state.harnessMessages) ? state.harnessMessages : [];
|
|
1778
|
+
if (msgs.length === 0) {
|
|
1779
|
+
elements.messages.innerHTML = '<div class="harness-debug-view"><em>No harness messages yet — they appear after the first assistant turn.</em></div>';
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const rows = msgs.map((m, i) => {
|
|
1783
|
+
const role = String(m.role || "?");
|
|
1784
|
+
const meta = m.metadata && typeof m.metadata === "object" ? m.metadata : null;
|
|
1785
|
+
const metaLine = meta
|
|
1786
|
+
? "step=" + (meta.step != null ? meta.step : "-") +
|
|
1787
|
+
" runId=" + (meta.runId ? String(meta.runId).slice(0, 16) : "-") +
|
|
1788
|
+
" id=" + (meta.id ? String(meta.id).slice(0, 12) : "-")
|
|
1789
|
+
: "";
|
|
1790
|
+
let content = m.content;
|
|
1791
|
+
if (typeof content !== "string") content = JSON.stringify(content, null, 2);
|
|
1792
|
+
// Pretty-print JSON content where possible (assistant tool calls,
|
|
1793
|
+
// tool result arrays, etc.) so it's actually readable.
|
|
1794
|
+
const trimmed = content.trim();
|
|
1795
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1796
|
+
try { content = JSON.stringify(JSON.parse(trimmed), null, 2); } catch (_e) { /* leave as-is */ }
|
|
1797
|
+
}
|
|
1798
|
+
return '<div class="hd-msg role-' + escapeHtml(role) + '">' +
|
|
1799
|
+
'<div class="hd-role">#' + i + ' · ' + escapeHtml(role) + '</div>' +
|
|
1800
|
+
(metaLine ? '<div class="hd-meta">' + escapeHtml(metaLine) + '</div>' : '') +
|
|
1801
|
+
'<div>' + escapeHtml(content) + '</div>' +
|
|
1802
|
+
'</div>';
|
|
1803
|
+
}).join("");
|
|
1804
|
+
elements.messages.innerHTML = '<div class="harness-debug-view">' + rows + '</div>';
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
const updateViewToggleVisibility = () => {
|
|
1808
|
+
if (!elements.viewToggle) return;
|
|
1809
|
+
if (!state.verboseDev) {
|
|
1810
|
+
elements.viewToggle.hidden = true;
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
elements.viewToggle.hidden = false;
|
|
1814
|
+
elements.viewToggle.textContent = state.viewMode === "harness" ? "harness view" : "user view";
|
|
1815
|
+
elements.viewToggle.classList.toggle("is-harness", state.viewMode === "harness");
|
|
1816
|
+
};
|
|
1817
|
+
|
|
1818
|
+
if (elements.viewToggle) {
|
|
1819
|
+
elements.viewToggle.addEventListener("click", () => {
|
|
1820
|
+
state.viewMode = state.viewMode === "harness" ? "user" : "harness";
|
|
1821
|
+
updateViewToggleVisibility();
|
|
1822
|
+
if (state.viewMode === "harness") {
|
|
1823
|
+
renderHarnessView();
|
|
1824
|
+
} else {
|
|
1825
|
+
renderMessages(state.activeMessages, state.isStreaming);
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1760
1830
|
const renderMessages = (messages, isStreaming = false, options = {}) => {
|
|
1831
|
+
// In harness debug view, the user-facing renderer is bypassed.
|
|
1832
|
+
if (state.viewMode === "harness") {
|
|
1833
|
+
renderHarnessView();
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1761
1836
|
const previousScrollTop = elements.messages.scrollTop;
|
|
1762
1837
|
const shouldStickToBottom =
|
|
1763
1838
|
options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
|
|
@@ -2021,6 +2096,13 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
2021
2096
|
.catch(() => ({ threads: [] }));
|
|
2022
2097
|
const payload = await conversationPromise;
|
|
2023
2098
|
elements.chatTitle.textContent = payload.conversation.title;
|
|
2099
|
+
// Verbose dev (-v) only — server includes verboseDev: true and the
|
|
2100
|
+
// raw harness-message stream so we can offer a debug toggle.
|
|
2101
|
+
state.verboseDev = payload.verboseDev === true;
|
|
2102
|
+
state.harnessMessages = state.verboseDev && Array.isArray(payload.conversation._harnessMessages)
|
|
2103
|
+
? payload.conversation._harnessMessages
|
|
2104
|
+
: null;
|
|
2105
|
+
updateViewToggleVisibility();
|
|
2024
2106
|
// Merge own pending approvals + subagent pending approvals
|
|
2025
2107
|
var allPendingApprovals = [].concat(
|
|
2026
2108
|
payload.conversation.pendingApprovals || payload.pendingApprovals || [],
|
|
@@ -2319,6 +2401,12 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
2319
2401
|
if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
|
|
2320
2402
|
state.contextWindow = payload.conversation.contextWindow;
|
|
2321
2403
|
}
|
|
2404
|
+
// Keep harness debug view fresh on refetches in -v mode.
|
|
2405
|
+
state.verboseDev = payload.verboseDev === true;
|
|
2406
|
+
state.harnessMessages = state.verboseDev && Array.isArray(payload.conversation._harnessMessages)
|
|
2407
|
+
? payload.conversation._harnessMessages
|
|
2408
|
+
: null;
|
|
2409
|
+
updateViewToggleVisibility();
|
|
2322
2410
|
updateContextRing();
|
|
2323
2411
|
renderMessages(state.activeMessages, streaming);
|
|
2324
2412
|
return payload;
|
|
@@ -3983,6 +4071,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
3983
4071
|
const startNewChat = () => {
|
|
3984
4072
|
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
3985
4073
|
state.activeConversationId = null;
|
|
4074
|
+
state.activeFilePath = null;
|
|
4075
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
3986
4076
|
state.activeMessages = [];
|
|
3987
4077
|
state.confirmDeleteId = null;
|
|
3988
4078
|
state.contextTokens = 0;
|
|
@@ -4321,13 +4411,17 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4321
4411
|
});
|
|
4322
4412
|
|
|
4323
4413
|
let dragCounter = 0;
|
|
4414
|
+
const _inFileExplorer = (target) =>
|
|
4415
|
+
elements.fileExplorer && target instanceof Node && elements.fileExplorer.contains(target);
|
|
4324
4416
|
document.addEventListener("dragenter", (e) => {
|
|
4325
4417
|
e.preventDefault();
|
|
4418
|
+
if (_inFileExplorer(e.target)) return;
|
|
4326
4419
|
dragCounter++;
|
|
4327
4420
|
if (dragCounter === 1) elements.dragOverlay.classList.add("active");
|
|
4328
4421
|
});
|
|
4329
4422
|
document.addEventListener("dragleave", (e) => {
|
|
4330
4423
|
e.preventDefault();
|
|
4424
|
+
if (_inFileExplorer(e.target)) return;
|
|
4331
4425
|
dragCounter--;
|
|
4332
4426
|
if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
|
|
4333
4427
|
});
|
|
@@ -4336,6 +4430,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4336
4430
|
e.preventDefault();
|
|
4337
4431
|
dragCounter = 0;
|
|
4338
4432
|
elements.dragOverlay.classList.remove("active");
|
|
4433
|
+
if (_inFileExplorer(e.target)) return;
|
|
4339
4434
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
|
4340
4435
|
addFiles(e.dataTransfer.files);
|
|
4341
4436
|
}
|
|
@@ -4598,6 +4693,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4598
4693
|
});
|
|
4599
4694
|
|
|
4600
4695
|
const navigateToConversation = async (conversationId) => {
|
|
4696
|
+
state.activeFilePath = null;
|
|
4697
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
4601
4698
|
if (conversationId) {
|
|
4602
4699
|
state.activeConversationId = conversationId;
|
|
4603
4700
|
renderConversationList();
|
|
@@ -4624,8 +4721,893 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4624
4721
|
}
|
|
4625
4722
|
};
|
|
4626
4723
|
|
|
4724
|
+
// ----- File explorer (sidebar Files mode) -----
|
|
4725
|
+
|
|
4726
|
+
const formatBytes = (n) => {
|
|
4727
|
+
if (typeof n !== "number" || !isFinite(n)) return "";
|
|
4728
|
+
if (n < 1024) return n + " B";
|
|
4729
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
4730
|
+
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
|
|
4731
|
+
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
|
4732
|
+
};
|
|
4733
|
+
|
|
4734
|
+
const joinPath = (parent, name) => {
|
|
4735
|
+
if (parent === "/") return "/" + name;
|
|
4736
|
+
return parent + "/" + name;
|
|
4737
|
+
};
|
|
4738
|
+
|
|
4739
|
+
const parentPath = (p) => {
|
|
4740
|
+
if (!p || p === "/") return "/";
|
|
4741
|
+
const idx = p.lastIndexOf("/");
|
|
4742
|
+
if (idx <= 0) return "/";
|
|
4743
|
+
return p.slice(0, idx);
|
|
4744
|
+
};
|
|
4745
|
+
|
|
4746
|
+
const TEXT_LIKE_MIME_PREFIXES = ["text/"];
|
|
4747
|
+
const TEXT_LIKE_MIME_EXACT = new Set([
|
|
4748
|
+
"application/json",
|
|
4749
|
+
"application/javascript",
|
|
4750
|
+
"application/xml",
|
|
4751
|
+
"application/x-sh",
|
|
4752
|
+
"application/x-yaml",
|
|
4753
|
+
"application/yaml",
|
|
4754
|
+
"application/toml",
|
|
4755
|
+
"application/x-www-form-urlencoded",
|
|
4756
|
+
]);
|
|
4757
|
+
const TEXT_LIKE_EXTENSIONS = new Set([
|
|
4758
|
+
"md","txt","log","csv","tsv","js","mjs","cjs","jsx","ts","tsx","json","yaml","yml","toml",
|
|
4759
|
+
"xml","html","htm","css","scss","sass","less","sh","bash","zsh","py","rb","go","rs","java",
|
|
4760
|
+
"kt","swift","c","cpp","h","hpp","sql","env","ini","conf","cfg","gitignore","editorconfig",
|
|
4761
|
+
]);
|
|
4762
|
+
|
|
4763
|
+
const categorizePreview = (mime, name) => {
|
|
4764
|
+
const m = (mime || "").toLowerCase();
|
|
4765
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
4766
|
+
if (m === "text/html" || ext === "html" || ext === "htm") return "html";
|
|
4767
|
+
if (m.startsWith("image/")) return "image";
|
|
4768
|
+
if (m === "application/pdf") return "pdf";
|
|
4769
|
+
if (m.startsWith("audio/")) return "audio";
|
|
4770
|
+
if (m.startsWith("video/")) return "video";
|
|
4771
|
+
for (const p of TEXT_LIKE_MIME_PREFIXES) if (m.startsWith(p)) return "text";
|
|
4772
|
+
if (TEXT_LIKE_MIME_EXACT.has(m)) return "text";
|
|
4773
|
+
if (TEXT_LIKE_EXTENSIONS.has(ext)) return "text";
|
|
4774
|
+
if (!m && (ext === "" || /^[a-z0-9]+$/i.test(ext))) return "text-maybe";
|
|
4775
|
+
return "binary";
|
|
4776
|
+
};
|
|
4777
|
+
|
|
4778
|
+
const TEXT_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
|
4779
|
+
|
|
4780
|
+
const folderIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 4.5a1 1 0 0 1 1-1h3l1.5 1.5h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-10.5a1 1 0 0 1-1-1v-7.5z"/></svg>';
|
|
4781
|
+
const fileIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 1.5H3.5a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V6L9 1.5z"/><path d="M9 1.5V6h4.5"/></svg>';
|
|
4782
|
+
const downloadIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M5 7l3 3 3-3M2.5 12.5v.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-.5"/></svg>';
|
|
4783
|
+
const refreshIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 8a6 6 0 0 1 10.5-4M14 8a6 6 0 0 1-10.5 4"/><path d="M12.5 1.5v3h-3M3.5 14.5v-3h3"/></svg>';
|
|
4784
|
+
const closeIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
|
4785
|
+
const caretIconSvg = '<svg viewBox="0 0 12 12" fill="none" width="10" height="10"><path d="M4.5 2.75L8 6L4.5 9.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
|
|
4786
|
+
const uploadIconSvg = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3M2.5 12.5v.5a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-.5"/></svg>';
|
|
4787
|
+
const fetchDirEntries = async (dirPath) => {
|
|
4788
|
+
const qs = "?path=" + encodeURIComponent(dirPath);
|
|
4789
|
+
const data = await api("/api/vfs-list" + qs);
|
|
4790
|
+
state.dirCache.set(dirPath, data.entries || []);
|
|
4791
|
+
if (data.usage) state.fileExplorerUsage = data.usage;
|
|
4792
|
+
return data.entries || [];
|
|
4793
|
+
};
|
|
4794
|
+
|
|
4795
|
+
const ensureDirLoaded = async (dirPath) => {
|
|
4796
|
+
if (state.dirCache.has(dirPath)) return;
|
|
4797
|
+
try {
|
|
4798
|
+
await fetchDirEntries(dirPath);
|
|
4799
|
+
state.fileExplorerError = null;
|
|
4800
|
+
} catch (err) {
|
|
4801
|
+
state.fileExplorerError = err.message || "Failed to load directory";
|
|
4802
|
+
}
|
|
4803
|
+
};
|
|
4804
|
+
|
|
4805
|
+
const renderFileExplorer = () => {
|
|
4806
|
+
const root = elements.fileExplorer;
|
|
4807
|
+
if (!root) return;
|
|
4808
|
+
root.innerHTML = "";
|
|
4809
|
+
|
|
4810
|
+
// Pending uploads spinner row
|
|
4811
|
+
if (state.pendingUploads > 0) {
|
|
4812
|
+
const row = document.createElement("div");
|
|
4813
|
+
row.className = "file-upload-row";
|
|
4814
|
+
row.innerHTML = '<span class="file-upload-spinner"></span><span>Uploading ' + state.pendingUploads + (state.pendingUploads === 1 ? ' file…' : ' files…') + '</span>';
|
|
4815
|
+
root.appendChild(row);
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
// Error row
|
|
4819
|
+
if (state.fileExplorerError) {
|
|
4820
|
+
const err = document.createElement("div");
|
|
4821
|
+
err.className = "file-explorer-error";
|
|
4822
|
+
err.textContent = state.fileExplorerError;
|
|
4823
|
+
const retry = document.createElement("button");
|
|
4824
|
+
retry.textContent = "Retry";
|
|
4825
|
+
retry.onclick = async () => {
|
|
4826
|
+
state.fileExplorerError = null;
|
|
4827
|
+
state.dirCache.clear();
|
|
4828
|
+
for (const dir of Array.from(state.expandedDirs)) {
|
|
4829
|
+
try { await fetchDirEntries(dir); } catch {}
|
|
4830
|
+
}
|
|
4831
|
+
renderFileExplorer();
|
|
4832
|
+
};
|
|
4833
|
+
err.appendChild(document.createElement("br"));
|
|
4834
|
+
err.appendChild(retry);
|
|
4835
|
+
root.appendChild(err);
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
// Tree
|
|
4839
|
+
const tree = document.createElement("div");
|
|
4840
|
+
tree.className = "file-children";
|
|
4841
|
+
tree.dataset.dir = "/";
|
|
4842
|
+
renderDirInto(tree, "/", 0);
|
|
4843
|
+
root.appendChild(tree);
|
|
4844
|
+
|
|
4845
|
+
// Footer (status bar with usage + upload)
|
|
4846
|
+
const footer = document.createElement("div");
|
|
4847
|
+
footer.className = "file-explorer-footer";
|
|
4848
|
+
const usageEl = document.createElement("div");
|
|
4849
|
+
usageEl.className = "file-explorer-usage";
|
|
4850
|
+
if (state.fileExplorerUsage) {
|
|
4851
|
+
const u = state.fileExplorerUsage;
|
|
4852
|
+
const countEl = document.createElement("span");
|
|
4853
|
+
countEl.textContent = u.fileCount + " file" + (u.fileCount === 1 ? "" : "s");
|
|
4854
|
+
const sizeEl = document.createElement("span");
|
|
4855
|
+
sizeEl.textContent = formatBytes(u.totalBytes);
|
|
4856
|
+
usageEl.append(countEl, sizeEl);
|
|
4857
|
+
}
|
|
4858
|
+
const refreshBtn = document.createElement("button");
|
|
4859
|
+
refreshBtn.className = "file-explorer-icon-btn";
|
|
4860
|
+
refreshBtn.title = "Refresh";
|
|
4861
|
+
refreshBtn.innerHTML = refreshIconSvg;
|
|
4862
|
+
refreshBtn.onclick = async () => {
|
|
4863
|
+
state.dirCache.clear();
|
|
4864
|
+
for (const dir of Array.from(state.expandedDirs)) {
|
|
4865
|
+
try { await fetchDirEntries(dir); } catch {}
|
|
4866
|
+
}
|
|
4867
|
+
renderFileExplorer();
|
|
4868
|
+
};
|
|
4869
|
+
const uploadBtn = document.createElement("button");
|
|
4870
|
+
uploadBtn.className = "file-explorer-upload";
|
|
4871
|
+
uploadBtn.title = "Upload files";
|
|
4872
|
+
uploadBtn.innerHTML = uploadIconSvg + '<span>Upload</span>';
|
|
4873
|
+
uploadBtn.onclick = () => triggerUploadPicker("/");
|
|
4874
|
+
footer.append(usageEl, refreshBtn, uploadBtn);
|
|
4875
|
+
root.appendChild(footer);
|
|
4876
|
+
};
|
|
4877
|
+
|
|
4878
|
+
const renderDirInto = (container, dirPath, depth) => {
|
|
4879
|
+
const entries = state.dirCache.get(dirPath);
|
|
4880
|
+
if (!entries) {
|
|
4881
|
+
// Lazy fetch for newly expanded dir
|
|
4882
|
+
ensureDirLoaded(dirPath).then(() => renderFileExplorer());
|
|
4883
|
+
if (dirPath !== "/") {
|
|
4884
|
+
const loading = document.createElement("div");
|
|
4885
|
+
loading.className = "file-explorer-empty";
|
|
4886
|
+
loading.style.paddingLeft = (16 + depth * 14) + "px";
|
|
4887
|
+
loading.textContent = "Loading…";
|
|
4888
|
+
container.appendChild(loading);
|
|
4889
|
+
}
|
|
4890
|
+
return;
|
|
4891
|
+
}
|
|
4892
|
+
if (entries.length === 0 && dirPath === "/") {
|
|
4893
|
+
const empty = document.createElement("div");
|
|
4894
|
+
empty.className = "file-explorer-empty";
|
|
4895
|
+
empty.innerHTML = "No files yet. Tools like " + _TK + "write_file" + _TK + " and " + _TK + "bash" + _TK + " will populate this.";
|
|
4896
|
+
container.appendChild(empty);
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4899
|
+
for (const entry of entries) {
|
|
4900
|
+
const childPath = joinPath(dirPath, entry.name);
|
|
4901
|
+
const row = document.createElement("div");
|
|
4902
|
+
row.className = "file-row" + (entry.type === "directory" ? " is-dir" : "") + (state.activeFilePath === childPath ? " active" : "");
|
|
4903
|
+
row.style.paddingLeft = (8 + depth * 14) + "px";
|
|
4904
|
+
row.dataset.path = childPath;
|
|
4905
|
+
row.dataset.type = entry.type;
|
|
4906
|
+
|
|
4907
|
+
const caret = document.createElement("span");
|
|
4908
|
+
caret.className = "file-caret" + (entry.type === "directory" ? "" : " empty") + (state.expandedDirs.has(childPath) ? " open" : "");
|
|
4909
|
+
caret.innerHTML = caretIconSvg;
|
|
4910
|
+
row.appendChild(caret);
|
|
4911
|
+
|
|
4912
|
+
const icon = document.createElement("span");
|
|
4913
|
+
icon.className = "file-icon";
|
|
4914
|
+
icon.innerHTML = entry.type === "directory" ? folderIconSvg : fileIconSvg;
|
|
4915
|
+
row.appendChild(icon);
|
|
4916
|
+
|
|
4917
|
+
const name = document.createElement("span");
|
|
4918
|
+
name.className = "file-name";
|
|
4919
|
+
name.textContent = entry.name;
|
|
4920
|
+
row.appendChild(name);
|
|
4921
|
+
|
|
4922
|
+
if (entry.type === "directory") {
|
|
4923
|
+
row.onclick = (e) => {
|
|
4924
|
+
e.stopPropagation();
|
|
4925
|
+
if (state.confirmDeletePath) { state.confirmDeletePath = null; renderFileExplorer(); return; }
|
|
4926
|
+
if (state.expandedDirs.has(childPath)) {
|
|
4927
|
+
state.expandedDirs.delete(childPath);
|
|
4928
|
+
} else {
|
|
4929
|
+
state.expandedDirs.add(childPath);
|
|
4930
|
+
}
|
|
4931
|
+
renderFileExplorer();
|
|
4932
|
+
};
|
|
4933
|
+
} else {
|
|
4934
|
+
row.onclick = (e) => {
|
|
4935
|
+
e.stopPropagation();
|
|
4936
|
+
if (state.confirmDeletePath) { state.confirmDeletePath = null; renderFileExplorer(); return; }
|
|
4937
|
+
selectFile(childPath);
|
|
4938
|
+
};
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
const isConfirming = state.confirmDeletePath === childPath;
|
|
4942
|
+
const actions = document.createElement("div");
|
|
4943
|
+
actions.className = "file-row-actions" + (isConfirming ? " confirming" : "");
|
|
4944
|
+
|
|
4945
|
+
const dl = document.createElement("a");
|
|
4946
|
+
dl.className = "file-row-action";
|
|
4947
|
+
if (entry.type === "directory") {
|
|
4948
|
+
dl.href = "/api/vfs-archive?path=" + encodeURIComponent(childPath);
|
|
4949
|
+
dl.title = "Download as zip";
|
|
4950
|
+
dl.setAttribute("download", entry.name + ".zip");
|
|
4951
|
+
} else {
|
|
4952
|
+
dl.href = "/api/vfs/" + encodeURI(childPath.replace(/^\\//, ""));
|
|
4953
|
+
dl.title = "Download";
|
|
4954
|
+
dl.setAttribute("download", entry.name);
|
|
4955
|
+
}
|
|
4956
|
+
dl.innerHTML = downloadIconSvg;
|
|
4957
|
+
dl.onclick = (e) => { e.stopPropagation(); };
|
|
4958
|
+
actions.appendChild(dl);
|
|
4959
|
+
|
|
4960
|
+
const del = document.createElement("button");
|
|
4961
|
+
del.className = "file-row-action file-delete" + (isConfirming ? " confirming" : "");
|
|
4962
|
+
if (isConfirming) del.textContent = "sure?";
|
|
4963
|
+
else del.innerHTML = closeIconSvg;
|
|
4964
|
+
del.title = "Delete";
|
|
4965
|
+
del.onclick = async (e) => {
|
|
4966
|
+
e.stopPropagation();
|
|
4967
|
+
if (!isConfirming) {
|
|
4968
|
+
state.confirmDeletePath = childPath;
|
|
4969
|
+
renderFileExplorer();
|
|
4970
|
+
return;
|
|
4971
|
+
}
|
|
4972
|
+
state.confirmDeletePath = null;
|
|
4973
|
+
await deleteEntry(childPath, entry.type, dirPath);
|
|
4974
|
+
};
|
|
4975
|
+
actions.appendChild(del);
|
|
4976
|
+
row.appendChild(actions);
|
|
4977
|
+
|
|
4978
|
+
container.appendChild(row);
|
|
4979
|
+
|
|
4980
|
+
if (entry.type === "directory" && state.expandedDirs.has(childPath)) {
|
|
4981
|
+
const childWrap = document.createElement("div");
|
|
4982
|
+
childWrap.className = "file-children";
|
|
4983
|
+
childWrap.dataset.dir = childPath;
|
|
4984
|
+
renderDirInto(childWrap, childPath, depth + 1);
|
|
4985
|
+
container.appendChild(childWrap);
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
};
|
|
4989
|
+
|
|
4990
|
+
const selectFile = async (filePath) => {
|
|
4991
|
+
state.activeFilePath = filePath;
|
|
4992
|
+
state.activeConversationId = null;
|
|
4993
|
+
pushFileUrl(filePath);
|
|
4994
|
+
updateComposerVisibility();
|
|
4995
|
+
renderFileExplorer();
|
|
4996
|
+
await renderFilePreview(filePath);
|
|
4997
|
+
};
|
|
4998
|
+
|
|
4999
|
+
const renderFilePreview = async (filePath) => {
|
|
5000
|
+
const messages = elements.messages;
|
|
5001
|
+
if (!messages) return;
|
|
5002
|
+
const filename = filePath.split("/").pop() || filePath;
|
|
5003
|
+
elements.chatTitle.textContent = filename;
|
|
5004
|
+
messages.innerHTML = '<div class="file-preview"><div class="file-explorer-empty">Loading…</div></div>';
|
|
5005
|
+
let response;
|
|
5006
|
+
try {
|
|
5007
|
+
response = await fetch("/api/vfs/" + encodeURI(filePath.replace(/^\\//, "")), {
|
|
5008
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
5009
|
+
headers: buildAuthHeaders(),
|
|
5010
|
+
});
|
|
5011
|
+
} catch (err) {
|
|
5012
|
+
renderPreviewError(filePath, "Network error");
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
if (!response.ok) {
|
|
5016
|
+
renderPreviewError(filePath, "HTTP " + response.status);
|
|
5017
|
+
return;
|
|
5018
|
+
}
|
|
5019
|
+
const mime = (response.headers.get("content-type") || "").split(";")[0].trim();
|
|
5020
|
+
const sizeHeader = response.headers.get("content-length");
|
|
5021
|
+
const size = sizeHeader ? parseInt(sizeHeader, 10) : 0;
|
|
5022
|
+
const category = categorizePreview(mime, filename);
|
|
5023
|
+
|
|
5024
|
+
if (category === "text" || category === "text-maybe") {
|
|
5025
|
+
if (size && size > TEXT_PREVIEW_MAX_BYTES) {
|
|
5026
|
+
renderPreviewPlaceholder(filePath, mime, size, "Text file too large to preview inline.");
|
|
5027
|
+
return;
|
|
5028
|
+
}
|
|
5029
|
+
const buf = await response.arrayBuffer();
|
|
5030
|
+
if (buf.byteLength > TEXT_PREVIEW_MAX_BYTES) {
|
|
5031
|
+
renderPreviewPlaceholder(filePath, mime, buf.byteLength, "Text file too large to preview inline.");
|
|
5032
|
+
return;
|
|
5033
|
+
}
|
|
5034
|
+
let text;
|
|
5035
|
+
try {
|
|
5036
|
+
text = new TextDecoder("utf-8", { fatal: category === "text-maybe" }).decode(buf);
|
|
5037
|
+
} catch {
|
|
5038
|
+
renderPreviewPlaceholder(filePath, mime, buf.byteLength, "Not a text file.");
|
|
5039
|
+
return;
|
|
5040
|
+
}
|
|
5041
|
+
renderTextView(filePath, text, mime || "text/plain");
|
|
5042
|
+
return;
|
|
5043
|
+
}
|
|
5044
|
+
if (category === "image") {
|
|
5045
|
+
const blob = await response.blob();
|
|
5046
|
+
const url = URL.createObjectURL(blob);
|
|
5047
|
+
const wrap = document.createElement("div");
|
|
5048
|
+
wrap.className = "file-preview";
|
|
5049
|
+
const inner = document.createElement("div");
|
|
5050
|
+
inner.className = "file-preview-image";
|
|
5051
|
+
const img = document.createElement("img");
|
|
5052
|
+
img.src = url;
|
|
5053
|
+
img.alt = filename;
|
|
5054
|
+
img.onload = () => URL.revokeObjectURL(url);
|
|
5055
|
+
inner.appendChild(img);
|
|
5056
|
+
wrap.append(buildPreviewActions(filePath), inner);
|
|
5057
|
+
messages.innerHTML = "";
|
|
5058
|
+
messages.appendChild(wrap);
|
|
5059
|
+
return;
|
|
5060
|
+
}
|
|
5061
|
+
if (category === "pdf") {
|
|
5062
|
+
const blob = await response.blob();
|
|
5063
|
+
const url = URL.createObjectURL(blob);
|
|
5064
|
+
const wrap = document.createElement("div");
|
|
5065
|
+
wrap.className = "file-preview";
|
|
5066
|
+
const inner = document.createElement("div");
|
|
5067
|
+
inner.className = "file-preview-pdf";
|
|
5068
|
+
const iframe = document.createElement("iframe");
|
|
5069
|
+
iframe.src = url;
|
|
5070
|
+
iframe.title = filename;
|
|
5071
|
+
inner.appendChild(iframe);
|
|
5072
|
+
wrap.append(buildPreviewActions(filePath), inner);
|
|
5073
|
+
messages.innerHTML = "";
|
|
5074
|
+
messages.appendChild(wrap);
|
|
5075
|
+
return;
|
|
5076
|
+
}
|
|
5077
|
+
if (category === "html") {
|
|
5078
|
+
const blob = await response.blob();
|
|
5079
|
+
const url = URL.createObjectURL(blob);
|
|
5080
|
+
const wrap = document.createElement("div");
|
|
5081
|
+
wrap.className = "file-preview";
|
|
5082
|
+
const inner = document.createElement("div");
|
|
5083
|
+
inner.className = "file-preview-pdf";
|
|
5084
|
+
const iframe = document.createElement("iframe");
|
|
5085
|
+
iframe.src = url;
|
|
5086
|
+
iframe.title = filename;
|
|
5087
|
+
iframe.setAttribute("sandbox", "");
|
|
5088
|
+
inner.appendChild(iframe);
|
|
5089
|
+
wrap.append(buildPreviewActions(filePath), inner);
|
|
5090
|
+
messages.innerHTML = "";
|
|
5091
|
+
messages.appendChild(wrap);
|
|
5092
|
+
return;
|
|
5093
|
+
}
|
|
5094
|
+
if (category === "audio" || category === "video") {
|
|
5095
|
+
const blob = await response.blob();
|
|
5096
|
+
const url = URL.createObjectURL(blob);
|
|
5097
|
+
const wrap = document.createElement("div");
|
|
5098
|
+
wrap.className = "file-preview";
|
|
5099
|
+
const inner = document.createElement("div");
|
|
5100
|
+
inner.className = "file-preview-media";
|
|
5101
|
+
const media = document.createElement(category);
|
|
5102
|
+
media.src = url;
|
|
5103
|
+
media.controls = true;
|
|
5104
|
+
media.style.maxWidth = "100%";
|
|
5105
|
+
media.style.maxHeight = "100%";
|
|
5106
|
+
inner.appendChild(media);
|
|
5107
|
+
wrap.append(buildPreviewActions(filePath), inner);
|
|
5108
|
+
messages.innerHTML = "";
|
|
5109
|
+
messages.appendChild(wrap);
|
|
5110
|
+
return;
|
|
5111
|
+
}
|
|
5112
|
+
renderPreviewPlaceholder(filePath, mime, size, "This file type can't be previewed.");
|
|
5113
|
+
};
|
|
5114
|
+
|
|
5115
|
+
const buildDownloadLink = (filePath) => {
|
|
5116
|
+
const filename = filePath.split("/").pop() || "download";
|
|
5117
|
+
const a = document.createElement("a");
|
|
5118
|
+
a.className = "file-preview-action-btn";
|
|
5119
|
+
a.href = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
|
|
5120
|
+
a.textContent = "Download";
|
|
5121
|
+
a.setAttribute("download", filename);
|
|
5122
|
+
return a;
|
|
5123
|
+
};
|
|
5124
|
+
|
|
5125
|
+
const buildPreviewActions = (filePath, leftExtras = [], rightExtras = []) => {
|
|
5126
|
+
const actions = document.createElement("div");
|
|
5127
|
+
actions.className = "file-preview-actions";
|
|
5128
|
+
const left = document.createElement("div");
|
|
5129
|
+
left.className = "file-preview-actions-group";
|
|
5130
|
+
left.appendChild(buildDownloadLink(filePath));
|
|
5131
|
+
left.appendChild(buildCopyLinkButton(filePath));
|
|
5132
|
+
for (const el of leftExtras) left.appendChild(el);
|
|
5133
|
+
const right = document.createElement("div");
|
|
5134
|
+
right.className = "file-preview-actions-group";
|
|
5135
|
+
for (const el of rightExtras) right.appendChild(el);
|
|
5136
|
+
actions.append(left, right);
|
|
5137
|
+
return actions;
|
|
5138
|
+
};
|
|
5139
|
+
|
|
5140
|
+
const csvDelimiter = (mime, name) => {
|
|
5141
|
+
const m = (mime || "").toLowerCase();
|
|
5142
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
5143
|
+
if (m === "text/csv" || ext === "csv") return ",";
|
|
5144
|
+
if (m === "text/tab-separated-values" || ext === "tsv") return "\\t";
|
|
5145
|
+
return null;
|
|
5146
|
+
};
|
|
5147
|
+
|
|
5148
|
+
const parseDelimited = (text, delimiter) => {
|
|
5149
|
+
const rows = [];
|
|
5150
|
+
let row = [];
|
|
5151
|
+
let field = "";
|
|
5152
|
+
let inQuotes = false;
|
|
5153
|
+
for (let i = 0; i < text.length; i++) {
|
|
5154
|
+
const c = text[i];
|
|
5155
|
+
if (inQuotes) {
|
|
5156
|
+
if (c === '"') {
|
|
5157
|
+
if (text[i + 1] === '"') { field += '"'; i++; }
|
|
5158
|
+
else inQuotes = false;
|
|
5159
|
+
} else {
|
|
5160
|
+
field += c;
|
|
5161
|
+
}
|
|
5162
|
+
continue;
|
|
5163
|
+
}
|
|
5164
|
+
if (c === '"') { inQuotes = true; continue; }
|
|
5165
|
+
if (c === delimiter) { row.push(field); field = ""; continue; }
|
|
5166
|
+
if (c === "\\n" || c === "\\r") {
|
|
5167
|
+
if (c === "\\r" && text[i + 1] === "\\n") i++;
|
|
5168
|
+
row.push(field);
|
|
5169
|
+
field = "";
|
|
5170
|
+
rows.push(row);
|
|
5171
|
+
row = [];
|
|
5172
|
+
continue;
|
|
5173
|
+
}
|
|
5174
|
+
field += c;
|
|
5175
|
+
}
|
|
5176
|
+
if (field.length > 0 || row.length > 0) {
|
|
5177
|
+
row.push(field);
|
|
5178
|
+
rows.push(row);
|
|
5179
|
+
}
|
|
5180
|
+
return rows;
|
|
5181
|
+
};
|
|
5182
|
+
|
|
5183
|
+
const TABLE_PREVIEW_MAX_ROWS = 5000;
|
|
5184
|
+
|
|
5185
|
+
const isMarkdownFile = (mime, name) => {
|
|
5186
|
+
const m = (mime || "").toLowerCase();
|
|
5187
|
+
if (m.startsWith("text/markdown") || m === "text/x-markdown") return true;
|
|
5188
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
5189
|
+
return ext === "md" || ext === "markdown" || ext === "mdx";
|
|
5190
|
+
};
|
|
5191
|
+
|
|
5192
|
+
const flashLabel = (btn, label, durationMs) => {
|
|
5193
|
+
const original = btn.dataset.label || btn.textContent;
|
|
5194
|
+
btn.dataset.label = original;
|
|
5195
|
+
btn.textContent = label;
|
|
5196
|
+
if (btn._flashTimer) clearTimeout(btn._flashTimer);
|
|
5197
|
+
btn._flashTimer = setTimeout(() => { btn.textContent = original; }, durationMs);
|
|
5198
|
+
};
|
|
5199
|
+
|
|
5200
|
+
const buildCopyTextButton = (text) => {
|
|
5201
|
+
const btn = document.createElement("button");
|
|
5202
|
+
btn.className = "file-preview-action-btn";
|
|
5203
|
+
btn.textContent = "Copy";
|
|
5204
|
+
btn.onclick = async () => {
|
|
5205
|
+
try {
|
|
5206
|
+
await navigator.clipboard.writeText(text);
|
|
5207
|
+
flashLabel(btn, "Copied", 1500);
|
|
5208
|
+
} catch (err) {
|
|
5209
|
+
window.alert("Failed to copy: " + (err.message || "clipboard unavailable"));
|
|
5210
|
+
}
|
|
5211
|
+
};
|
|
5212
|
+
return btn;
|
|
5213
|
+
};
|
|
5214
|
+
|
|
5215
|
+
const buildCopyLinkButton = (filePath) => {
|
|
5216
|
+
const btn = document.createElement("button");
|
|
5217
|
+
btn.className = "file-preview-action-btn";
|
|
5218
|
+
btn.textContent = "Copy link";
|
|
5219
|
+
btn.onclick = async () => {
|
|
5220
|
+
const url = window.location.origin + "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
|
|
5221
|
+
try {
|
|
5222
|
+
await navigator.clipboard.writeText(url);
|
|
5223
|
+
flashLabel(btn, "Copied", 1500);
|
|
5224
|
+
} catch (err) {
|
|
5225
|
+
window.alert("Failed to copy: " + (err.message || "clipboard unavailable"));
|
|
5226
|
+
}
|
|
5227
|
+
};
|
|
5228
|
+
return btn;
|
|
5229
|
+
};
|
|
5230
|
+
|
|
5231
|
+
const renderTextView = (filePath, text, mime) => {
|
|
5232
|
+
const messages = elements.messages;
|
|
5233
|
+
const filename = filePath.split("/").pop() || filePath;
|
|
5234
|
+
const wrap = document.createElement("div");
|
|
5235
|
+
wrap.className = "file-preview";
|
|
5236
|
+
const copyBtn = buildCopyTextButton(text);
|
|
5237
|
+
const editBtn = document.createElement("button");
|
|
5238
|
+
editBtn.className = "file-preview-action-btn";
|
|
5239
|
+
editBtn.textContent = "Edit";
|
|
5240
|
+
editBtn.onclick = () => renderTextEditor(filePath, text, mime);
|
|
5241
|
+
|
|
5242
|
+
let body;
|
|
5243
|
+
const delimiter = csvDelimiter(mime, filename);
|
|
5244
|
+
if (delimiter) {
|
|
5245
|
+
body = document.createElement("div");
|
|
5246
|
+
body.className = "file-preview-table-wrap";
|
|
5247
|
+
const rows = parseDelimited(text, delimiter);
|
|
5248
|
+
if (rows.length === 0) {
|
|
5249
|
+
const empty = document.createElement("div");
|
|
5250
|
+
empty.className = "file-explorer-empty";
|
|
5251
|
+
empty.textContent = "Empty file";
|
|
5252
|
+
body.appendChild(empty);
|
|
5253
|
+
} else {
|
|
5254
|
+
const truncated = rows.length > TABLE_PREVIEW_MAX_ROWS;
|
|
5255
|
+
const visibleRows = truncated ? rows.slice(0, TABLE_PREVIEW_MAX_ROWS) : rows;
|
|
5256
|
+
const table = document.createElement("table");
|
|
5257
|
+
table.className = "file-preview-table";
|
|
5258
|
+
const thead = document.createElement("thead");
|
|
5259
|
+
const headTr = document.createElement("tr");
|
|
5260
|
+
for (const cell of visibleRows[0]) {
|
|
5261
|
+
const th = document.createElement("th");
|
|
5262
|
+
th.textContent = cell;
|
|
5263
|
+
headTr.appendChild(th);
|
|
5264
|
+
}
|
|
5265
|
+
thead.appendChild(headTr);
|
|
5266
|
+
table.appendChild(thead);
|
|
5267
|
+
const tbody = document.createElement("tbody");
|
|
5268
|
+
for (let r = 1; r < visibleRows.length; r++) {
|
|
5269
|
+
const tr = document.createElement("tr");
|
|
5270
|
+
for (const cell of visibleRows[r]) {
|
|
5271
|
+
const td = document.createElement("td");
|
|
5272
|
+
td.textContent = cell;
|
|
5273
|
+
tr.appendChild(td);
|
|
5274
|
+
}
|
|
5275
|
+
tbody.appendChild(tr);
|
|
5276
|
+
}
|
|
5277
|
+
table.appendChild(tbody);
|
|
5278
|
+
body.appendChild(table);
|
|
5279
|
+
if (truncated) {
|
|
5280
|
+
const note = document.createElement("div");
|
|
5281
|
+
note.className = "file-preview-table-truncated";
|
|
5282
|
+
note.textContent = "Showing first " + TABLE_PREVIEW_MAX_ROWS + " of " + rows.length + " rows. Click Edit to see the raw file.";
|
|
5283
|
+
body.appendChild(note);
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
} else if (isMarkdownFile(mime, filename)) {
|
|
5287
|
+
body = document.createElement("div");
|
|
5288
|
+
body.className = "file-preview-markdown";
|
|
5289
|
+
const inner = document.createElement("div");
|
|
5290
|
+
inner.className = "assistant-content";
|
|
5291
|
+
inner.innerHTML = renderAssistantMarkdown(text);
|
|
5292
|
+
body.appendChild(inner);
|
|
5293
|
+
} else {
|
|
5294
|
+
body = document.createElement("pre");
|
|
5295
|
+
body.className = "file-preview-text";
|
|
5296
|
+
body.textContent = text;
|
|
5297
|
+
}
|
|
5298
|
+
wrap.append(buildPreviewActions(filePath, [copyBtn], [editBtn]), body);
|
|
5299
|
+
messages.innerHTML = "";
|
|
5300
|
+
messages.appendChild(wrap);
|
|
5301
|
+
};
|
|
5302
|
+
|
|
5303
|
+
const renderTextEditor = (filePath, originalText, mime) => {
|
|
5304
|
+
const messages = elements.messages;
|
|
5305
|
+
const wrap = document.createElement("div");
|
|
5306
|
+
wrap.className = "file-preview";
|
|
5307
|
+
const actions = document.createElement("div");
|
|
5308
|
+
actions.className = "file-preview-actions";
|
|
5309
|
+
const cancelBtn = document.createElement("button");
|
|
5310
|
+
cancelBtn.className = "file-preview-action-btn";
|
|
5311
|
+
cancelBtn.textContent = "Cancel";
|
|
5312
|
+
const saveBtn = document.createElement("button");
|
|
5313
|
+
saveBtn.className = "file-preview-action-btn primary";
|
|
5314
|
+
saveBtn.textContent = "Save";
|
|
5315
|
+
saveBtn.disabled = true;
|
|
5316
|
+
const leftGroup = document.createElement("div");
|
|
5317
|
+
leftGroup.className = "file-preview-actions-group";
|
|
5318
|
+
const rightGroup = document.createElement("div");
|
|
5319
|
+
rightGroup.className = "file-preview-actions-group";
|
|
5320
|
+
rightGroup.append(cancelBtn, saveBtn);
|
|
5321
|
+
actions.append(leftGroup, rightGroup);
|
|
5322
|
+
const textarea = document.createElement("textarea");
|
|
5323
|
+
textarea.className = "file-edit-textarea";
|
|
5324
|
+
textarea.value = originalText;
|
|
5325
|
+
textarea.spellcheck = false;
|
|
5326
|
+
textarea.oninput = () => { saveBtn.disabled = textarea.value === originalText; };
|
|
5327
|
+
cancelBtn.onclick = () => renderTextView(filePath, originalText, mime);
|
|
5328
|
+
saveBtn.onclick = async () => {
|
|
5329
|
+
const newText = textarea.value;
|
|
5330
|
+
saveBtn.disabled = true;
|
|
5331
|
+
saveBtn.textContent = "Saving…";
|
|
5332
|
+
cancelBtn.disabled = true;
|
|
5333
|
+
try {
|
|
5334
|
+
const url = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, "")) + "?overwrite=1";
|
|
5335
|
+
const response = await fetch(url, {
|
|
5336
|
+
method: "PUT",
|
|
5337
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
5338
|
+
headers: { ...buildAuthHeaders(), "Content-Type": mime || "text/plain" },
|
|
5339
|
+
body: newText,
|
|
5340
|
+
});
|
|
5341
|
+
if (!response.ok) {
|
|
5342
|
+
let msg = "Save failed (" + response.status + ")";
|
|
5343
|
+
try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
|
|
5344
|
+
throw new Error(msg);
|
|
5345
|
+
}
|
|
5346
|
+
} catch (err) {
|
|
5347
|
+
saveBtn.textContent = "Save";
|
|
5348
|
+
saveBtn.disabled = false;
|
|
5349
|
+
cancelBtn.disabled = false;
|
|
5350
|
+
window.alert("Failed to save: " + (err.message || "unknown error"));
|
|
5351
|
+
return;
|
|
5352
|
+
}
|
|
5353
|
+
// Invalidate parent dir cache so file size/mtime refresh on next view
|
|
5354
|
+
state.dirCache.delete(parentPath(filePath));
|
|
5355
|
+
renderTextView(filePath, newText, mime);
|
|
5356
|
+
};
|
|
5357
|
+
wrap.append(actions, textarea);
|
|
5358
|
+
messages.innerHTML = "";
|
|
5359
|
+
messages.appendChild(wrap);
|
|
5360
|
+
textarea.focus();
|
|
5361
|
+
};
|
|
5362
|
+
|
|
5363
|
+
const renderPreviewPlaceholder = (filePath, mime, size, reason) => {
|
|
5364
|
+
const messages = elements.messages;
|
|
5365
|
+
const filename = filePath.split("/").pop() || filePath;
|
|
5366
|
+
const downloadUrl = "/api/vfs/" + encodeURI(filePath.replace(/^\\//, ""));
|
|
5367
|
+
const wrap = document.createElement("div");
|
|
5368
|
+
wrap.className = "file-preview";
|
|
5369
|
+
const inner = document.createElement("div");
|
|
5370
|
+
inner.className = "file-preview-placeholder";
|
|
5371
|
+
const card = document.createElement("div");
|
|
5372
|
+
const nameEl = document.createElement("div");
|
|
5373
|
+
nameEl.className = "file-preview-name";
|
|
5374
|
+
nameEl.textContent = filename;
|
|
5375
|
+
const metaEl = document.createElement("div");
|
|
5376
|
+
metaEl.className = "file-preview-meta";
|
|
5377
|
+
const metaParts = [];
|
|
5378
|
+
if (mime) metaParts.push(mime);
|
|
5379
|
+
if (size) metaParts.push(formatBytes(size));
|
|
5380
|
+
metaEl.textContent = (reason ? reason + " · " : "") + metaParts.join(" · ");
|
|
5381
|
+
const a = document.createElement("a");
|
|
5382
|
+
a.className = "file-preview-download";
|
|
5383
|
+
a.href = downloadUrl;
|
|
5384
|
+
a.textContent = "Download";
|
|
5385
|
+
a.setAttribute("download", filename);
|
|
5386
|
+
card.append(nameEl, metaEl, a);
|
|
5387
|
+
inner.appendChild(card);
|
|
5388
|
+
wrap.appendChild(inner);
|
|
5389
|
+
messages.innerHTML = "";
|
|
5390
|
+
messages.appendChild(wrap);
|
|
5391
|
+
};
|
|
5392
|
+
|
|
5393
|
+
const renderPreviewError = (filePath, message) => {
|
|
5394
|
+
const messages = elements.messages;
|
|
5395
|
+
const filename = filePath.split("/").pop() || filePath;
|
|
5396
|
+
const wrap = document.createElement("div");
|
|
5397
|
+
wrap.className = "file-preview";
|
|
5398
|
+
const inner = document.createElement("div");
|
|
5399
|
+
inner.className = "file-preview-placeholder";
|
|
5400
|
+
const card = document.createElement("div");
|
|
5401
|
+
card.innerHTML = '<div class="file-preview-name">' + escapeHtml(filename) + '</div><div class="file-preview-meta">Failed to load file (' + escapeHtml(message) + ')</div>';
|
|
5402
|
+
inner.appendChild(card);
|
|
5403
|
+
wrap.appendChild(inner);
|
|
5404
|
+
messages.innerHTML = "";
|
|
5405
|
+
messages.appendChild(wrap);
|
|
5406
|
+
};
|
|
5407
|
+
|
|
5408
|
+
const updateComposerVisibility = () => {
|
|
5409
|
+
if (!elements.composer) return;
|
|
5410
|
+
if (state.activeFilePath) elements.composer.classList.add("hidden");
|
|
5411
|
+
else elements.composer.classList.remove("hidden");
|
|
5412
|
+
};
|
|
5413
|
+
|
|
5414
|
+
const deleteEntry = async (path, type, parentDir) => {
|
|
5415
|
+
try {
|
|
5416
|
+
const url = "/api/vfs/" + encodeURI(path.replace(/^\\//, ""));
|
|
5417
|
+
const response = await fetch(url, {
|
|
5418
|
+
method: "DELETE",
|
|
5419
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
5420
|
+
headers: buildAuthHeaders(),
|
|
5421
|
+
});
|
|
5422
|
+
if (!response.ok) {
|
|
5423
|
+
let msg = "Delete failed (" + response.status + ")";
|
|
5424
|
+
try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
|
|
5425
|
+
throw new Error(msg);
|
|
5426
|
+
}
|
|
5427
|
+
} catch (err) {
|
|
5428
|
+
window.alert("Failed to delete: " + (err.message || "unknown error"));
|
|
5429
|
+
renderFileExplorer();
|
|
5430
|
+
return;
|
|
5431
|
+
}
|
|
5432
|
+
// Drop cache for parent and any descendants we cached
|
|
5433
|
+
state.dirCache.delete(parentDir);
|
|
5434
|
+
if (type === "directory") {
|
|
5435
|
+
const prefix = path === "/" ? "/" : path + "/";
|
|
5436
|
+
for (const k of Array.from(state.dirCache.keys())) {
|
|
5437
|
+
if (k === path || k.startsWith(prefix)) state.dirCache.delete(k);
|
|
5438
|
+
}
|
|
5439
|
+
for (const d of Array.from(state.expandedDirs)) {
|
|
5440
|
+
if (d === path || d.startsWith(prefix)) state.expandedDirs.delete(d);
|
|
5441
|
+
}
|
|
5442
|
+
}
|
|
5443
|
+
if (state.activeFilePath === path || (type === "directory" && state.activeFilePath && state.activeFilePath.startsWith(path + "/"))) {
|
|
5444
|
+
state.activeFilePath = null;
|
|
5445
|
+
elements.chatTitle.textContent = "";
|
|
5446
|
+
elements.messages.innerHTML = "";
|
|
5447
|
+
updateComposerVisibility();
|
|
5448
|
+
replaceFileUrl(null);
|
|
5449
|
+
}
|
|
5450
|
+
try { await fetchDirEntries(parentDir); } catch {}
|
|
5451
|
+
renderFileExplorer();
|
|
5452
|
+
};
|
|
5453
|
+
|
|
5454
|
+
const switchSidebarMode = (mode) => {
|
|
5455
|
+
if (mode !== "conversations" && mode !== "files") return;
|
|
5456
|
+
state.sidebarMode = mode;
|
|
5457
|
+
const buttons = document.querySelectorAll(".sidebar-segmented .seg-btn");
|
|
5458
|
+
buttons.forEach((b) => {
|
|
5459
|
+
if (b.dataset.mode === mode) b.classList.add("active");
|
|
5460
|
+
else b.classList.remove("active");
|
|
5461
|
+
});
|
|
5462
|
+
const list = elements.list;
|
|
5463
|
+
const explorer = elements.fileExplorer;
|
|
5464
|
+
if (mode === "files") {
|
|
5465
|
+
list.classList.add("hidden");
|
|
5466
|
+
explorer.classList.remove("hidden");
|
|
5467
|
+
if (!state.dirCache.has("/")) {
|
|
5468
|
+
ensureDirLoaded("/").then(() => renderFileExplorer());
|
|
5469
|
+
}
|
|
5470
|
+
renderFileExplorer();
|
|
5471
|
+
} else {
|
|
5472
|
+
explorer.classList.add("hidden");
|
|
5473
|
+
list.classList.remove("hidden");
|
|
5474
|
+
}
|
|
5475
|
+
};
|
|
5476
|
+
|
|
5477
|
+
// ----- Uploads + mkdir + drag-drop -----
|
|
5478
|
+
|
|
5479
|
+
let _hiddenUploadInput = null;
|
|
5480
|
+
const triggerUploadPicker = (targetDir) => {
|
|
5481
|
+
if (!_hiddenUploadInput) {
|
|
5482
|
+
_hiddenUploadInput = document.createElement("input");
|
|
5483
|
+
_hiddenUploadInput.type = "file";
|
|
5484
|
+
_hiddenUploadInput.multiple = true;
|
|
5485
|
+
_hiddenUploadInput.style.display = "none";
|
|
5486
|
+
document.body.appendChild(_hiddenUploadInput);
|
|
5487
|
+
}
|
|
5488
|
+
_hiddenUploadInput.value = "";
|
|
5489
|
+
_hiddenUploadInput.onchange = () => {
|
|
5490
|
+
const files = Array.from(_hiddenUploadInput.files || []);
|
|
5491
|
+
if (files.length > 0) uploadFiles(files, targetDir);
|
|
5492
|
+
};
|
|
5493
|
+
_hiddenUploadInput.click();
|
|
5494
|
+
};
|
|
5495
|
+
|
|
5496
|
+
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
5497
|
+
|
|
5498
|
+
const uploadOne = async (file, targetDir, overwrite) => {
|
|
5499
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
5500
|
+
throw new Error("File too large (limit " + formatBytes(MAX_UPLOAD_BYTES) + ")");
|
|
5501
|
+
}
|
|
5502
|
+
const targetPath = joinPath(targetDir, file.name);
|
|
5503
|
+
const url = "/api/vfs/" + encodeURI(targetPath.replace(/^\\//, "")) + (overwrite ? "?overwrite=1" : "");
|
|
5504
|
+
const response = await fetch(url, {
|
|
5505
|
+
method: "PUT",
|
|
5506
|
+
credentials: state.tenantToken ? "omit" : "include",
|
|
5507
|
+
headers: { ...buildAuthHeaders(), "Content-Type": file.type || "application/octet-stream" },
|
|
5508
|
+
body: file,
|
|
5509
|
+
});
|
|
5510
|
+
if (response.status === 409) {
|
|
5511
|
+
const ok = window.confirm("A file named \\\"" + file.name + "\\\" already exists in " + targetDir + ". Overwrite?");
|
|
5512
|
+
if (!ok) return;
|
|
5513
|
+
return uploadOne(file, targetDir, true);
|
|
5514
|
+
}
|
|
5515
|
+
if (!response.ok) {
|
|
5516
|
+
let msg = "Upload failed (" + response.status + ")";
|
|
5517
|
+
try { const p = await response.json(); if (p && p.message) msg = p.message; } catch {}
|
|
5518
|
+
throw new Error(msg);
|
|
5519
|
+
}
|
|
5520
|
+
};
|
|
5521
|
+
|
|
5522
|
+
const uploadFiles = async (files, targetDir) => {
|
|
5523
|
+
for (const file of files) {
|
|
5524
|
+
state.pendingUploads += 1;
|
|
5525
|
+
renderFileExplorer();
|
|
5526
|
+
try {
|
|
5527
|
+
await uploadOne(file, targetDir, false);
|
|
5528
|
+
} catch (err) {
|
|
5529
|
+
window.alert("Failed to upload " + file.name + ": " + (err.message || "unknown error"));
|
|
5530
|
+
} finally {
|
|
5531
|
+
state.pendingUploads -= 1;
|
|
5532
|
+
}
|
|
5533
|
+
}
|
|
5534
|
+
state.dirCache.delete(targetDir);
|
|
5535
|
+
try { await fetchDirEntries(targetDir); } catch {}
|
|
5536
|
+
state.expandedDirs.add(targetDir);
|
|
5537
|
+
renderFileExplorer();
|
|
5538
|
+
};
|
|
5539
|
+
|
|
5540
|
+
let _dropTargetEl = null;
|
|
5541
|
+
const _setDropTarget = (el) => {
|
|
5542
|
+
if (_dropTargetEl === el) return;
|
|
5543
|
+
if (_dropTargetEl) _dropTargetEl.classList.remove("drop-target");
|
|
5544
|
+
_dropTargetEl = el;
|
|
5545
|
+
if (_dropTargetEl) _dropTargetEl.classList.add("drop-target");
|
|
5546
|
+
};
|
|
5547
|
+
const _resolveDropTarget = (eventTarget) => {
|
|
5548
|
+
if (!(eventTarget instanceof Element)) return null;
|
|
5549
|
+
// A folder header row takes precedence — drop INTO that folder.
|
|
5550
|
+
const folderRow = eventTarget.closest(".file-row.is-dir");
|
|
5551
|
+
if (folderRow && elements.fileExplorer.contains(folderRow)) return folderRow;
|
|
5552
|
+
// Otherwise the deepest .file-children wrap — drop into its parent dir.
|
|
5553
|
+
const childrenWrap = eventTarget.closest(".file-children");
|
|
5554
|
+
if (childrenWrap && elements.fileExplorer.contains(childrenWrap)) return childrenWrap;
|
|
5555
|
+
return null;
|
|
5556
|
+
};
|
|
5557
|
+
const _resolveDropPath = (el) => {
|
|
5558
|
+
if (!el) return "/";
|
|
5559
|
+
if (el.classList.contains("file-row")) return el.dataset.path || "/";
|
|
5560
|
+
if (el.classList.contains("file-children")) return el.dataset.dir || "/";
|
|
5561
|
+
return "/";
|
|
5562
|
+
};
|
|
5563
|
+
|
|
5564
|
+
const attachExplorerDropHandlers = () => {
|
|
5565
|
+
const root = elements.fileExplorer;
|
|
5566
|
+
if (!root) return;
|
|
5567
|
+
root.addEventListener("dragover", (e) => {
|
|
5568
|
+
if (!e.dataTransfer || ![...(e.dataTransfer.types || [])].includes("Files")) return;
|
|
5569
|
+
e.preventDefault();
|
|
5570
|
+
_setDropTarget(_resolveDropTarget(e.target));
|
|
5571
|
+
});
|
|
5572
|
+
root.addEventListener("dragleave", (e) => {
|
|
5573
|
+
if (!root.contains(e.relatedTarget)) _setDropTarget(null);
|
|
5574
|
+
});
|
|
5575
|
+
root.addEventListener("drop", (e) => {
|
|
5576
|
+
if (!e.dataTransfer || !e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
|
5577
|
+
e.preventDefault();
|
|
5578
|
+
const target = _dropTargetEl;
|
|
5579
|
+
const dir = _resolveDropPath(target);
|
|
5580
|
+
_setDropTarget(null);
|
|
5581
|
+
const items = Array.from(e.dataTransfer.items || []);
|
|
5582
|
+
const hasDir = items.some((it) => {
|
|
5583
|
+
const fn = it.webkitGetAsEntry && it.webkitGetAsEntry();
|
|
5584
|
+
return fn && fn.isDirectory;
|
|
5585
|
+
});
|
|
5586
|
+
const files = Array.from(e.dataTransfer.files);
|
|
5587
|
+
if (hasDir) {
|
|
5588
|
+
window.alert("Folder uploads aren't supported yet — drop individual files.");
|
|
5589
|
+
}
|
|
5590
|
+
if (files.length > 0) uploadFiles(files, dir);
|
|
5591
|
+
});
|
|
5592
|
+
};
|
|
5593
|
+
|
|
5594
|
+
// Wire segmented control + drop-handlers once the DOM is ready
|
|
5595
|
+
(function wireFileExplorer() {
|
|
5596
|
+
const buttons = document.querySelectorAll(".sidebar-segmented .seg-btn");
|
|
5597
|
+
buttons.forEach((b) => {
|
|
5598
|
+
b.addEventListener("click", () => switchSidebarMode(b.dataset.mode));
|
|
5599
|
+
});
|
|
5600
|
+
attachExplorerDropHandlers();
|
|
5601
|
+
})();
|
|
5602
|
+
|
|
4627
5603
|
window.addEventListener("popstate", async () => {
|
|
4628
5604
|
if (state.isStreaming) return;
|
|
5605
|
+
const filePath = getFilePathFromUrl();
|
|
5606
|
+
if (filePath) {
|
|
5607
|
+
switchSidebarMode("files");
|
|
5608
|
+
await selectFile(filePath);
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
4629
5611
|
const conversationId = getConversationIdFromUrl();
|
|
4630
5612
|
await navigateToConversation(conversationId);
|
|
4631
5613
|
});
|
|
@@ -4636,6 +5618,26 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4636
5618
|
return;
|
|
4637
5619
|
}
|
|
4638
5620
|
await loadConversations();
|
|
5621
|
+
const urlFilePath = getFilePathFromUrl();
|
|
5622
|
+
if (urlFilePath) {
|
|
5623
|
+
switchSidebarMode("files");
|
|
5624
|
+
// Expand ancestors so the file is visible in the tree
|
|
5625
|
+
let p = parentPath(urlFilePath);
|
|
5626
|
+
const ancestors = [];
|
|
5627
|
+
while (p && p !== "/") { ancestors.push(p); p = parentPath(p); }
|
|
5628
|
+
ancestors.push("/");
|
|
5629
|
+
for (const a of ancestors.reverse()) {
|
|
5630
|
+
state.expandedDirs.add(a);
|
|
5631
|
+
try { await fetchDirEntries(a); } catch {}
|
|
5632
|
+
}
|
|
5633
|
+
state.activeFilePath = urlFilePath;
|
|
5634
|
+
updateComposerVisibility();
|
|
5635
|
+
renderFileExplorer();
|
|
5636
|
+
await renderFilePreview(urlFilePath);
|
|
5637
|
+
autoResizePrompt();
|
|
5638
|
+
updateContextRing();
|
|
5639
|
+
return;
|
|
5640
|
+
}
|
|
4639
5641
|
const urlConversationId = getConversationIdFromUrl();
|
|
4640
5642
|
if (urlConversationId) {
|
|
4641
5643
|
state.activeConversationId = urlConversationId;
|