@poncho-ai/cli 0.38.1 → 0.40.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 +6 -6
- package/CHANGELOG.md +170 -0
- package/dist/{chunk-W7SQVUB4.js → chunk-KVGMTYDD.js} +1959 -66
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-interactive-ink-UKPUGCDW.js → run-interactive-ink-LJTKUUV4.js} +1 -1
- package/package.json +4 -4
- package/src/index.ts +366 -6
- package/src/init-onboarding.ts +8 -2
- package/src/scaffolding.ts +75 -13
- package/src/templates.ts +5 -1
- package/src/vfs-zip.ts +94 -0
- package/src/web-ui-client.ts +1022 -0
- package/src/web-ui-styles.ts +408 -1
- package/src/web-ui.ts +6 -0
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,
|
|
@@ -64,6 +69,14 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
64
69
|
abortController: null,
|
|
65
70
|
pendingFiles: [],
|
|
66
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,
|
|
67
80
|
};
|
|
68
81
|
|
|
69
82
|
const agentInitial = document.body.dataset.agentInitial || "A";
|
|
@@ -79,6 +92,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
79
92
|
topbarNewChat: $("topbar-new-chat"),
|
|
80
93
|
messages: $("messages"),
|
|
81
94
|
chatTitle: $("chat-title"),
|
|
95
|
+
viewToggle: $("view-toggle"),
|
|
82
96
|
logout: $("logout"),
|
|
83
97
|
composer: $("composer"),
|
|
84
98
|
prompt: $("prompt"),
|
|
@@ -112,6 +126,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
112
126
|
threadAttachmentPreview: $("thread-attachment-preview"),
|
|
113
127
|
threadPrompt: $("thread-prompt"),
|
|
114
128
|
threadSend: $("thread-send"),
|
|
129
|
+
fileExplorer: $("file-explorer"),
|
|
115
130
|
};
|
|
116
131
|
const sendIconMarkup =
|
|
117
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>';
|
|
@@ -163,6 +178,25 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
163
178
|
return match ? decodeURIComponent(match[1]) : null;
|
|
164
179
|
};
|
|
165
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
|
+
|
|
166
200
|
const mutatingMethods = new Set(["POST", "PATCH", "PUT", "DELETE"]);
|
|
167
201
|
|
|
168
202
|
const api = async (path, options = {}) => {
|
|
@@ -831,6 +865,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
831
865
|
}
|
|
832
866
|
var switchingFamily = state.subagentsParentId !== c.conversationId;
|
|
833
867
|
state.activeConversationId = c.conversationId;
|
|
868
|
+
state.activeFilePath = null;
|
|
869
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
834
870
|
state.viewingSubagentId = null;
|
|
835
871
|
state.parentConversationId = null;
|
|
836
872
|
if (switchingFamily) {
|
|
@@ -1737,7 +1773,66 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
1737
1773
|
}
|
|
1738
1774
|
};
|
|
1739
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
|
+
|
|
1740
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
|
+
}
|
|
1741
1836
|
const previousScrollTop = elements.messages.scrollTop;
|
|
1742
1837
|
const shouldStickToBottom =
|
|
1743
1838
|
options.forceScrollBottom === true || state.isMessagesPinnedToBottom;
|
|
@@ -2001,6 +2096,13 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
2001
2096
|
.catch(() => ({ threads: [] }));
|
|
2002
2097
|
const payload = await conversationPromise;
|
|
2003
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();
|
|
2004
2106
|
// Merge own pending approvals + subagent pending approvals
|
|
2005
2107
|
var allPendingApprovals = [].concat(
|
|
2006
2108
|
payload.conversation.pendingApprovals || payload.pendingApprovals || [],
|
|
@@ -2299,6 +2401,12 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
2299
2401
|
if (typeof payload.conversation.contextWindow === "number" && payload.conversation.contextWindow > 0) {
|
|
2300
2402
|
state.contextWindow = payload.conversation.contextWindow;
|
|
2301
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();
|
|
2302
2410
|
updateContextRing();
|
|
2303
2411
|
renderMessages(state.activeMessages, streaming);
|
|
2304
2412
|
return payload;
|
|
@@ -3963,6 +4071,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
3963
4071
|
const startNewChat = () => {
|
|
3964
4072
|
if (window._resetBrowserPanel) window._resetBrowserPanel();
|
|
3965
4073
|
state.activeConversationId = null;
|
|
4074
|
+
state.activeFilePath = null;
|
|
4075
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
3966
4076
|
state.activeMessages = [];
|
|
3967
4077
|
state.confirmDeleteId = null;
|
|
3968
4078
|
state.contextTokens = 0;
|
|
@@ -4301,13 +4411,17 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4301
4411
|
});
|
|
4302
4412
|
|
|
4303
4413
|
let dragCounter = 0;
|
|
4414
|
+
const _inFileExplorer = (target) =>
|
|
4415
|
+
elements.fileExplorer && target instanceof Node && elements.fileExplorer.contains(target);
|
|
4304
4416
|
document.addEventListener("dragenter", (e) => {
|
|
4305
4417
|
e.preventDefault();
|
|
4418
|
+
if (_inFileExplorer(e.target)) return;
|
|
4306
4419
|
dragCounter++;
|
|
4307
4420
|
if (dragCounter === 1) elements.dragOverlay.classList.add("active");
|
|
4308
4421
|
});
|
|
4309
4422
|
document.addEventListener("dragleave", (e) => {
|
|
4310
4423
|
e.preventDefault();
|
|
4424
|
+
if (_inFileExplorer(e.target)) return;
|
|
4311
4425
|
dragCounter--;
|
|
4312
4426
|
if (dragCounter <= 0) { dragCounter = 0; elements.dragOverlay.classList.remove("active"); }
|
|
4313
4427
|
});
|
|
@@ -4316,6 +4430,7 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4316
4430
|
e.preventDefault();
|
|
4317
4431
|
dragCounter = 0;
|
|
4318
4432
|
elements.dragOverlay.classList.remove("active");
|
|
4433
|
+
if (_inFileExplorer(e.target)) return;
|
|
4319
4434
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
|
4320
4435
|
addFiles(e.dataTransfer.files);
|
|
4321
4436
|
}
|
|
@@ -4578,6 +4693,8 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4578
4693
|
});
|
|
4579
4694
|
|
|
4580
4695
|
const navigateToConversation = async (conversationId) => {
|
|
4696
|
+
state.activeFilePath = null;
|
|
4697
|
+
if (elements.composer) elements.composer.classList.remove("hidden");
|
|
4581
4698
|
if (conversationId) {
|
|
4582
4699
|
state.activeConversationId = conversationId;
|
|
4583
4700
|
renderConversationList();
|
|
@@ -4604,8 +4721,893 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4604
4721
|
}
|
|
4605
4722
|
};
|
|
4606
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
|
+
|
|
4607
5603
|
window.addEventListener("popstate", async () => {
|
|
4608
5604
|
if (state.isStreaming) return;
|
|
5605
|
+
const filePath = getFilePathFromUrl();
|
|
5606
|
+
if (filePath) {
|
|
5607
|
+
switchSidebarMode("files");
|
|
5608
|
+
await selectFile(filePath);
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
4609
5611
|
const conversationId = getConversationIdFromUrl();
|
|
4610
5612
|
await navigateToConversation(conversationId);
|
|
4611
5613
|
});
|
|
@@ -4616,6 +5618,26 @@ export const getWebUiClientScript = (markedSource: string): string => `
|
|
|
4616
5618
|
return;
|
|
4617
5619
|
}
|
|
4618
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
|
+
}
|
|
4619
5641
|
const urlConversationId = getConversationIdFromUrl();
|
|
4620
5642
|
if (urlConversationId) {
|
|
4621
5643
|
state.activeConversationId = urlConversationId;
|