Commit 02876ff2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

dgdfg jdfjgyjt

parent 2dd589eb
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -10,46 +10,49 @@ function authHeader(token) { ...@@ -10,46 +10,49 @@ function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
function extractError(err, defaultMsg) {
let msg = err.detail || err.message || defaultMsg;
if (Array.isArray(msg)) return msg.map(m => m.msg || JSON.stringify(m)).join(", ");
if (typeof msg === "object" && msg !== null) return msg.message || JSON.stringify(msg);
return String(msg);
}
async function request(method, path, token, body) { async function request(method, path, token, body) {
const opts = { method, headers: headers(token) }; const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(extractError(err, "Request failed")); throw new Error(err.detail || err.message || "Request failed");
} }
return res.json(); return res.json();
} }
// ═══════════ Auth ═══════════ export const login = (username, password) =>
export const login = (username, password) => request("POST", "/auth/login", null, { username, password }); request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) => request("POST", "/auth/register", null, { username, email, password });
export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token); export const getMe = (token) => request("GET", "/auth/me", token);
// ═══════════ Chats ═══════════
export const listChats = (token) => request("GET", "/chats", token); export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data); export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) => request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) => updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) => request("GET", `/chats/${chatId}/messages`, token);
export const checkGenerating = (token, chatId) => request("GET", `/chats/${chatId}/generating`, token);
// ═══════════ Streaming ═══════════ export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token), body: JSON.stringify(body), signal, method: "POST", headers: headers(token),
body: JSON.stringify(body), signal,
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText })); const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(extractError(err, "Stream failed")); throw new Error(err.detail || "Stream failed");
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
...@@ -63,57 +66,81 @@ export async function* streamMessage(token, chatId, body, signal) { ...@@ -63,57 +66,81 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { /* skip */ } try { yield JSON.parse(line.slice(6)); } catch { }
} }
} }
} }
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ } try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
} }
} }
// ═══════════ Attachments ═══════════
export async function uploadAttachments(token, chatId, files) { export async function uploadAttachments(token, chatId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form, method: "POST", headers: authHeader(token), body: form,
}); });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json(); return res.json();
} }
export function getAttachmentUrl(attachmentId) { return `${BASE}/attachments/${attachmentId}/file`; }
export const deleteAttachment = (token, attachmentId) => request("DELETE", `/attachments/${attachmentId}`, token);
// ═══════════ Knowledge ═══════════ export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
export const deleteAttachment = (token, attachmentId) =>
request("DELETE", `/attachments/${attachmentId}`, token);
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") => request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) => request("GET", `/knowledge/${kbId}`, token); export const createKnowledgeBase = (token, name, description = "") =>
export const updateKnowledgeBase = (token, kbId, data) => request("PUT", `/knowledge/${kbId}`, token, data); request("POST", "/knowledge", token, { name, description });
export const deleteKnowledgeBase = (token, kbId) => request("DELETE", `/knowledge/${kbId}`, token);
export const listKnowledgeDocuments = (token, kbId) => request("GET", `/knowledge/${kbId}/documents`, token); export const getKnowledgeBase = (token, kbId) =>
export const deleteKnowledgeDocument = (token, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, token); request("GET", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) { export async function uploadDocuments(token, kbId, files) {
const form = new FormData(); const form = new FormData();
for (const file of files) form.append("files", file); for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(token), body: form }); const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json(); return res.json();
} }
export const uploadDocument = (token, kbId, file) => uploadDocuments(token, kbId, [file]);
// ═══════════ Admin ═══════════ export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token); export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token); export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data); export const adminCreateUser = (token, data) =>
export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token); request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token); export const adminListChats = (token) => request("GET", "/admin/chats", token);
// ═══════════ Code Download ═══════════ export async function downloadZip(token, markdown) {
export async function downloadZip(token, markdown, chatTitle) {
const res = await fetch(`${BASE}/files/download-zip`, { const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title: chatTitle || null }), method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }),
}); });
if (!res.ok) throw new Error("Download failed"); if (!res.ok) throw new Error("Download failed");
const ct = res.headers.get("content-type") || ""; const ct = res.headers.get("content-type") || "";
...@@ -122,9 +149,7 @@ export async function downloadZip(token, markdown, chatTitle) { ...@@ -122,9 +149,7 @@ export async function downloadZip(token, markdown, chatTitle) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
const raw = (chatTitle || "").trim(); a.download = "son-of-anton-code.zip";
const safeName = raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code";
a.download = `${safeName}.zip`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} else { } else {
...@@ -134,30 +159,47 @@ export async function downloadZip(token, markdown, chatTitle) { ...@@ -134,30 +159,47 @@ export async function downloadZip(token, markdown, chatTitle) {
} }
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0 // GitLab Repository API — Son of Anton v4.1.0
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token); export function gitlabGetTree(token, repoId, ref = "", path = "") {
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data); const p = new URLSearchParams();
export const gitlabTestConnection = (token, data) => request("POST", "/gitlab/test-connection", token, data); if (ref) p.set("ref", ref);
export const gitlabSearchProjects = (token, search, owned) => if (path) p.set("path", path);
request("GET", `/gitlab/projects?search=${encodeURIComponent(search || "")}&owned=${owned || false}`, token); return request("GET", `/gitlab/repos/${repoId}/tree?${p}`, token);
export const gitlabCreateProject = (token, data) => request("POST", "/gitlab/projects", token, data); }
export const gitlabListRepos = (token) => request("GET", "/gitlab/repos", token);
export const gitlabLinkRepo = (token, gitlabProjectId) => request("POST", "/gitlab/repos", token, { gitlab_project_id: gitlabProjectId }); export function gitlabGetFile(token, repoId, filePath, ref = "") {
export const gitlabUnlinkRepo = (token, repoId) => request("DELETE", `/gitlab/repos/${repoId}`, token); const p = new URLSearchParams({ path: filePath });
export const gitlabGetTree = (token, repoId, path, ref) => if (ref) p.set("ref", ref);
request("GET", `/gitlab/repos/${repoId}/tree?path=${encodeURIComponent(path || "")}&ref=${encodeURIComponent(ref || "")}`, token); return request("GET", `/gitlab/repos/${repoId}/file?${p}`, token);
export const gitlabGetFile = (token, repoId, path, ref) => }
request("GET", `/gitlab/repos/${repoId}/file?path=${encodeURIComponent(path)}&ref=${encodeURIComponent(ref || "")}`, token);
export const gitlabGetBranches = (token, repoId) => request("GET", `/gitlab/repos/${repoId}/branches`, token); export async function gitlabFileExists(token, repoId, filePath, ref = "") {
export const gitlabCreateBranch = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/branches`, token, data); try {
export const gitlabCommit = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit`, token, data); await gitlabGetFile(token, repoId, filePath, ref);
export const gitlabCommitSingle = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data); return true;
export const gitlabCreateMR = (token, repoId, data) => request("POST", `/gitlab/repos/${repoId}/merge-request`, token, data); } catch {
export const gitlabAnalyzeProject = (token, repoId, ref) => return false;
request("GET", `/gitlab/repos/${repoId}/analyze?ref=${encodeURIComponent(ref || "")}`, token); }
export const gitlabListActions = (token, status) => request("GET", `/gitlab/actions?status=${status || "pending"}`, token); }
export const gitlabCreateAction = (token, data) => request("POST", "/gitlab/actions", token, data);
export const gitlabApproveAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/approve`, token); export function gitlabCommitSingle(token, repoId, data) {
export const gitlabRejectAction = (token, actionId) => request("POST", `/gitlab/actions/${actionId}/reject`, token); return request("POST", `/gitlab/repos/${repoId}/commit-single`, token, data);
\ No newline at end of file }
export function gitlabCommitMulti(token, repoId, data) {
return request("POST", `/gitlab/repos/${repoId}/commit`, token, data);
}
export function gitlabGetBranches(token, repoId) {
return request("GET", `/gitlab/repos/${repoId}/branches`, token);
}
export function gitlabCreateBranch(token, repoId, data) {
return request("POST", `/gitlab/repos/${repoId}/branches`, token, data);
}
export function gitlabListRepos(token) {
return request("GET", `/gitlab/repos`, token);
}
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api"; import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager"; import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import RepoFilePanel from "./RepoFilePanel";
Send, Square, Settings2, X, Brain, BookOpen, Paperclip, import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, GitBranch } from "lucide-react";
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
} from "lucide-react";
const MODELS = [ const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" }, { id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" }, { id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
]; ];
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode }; function classifyFile(file) {
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" }; const ext = (file.name || "").split(".").pop().toLowerCase();
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" }; const mime = file.type || "";
function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document"; if (mime === "application/pdf" || ext === "pdf") return "document";
return "text"; return "text";
} }
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const currentChat = state.chats.find(c => c.id === chatId); const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || []; const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin"; const isStreamingGlobal = !!state.activeStreams[chatId];
const linkedRepo = currentChat?.linked_repo || null;
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [showRepoPanel, setShowRepoPanel] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [repos, setRepos] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId)); const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null); const scrollRef = useRef(null);
...@@ -60,9 +50,18 @@ export default function ChatView({ chatId }) { ...@@ -60,9 +50,18 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]); }, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => { const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return; if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); rafRef.current = requestAnimationFrame(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
rafRef.current = null;
});
}, []); }, []);
useEffect(() => { useEffect(() => {
...@@ -71,194 +70,201 @@ export default function ChatView({ chatId }) { ...@@ -71,194 +70,201 @@ export default function ChatView({ chatId }) {
const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]); const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData); setKbs(kbData);
if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } }
} catch { } } catch { }
})(); })();
}, [chatId, state.token, dispatch]); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
setSelectedRepoId(currentChat.linked_repo_id || null);
}
}, [chatId]);
function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
async function saveSettings() { async function saveSettings() {
try { try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId } }); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
} catch { } } catch { }
} }
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); } function toggleSettings() {
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); } if (showSettings) saveSettings();
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); } setShowSettings(!showSettings);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
e.target.value = "";
}
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
async function handleSend() { async function handleSend() {
const content = input.trim(); const content = input.trim();
if ((!content && !pendingFiles.length) || streamData.streaming) return; if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
const text = content || "Please analyze the attached file(s)."; const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = []; let attIds = [], uploaded = [];
if (pendingFiles.length) { if (pendingFiles.length) {
setUploading(true); setUploading(true);
try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch { setUploading(false); return; } try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
setUploading(false); setUploading(false);
} }
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } }); dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true; setInput("");
if (inputRef.current) inputRef.current.style.height = "auto"; pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } }); streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
} }
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } } function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) { const items = Array.from(e.clipboardData?.items || []).filter(i => i.kind === "file"); if (!items.length) return; e.preventDefault(); addFiles(items.map(i => i.getAsFile()).filter(Boolean)); }
function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = Array.from(e.dataTransfer?.files || []); if (files.length) addFiles(files); }
const streaming = streamData.streaming; function handlePaste(e) {
const linkedRepo = currentChat?.linked_repo; const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
}
async function handleCommitFromChat(filePath, code, action) { function handleDrop(e) {
if (!linkedRepo) return; e.preventDefault();
const branch = linkedRepo.default_branch; const files = Array.from(e.dataTransfer?.files || []);
const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`); if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
if (!msg) return;
try {
await gitlabCommitSingle(state.token, linkedRepo.id, { branch, file_path: filePath, content: code, commit_message: msg, action });
alert(`✅ Committed to ${branch}`);
} catch (e) { alert(`❌ ${e.message}`); }
} }
return ( const streaming = streamData.streaming;
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
{dragOver && (
<div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none">
<div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div>
</div>
)}
{/* Repo banner */} return (
{linkedRepo && ( <div className="flex-1 flex min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs"> {/* Main Chat Column */}
<GitBranch size={12} className="text-orange-400" /> <div className="flex-1 flex flex-col min-h-0 min-w-0">
<span className="text-orange-300 font-medium">{linkedRepo.name}</span> <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<span className="text-orange-300/60">({linkedRepo.default_branch})</span> {messages.map((m) => (
<span className="text-orange-300/40 ml-auto">Project-aware mode</span> <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} />
))}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }}
isStreaming isThinking={streamData.isThinking} token={state.token} linkedRepo={linkedRepo}
/>
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div>
)}
</div> </div>
)}
{/* Messages */} {/* Input Area */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto overscroll-contain px-3 sm:px-4 py-3 sm:py-4 space-y-3"> <div className="border-t border-anton-border bg-anton-surface p-4">
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)} {showSettings && (
{streaming && (streamData.thinking || streamData.text) && ( <div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} /> <div className="flex items-center justify-between">
)} <h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
{streaming && !streamData.text && !streamData.thinking && ( <button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in"> </div>
<div className="flex gap-1">{[0, 150, 300].map(d => <span key={d} className="w-1.5 h-1.5 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}</div> <div>
<span className="text-anton-muted text-sm">Thinking…</span> <label className="text-xs text-anton-muted mb-1 block">Model</label>
</div> <select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
)} {MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</div> </select>
</div>
{/* Input area */} <div>
<div className="border-t border-anton-border bg-anton-surface px-3 pt-2 pb-2 sm:px-4 sm:pt-3 sm:pb-3 safe-bottom"> <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
{showSettings && ( <input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto"> </div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
</div>
{isSuperadmin && repos.length > 0 && (
<div> <div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label> <div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"> <input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option> <option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>{r.name} ({r.default_branch})</option>)} {kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select> </select>
</div> </div>
)} </div>
</div> )}
)}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5 animate-fade-in"> <div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => (
const Icon = TYPE_ICONS[pf.type] || FileText; <div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
return ( {pf.type === "image" && pf.preview ? (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}> <img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm:h-16 object-cover" /> : ( ) : (
<div className="w-14 h-14 sm:w-16 sm:h-16 flex flex-col items-center justify-center px-1"> <div className="w-16 h-16 flex flex-col items-center justify-center px-1">
<Icon size={16} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} /> <FileText size={20} className="text-anton-muted mb-1" />
<span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span> <span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
</div> </div>
)} )}
<button onClick={() => removePending(i)} className="absolute -top-0.5 -right-0.5 w-5 h-5 bg-red-600 rounded-full flex items-center justify-center text-white shadow transition-opacity sm:opacity-0 sm:group-hover:opacity-100"><X size={10} /></button> <button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div> <div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div> </div>
); ))}
})} </div>
</div> )}
)}
<div className="flex items-end gap-1.5"> <div className="flex items-end gap-2">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button> <button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button> <button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} /> {linkedRepo && (
<div className="flex-1 min-w-0"> <button onClick={() => setShowRepoPanel(!showRepoPanel)} title={`${linkedRepo.name} — File Browser`}
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} className={`p-2.5 rounded-xl transition shrink-0 ${showRepoPanel ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-anton-card"}`}>
placeholder={pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} <GitBranch size={18} />
rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" </button>
onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} /> )}
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<div className="flex-1 relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
rows={1} style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div> </div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center disabled:opacity-30">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-anton-muted flex-wrap"> <div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
<span>{MODELS.find(m => m.id === model)?.label}</span> <span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tok</span> <span></span><span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>} {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>} {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{linkedRepo && <><span></span><span className="text-orange-400">🔀 {linkedRepo.name}</span></>} {linkedRepo && <><span></span><span className="text-green-400">🔗 {linkedRepo.name}</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</span></>} {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
{messages.some(m => m.role === "assistant") && ( {messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter(m => m.role === "assistant").map(m => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all, currentChat?.title); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ Code</button> <button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch { } }}
)} className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
)}
</div>
</div> </div>
</div> </div>
{/* Repo File Panel (slides in from right) */}
{showRepoPanel && linkedRepo && (
<RepoFilePanel linkedRepo={linkedRepo} token={state.token} onClose={() => setShowRepoPanel(false)} />
)}
</div> </div>
); );
} }
\ No newline at end of file
import React, { useState } from "react"; import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitCommitVertical } from "lucide-react"; import { Copy, Check, Download, GitBranch, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { gitlabFileExists, gitlabCommitSingle } from "../api";
export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) { const COMMIT_STATES = { idle: "idle", checking: "checking", committing: "committing", success: "success", error: "error" };
export default function CodeBlock({ language, filename, code, linkedRepo, token }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [commitState, setCommitState] = useState(COMMIT_STATES.idle);
const [commitMsg, setCommitMsg] = useState("");
const [commitError, setCommitError] = useState("");
const [showCommitForm, setShowCommitForm] = useState(false);
function handleCopy() { function handleCopy() {
navigator.clipboard.writeText(code); navigator.clipboard.writeText(code);
...@@ -22,43 +29,119 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm ...@@ -22,43 +29,119 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function handleCommit() { function toggleCommitForm() {
if (!onCommit || !filename) return; if (!showCommitForm) {
onCommit(filename, code, "update"); setCommitMsg(`Update ${filename || "file"} via Son of Anton`);
setCommitError("");
setCommitState(COMMIT_STATES.idle);
}
setShowCommitForm(!showCommitForm);
}
async function handleCommit() {
if (!linkedRepo || !token || !filename) return;
setCommitState(COMMIT_STATES.checking);
setCommitError("");
try {
const exists = await gitlabFileExists(token, linkedRepo.id, filename, linkedRepo.default_branch);
const action = exists ? "update" : "create";
setCommitState(COMMIT_STATES.committing);
await gitlabCommitSingle(token, linkedRepo.id, {
branch: linkedRepo.default_branch,
file_path: filename,
content: code,
commit_message: commitMsg || `${action === "create" ? "Create" : "Update"} ${filename} via Son of Anton`,
action,
});
setCommitState(COMMIT_STATES.success);
setTimeout(() => { setCommitState(COMMIT_STATES.idle); setShowCommitForm(false); }, 2500);
} catch (err) {
setCommitState(COMMIT_STATES.error);
setCommitError(err.message || "Commit failed");
}
} }
const canCommit = linkedRepo && filename && token;
return ( return (
<div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]"> <div className="rounded-lg overflow-hidden border border-anton-border my-3">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-anton-surface border-b border-anton-border"> <div className="flex items-center justify-between bg-[#1e1e2e] px-3 py-2 gap-2">
<div className="flex items-center gap-2 min-w-0"> <span className="text-xs text-anton-muted font-mono truncate min-w-0">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>} {filename || language || "code"}
{filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>} </span>
</div> <div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-0.5 shrink-0"> {canCommit && (
{linkedRepo && filename && ( <button onClick={toggleCommitForm} title="Commit to repo"
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`} className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition ${showCommitForm
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"> ? "bg-green-500/20 text-green-400"
<GitCommitVertical size={11} /> Commit : "text-anton-muted hover:text-green-400 hover:bg-green-500/10"
}`}>
<GitBranch size={12} />
<span className="hidden sm:inline">Push</span>
</button> </button>
)} )}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download"> {filename && (
<Download size={12} /> <button onClick={handleDownload} title="Download file"
</button> className="p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition">
<button onClick={handleCopy} className="p-1.5 text-anton-muted hover:text-white transition" title="Copy"> <Download size={13} />
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />} </button>
)}
<button onClick={handleCopy} title="Copy code"
className="p-1.5 rounded text-anton-muted hover:text-white hover:bg-white/5 transition">
{copied ? <Check size={13} className="text-green-400" /> : <Copy size={13} />}
</button> </button>
</div> </div>
</div> </div>
{/* Commit Form */}
{showCommitForm && canCommit && (
<div className="bg-[#16162a] border-b border-anton-border px-3 py-2.5 space-y-2 animate-fade-in">
<div className="flex items-center gap-2 text-[11px]">
<span className="text-anton-muted">Repo:</span>
<span className="text-green-400 font-mono">{linkedRepo.name}</span>
<span className="text-anton-muted"></span>
<span className="text-blue-400 font-mono">{linkedRepo.default_branch}</span>
</div>
<input
type="text" value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
placeholder="Commit message..."
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-xs text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50"
/>
<div className="flex items-center gap-2">
<button onClick={handleCommit}
disabled={commitState === COMMIT_STATES.checking || commitState === COMMIT_STATES.committing}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50 disabled:cursor-not-allowed">
{commitState === COMMIT_STATES.checking && <><Loader2 size={12} className="animate-spin" /> Checking...</>}
{commitState === COMMIT_STATES.committing && <><Loader2 size={12} className="animate-spin" /> Committing...</>}
{commitState === COMMIT_STATES.success && <><CheckCircle2 size={12} /> Committed!</>}
{commitState === COMMIT_STATES.error && <><XCircle size={12} /> Retry</>}
{commitState === COMMIT_STATES.idle && <><GitBranch size={12} /> Commit</>}
</button>
<button onClick={toggleCommitForm} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition">
Cancel
</button>
{commitState === COMMIT_STATES.success && (
<span className="text-[11px] text-green-400">✓ Pushed to {linkedRepo.default_branch}</span>
)}
</div>
{commitError && (
<div className="text-[11px] text-red-400 bg-red-500/10 rounded px-2 py-1">{commitError}</div>
)}
</div>
)}
{/* Code */} {/* Code */}
<SyntaxHighlighter <SyntaxHighlighter
language={language || "text"} language={language || "text"}
style={oneDark} style={vscDarkPlus}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }} customStyle={{ margin: 0, padding: "1rem", fontSize: "0.8rem", background: "#1a1a2e", maxHeight: "500px" }}
showLineNumbers={code.split("\n").length > 3} showLineNumbers
lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }} lineNumberStyle={{ minWidth: "2.5em", paddingRight: "1em", color: "#555" }}
wrapLongLines
> >
{code} {code}
</SyntaxHighlighter> </SyntaxHighlighter>
......
...@@ -8,16 +8,22 @@ import { ...@@ -8,16 +8,22 @@ import {
Image, Film, FileText, ExternalLink, Image, Film, FileText, ExternalLink,
} from "lucide-react"; } from "lucide-react";
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileText }; const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo, onCommit }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token, linkedRepo }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user"; const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [expandedImage, setExpandedImage] = useState(null); const [expandedImage, setExpandedImage] = useState(null);
function handleCopy() { navigator.clipboard.writeText(content || ""); setCopied(true); setTimeout(() => setCopied(false), 2000); } function handleCopy() {
navigator.clipboard.writeText(content || "");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
...@@ -34,14 +40,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -34,14 +40,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)} className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"> <button onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<Brain size={12} /> <Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />} {showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto"> <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto">
{thinking_content}{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
)} )}
</div> </div>
...@@ -49,7 +57,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -49,7 +57,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map(att => { {attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText; const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") {
...@@ -57,13 +65,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -57,13 +65,18 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<div key={att.id} className="relative group"> <div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename} <img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition" className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onError={e => { e.target.style.display = "none"; }} /> onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} />
{expandedImage === att.id && ( {expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer" onClick={() => setExpandedImage(null)}> <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
<img src={`${url}?token=${token}`} alt={att.original_filename} className="max-w-full max-h-full object-contain rounded-lg" /> onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg" />
</div> </div>
)} )}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">{att.original_filename}</div> <div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
{att.original_filename}
</div>
</div> </div>
); );
} }
...@@ -82,7 +95,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -82,7 +95,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div> </div>
)} )}
<div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"}`}> <div className={`rounded-2xl px-4 py-3 ${isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : ( ) : (
...@@ -93,14 +107,28 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -93,14 +107,28 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const rawLang = match?.[1] || ""; const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>; if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null; let lang = rawLang, filename = null;
if (rawLang.includes(":")) { const idx = rawLang.indexOf(":"); lang = rawLang.slice(0, idx); filename = rawLang.slice(idx + 1); } if (rawLang.includes(":")) {
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} linkedRepo={linkedRepo} onCommit={onCommit} />; const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return (
<CodeBlock
language={lang}
filename={filename}
code={String(children).replace(/\n$/, "")}
linkedRepo={linkedRepo}
token={token}
/>
);
}, },
pre({ children }) { return <>{children}</>; }, pre({ children }) { return <>{children}</>; },
}}> }}>
{content || ""} {content || ""}
</ReactMarkdown> </ReactMarkdown>
{isStreaming && !isThinking && <span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />} {isStreaming && !isThinking && (
<span className="inline-block w-1.5 h-4 bg-anton-accent ml-0.5 animate-pulse" />
)}
</div> </div>
)} )}
</div> </div>
...@@ -108,10 +136,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -108,10 +136,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1"> <div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"> <button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? "Copied" : "Copy"} {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted">{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}</span> <span className="text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}↑ tokens
</span>
)} )}
</div> </div>
)} )}
......
import React, { useState, useEffect, useCallback } from "react";
import {
X, FolderOpen, FileCode, ChevronRight, ChevronDown, GitBranch,
RefreshCw, Plus, Trash2, Save, Edit3, Eye, Loader2, FileText,
AlertCircle, CheckCircle2, FolderClosed,
} from "lucide-react";
import { gitlabGetTree, gitlabGetFile, gitlabCommitSingle, gitlabCommitMulti, gitlabGetBranches } from "../api";
const EXT_LANGS = {
py: "Python", js: "JavaScript", ts: "TypeScript", jsx: "React", tsx: "React TS",
cs: "C#", java: "Java", cpp: "C++", c: "C", go: "Go", rs: "Rust", rb: "Ruby",
php: "PHP", swift: "Swift", kt: "Kotlin", lua: "Lua", html: "HTML", css: "CSS",
json: "JSON", yaml: "YAML", yml: "YAML", xml: "XML", sql: "SQL", sh: "Shell",
md: "Markdown", txt: "Text", toml: "TOML", dart: "Dart", vue: "Vue",
};
function getFileExt(name) {
const parts = name.split(".");
return parts.length > 1 ? parts.pop().toLowerCase() : "";
}
function buildFileTree(items) {
const root = { name: "/", type: "tree", children: {}, path: "" };
for (const item of items) {
const parts = item.path.split("/");
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i < parts.length - 1) {
if (!node.children[part]) {
node.children[part] = { name: part, type: "tree", children: {}, path: parts.slice(0, i + 1).join("/") };
}
node = node.children[part];
} else {
node.children[part] = { ...item, children: item.type === "tree" ? {} : undefined };
}
}
}
return root;
}
function TreeNode({ node, depth, onSelect, selectedPath }) {
const [open, setOpen] = useState(depth < 2);
const isDir = node.type === "tree";
const isSelected = node.path === selectedPath;
const ext = !isDir ? getFileExt(node.name) : "";
const children = isDir ? Object.values(node.children).sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
}) : [];
return (
<div>
<button
onClick={() => { if (isDir) setOpen(!open); else onSelect(node.path); }}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs hover:bg-white/5 transition rounded ${isSelected ? "bg-anton-accent/15 text-anton-accent" : "text-anton-text"
}`}
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
{isDir ? (
open ? <ChevronDown size={12} className="text-anton-muted shrink-0" /> : <ChevronRight size={12} className="text-anton-muted shrink-0" />
) : <span className="w-3 shrink-0" />}
{isDir ? (
open ? <FolderOpen size={13} className="text-yellow-400 shrink-0" /> : <FolderClosed size={13} className="text-yellow-500/70 shrink-0" />
) : (
<FileCode size={13} className="text-blue-400 shrink-0" />
)}
<span className="truncate">{node.name}</span>
{ext && <span className="text-[9px] text-anton-muted ml-auto shrink-0">{EXT_LANGS[ext] || ext}</span>}
</button>
{isDir && open && children.map((child) => (
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} selectedPath={selectedPath} />
))}
</div>
);
}
export default function RepoFilePanel({ linkedRepo, token, onClose }) {
const [treeData, setTreeData] = useState(null);
const [branches, setBranches] = useState([]);
const [branch, setBranch] = useState(linkedRepo?.default_branch || "main");
const [selectedFile, setSelectedFile] = useState(null);
const [fileContent, setFileContent] = useState("");
const [editContent, setEditContent] = useState("");
const [editing, setEditing] = useState(false);
const [loading, setLoading] = useState(false);
const [fileLoading, setFileLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [commitMsg, setCommitMsg] = useState("");
const [status, setStatus] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const [newFilePath, setNewFilePath] = useState("");
const [newFileContent, setNewFileContent] = useState("");
const [showDelete, setShowDelete] = useState(false);
const loadTree = useCallback(async () => {
if (!linkedRepo) return;
setLoading(true);
try {
const data = await gitlabGetTree(token, linkedRepo.id, branch);
setTreeData(buildFileTree(data.items || []));
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setLoading(false);
}
}, [linkedRepo, token, branch]);
const loadBranches = useCallback(async () => {
if (!linkedRepo) return;
try {
const data = await gitlabGetBranches(token, linkedRepo.id);
setBranches(data);
} catch { }
}, [linkedRepo, token]);
useEffect(() => { loadTree(); loadBranches(); }, [loadTree, loadBranches]);
async function handleSelectFile(path) {
setSelectedFile(path);
setEditing(false);
setFileLoading(true);
setStatus(null);
try {
const data = await gitlabGetFile(token, linkedRepo.id, path, branch);
setFileContent(data.content || "");
setEditContent(data.content || "");
} catch (err) {
setFileContent(`[Error loading file: ${err.message}]`);
} finally {
setFileLoading(false);
}
}
async function handleSave() {
if (!selectedFile || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitSingle(token, linkedRepo.id, {
branch,
file_path: selectedFile,
content: editContent,
commit_message: commitMsg || `Update ${selectedFile} via Son of Anton`,
action: "update",
});
setFileContent(editContent);
setEditing(false);
setCommitMsg("");
setStatus({ type: "success", text: `Committed to ${branch}` });
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
async function handleCreate() {
if (!newFilePath.trim() || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitSingle(token, linkedRepo.id, {
branch,
file_path: newFilePath.trim(),
content: newFileContent || "",
commit_message: `Create ${newFilePath.trim()} via Son of Anton`,
action: "create",
});
setShowCreate(false);
setNewFilePath("");
setNewFileContent("");
setStatus({ type: "success", text: `Created ${newFilePath.trim()}` });
loadTree();
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!selectedFile || !linkedRepo) return;
setSaving(true);
setStatus(null);
try {
await gitlabCommitMulti(token, linkedRepo.id, {
branch,
commit_message: `Delete ${selectedFile} via Son of Anton`,
actions: [{ action: "delete", file_path: selectedFile }],
});
setShowDelete(false);
setSelectedFile(null);
setFileContent("");
setStatus({ type: "success", text: `Deleted ${selectedFile}` });
loadTree();
setTimeout(() => setStatus(null), 3000);
} catch (err) {
setStatus({ type: "error", text: err.message });
} finally {
setSaving(false);
}
}
if (!linkedRepo) return null;
return (
<div className="w-[420px] shrink-0 border-l border-anton-border bg-anton-surface flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="p-3 border-b border-anton-border space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<GitBranch size={14} className="text-green-400 shrink-0" />
<span className="text-sm font-semibold text-white truncate">{linkedRepo.name}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={loadTree} title="Refresh" className="p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition">
<RefreshCw size={13} className={loading ? "animate-spin" : ""} />
</button>
<button onClick={onClose} className="p-1.5 rounded hover:bg-white/5 text-anton-muted hover:text-white transition">
<X size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<select value={branch} onChange={(e) => setBranch(e.target.value)}
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-green-500/50">
{branches.map((b) => <option key={b.name} value={b.name}>{b.name}{b.default ? " ★" : ""}</option>)}
{!branches.length && <option value={branch}>{branch}</option>}
</select>
<button onClick={() => { setShowCreate(!showCreate); setShowDelete(false); }} title="New file"
className={`p-1.5 rounded transition ${showCreate ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-green-400 hover:bg-green-500/10"}`}>
<Plus size={14} />
</button>
</div>
{status && (
<div className={`flex items-center gap-1.5 text-[11px] px-2 py-1 rounded ${status.type === "success" ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
}`}>
{status.type === "success" ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
{status.text}
</div>
)}
</div>
{/* Create New File Form */}
{showCreate && (
<div className="p-3 border-b border-anton-border space-y-2 bg-green-500/5 animate-fade-in">
<div className="text-xs font-medium text-green-400">Create New File</div>
<input type="text" value={newFilePath} onChange={(e) => setNewFilePath(e.target.value)}
placeholder="path/to/file.ext"
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-xs text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50 font-mono" />
<textarea value={newFileContent} onChange={(e) => setNewFileContent(e.target.value)}
placeholder="File content (optional)..." rows={4}
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-xs text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50 font-mono resize-none" />
<div className="flex items-center gap-2">
<button onClick={handleCreate} disabled={!newFilePath.trim() || saving}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-green-600 hover:bg-green-500 text-white text-xs font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />} Create
</button>
<button onClick={() => setShowCreate(false)} className="px-3 py-1.5 rounded text-xs text-anton-muted hover:text-white transition">Cancel</button>
</div>
</div>
)}
{/* File Tree */}
<div className="flex-1 overflow-y-auto min-h-0">
{loading && !treeData ? (
<div className="flex items-center justify-center py-8 text-anton-muted text-xs"><Loader2 size={16} className="animate-spin mr-2" /> Loading tree...</div>
) : treeData ? (
<div className="py-1">
{Object.values(treeData.children).sort((a, b) => {
if (a.type === "tree" && b.type !== "tree") return -1;
if (a.type !== "tree" && b.type === "tree") return 1;
return a.name.localeCompare(b.name);
}).map((node) => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} selectedPath={selectedFile} />
))}
</div>
) : (
<div className="text-center py-8 text-anton-muted text-xs">No files loaded</div>
)}
</div>
{/* File Viewer / Editor */}
{selectedFile && (
<div className="border-t border-anton-border flex flex-col" style={{ height: "45%" }}>
<div className="flex items-center justify-between px-3 py-2 border-b border-anton-border bg-anton-card">
<div className="flex items-center gap-1.5 min-w-0">
<FileText size={12} className="text-blue-400 shrink-0" />
<span className="text-[11px] text-white font-mono truncate">{selectedFile}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{!editing ? (
<button onClick={() => { setEditing(true); setEditContent(fileContent); setCommitMsg(`Update ${selectedFile}`); }}
title="Edit" className="p-1 rounded text-anton-muted hover:text-yellow-400 hover:bg-yellow-500/10 transition">
<Edit3 size={12} />
</button>
) : (
<button onClick={() => setEditing(false)} title="View"
className="p-1 rounded text-yellow-400 bg-yellow-500/10 transition">
<Eye size={12} />
</button>
)}
<button onClick={() => { setShowDelete(true); setShowCreate(false); }} title="Delete"
className="p-1 rounded text-anton-muted hover:text-red-400 hover:bg-red-500/10 transition">
<Trash2 size={12} />
</button>
<button onClick={() => setSelectedFile(null)} className="p-1 rounded text-anton-muted hover:text-white transition">
<X size={12} />
</button>
</div>
</div>
{/* Delete Confirmation */}
{showDelete && (
<div className="px-3 py-2 bg-red-500/10 border-b border-red-500/20 animate-fade-in">
<div className="text-[11px] text-red-400 mb-2">Delete <strong>{selectedFile}</strong> from {branch}?</div>
<div className="flex items-center gap-2">
<button onClick={handleDelete} disabled={saving}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-red-600 hover:bg-red-500 text-white text-[11px] font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />} Delete
</button>
<button onClick={() => setShowDelete(false)} className="px-2.5 py-1 rounded text-[11px] text-anton-muted hover:text-white transition">Cancel</button>
</div>
</div>
)}
{/* Save bar */}
{editing && (
<div className="px-3 py-2 bg-yellow-500/5 border-b border-anton-border flex items-center gap-2">
<input type="text" value={commitMsg} onChange={(e) => setCommitMsg(e.target.value)} placeholder="Commit message..."
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-[11px] text-white placeholder-anton-muted focus:outline-none focus:border-green-500/50" />
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-green-600 hover:bg-green-500 text-white text-[11px] font-medium transition disabled:opacity-50">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Save size={11} />} Save
</button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-auto min-h-0">
{fileLoading ? (
<div className="flex items-center justify-center py-8 text-anton-muted text-xs"><Loader2 size={14} className="animate-spin mr-2" /> Loading...</div>
) : editing ? (
<textarea value={editContent} onChange={(e) => setEditContent(e.target.value)}
className="w-full h-full bg-[#1a1a2e] text-green-300 font-mono text-xs p-3 resize-none focus:outline-none" spellCheck={false} />
) : (
<pre className="text-xs text-anton-text font-mono p-3 whitespace-pre-wrap break-words">{fileContent}</pre>
)}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment