Commit 1a6de5e2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

try gitlabfix

parent 17ddd732
This diff is collapsed.
"""
Chat CRUD and message streaming — v4.0.0
Now with linked_repo_id for project-aware conversations.
Chat CRUD and message streaming — v4.0.0 Enhanced
Project-aware conversations + commit-from-chat support.
"""
import json
......@@ -13,9 +13,9 @@ from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo
from backend.auth import get_current_user
from backend.services import attachment_service
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
from backend.auth import get_current_user, require_superadmin
from backend.services import attachment_service, gitlab_service
from backend.services.generation_manager import manager as gen_manager
router = APIRouter()
......@@ -48,6 +48,12 @@ class SendMessageBody(BaseModel):
attachment_ids: list[str] = []
class CommitFromChatBody(BaseModel):
branch: str
commit_message: str
files: list[dict] # [{file_path, content, action}]
@router.get("")
def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chats = db.query(Chat).filter(Chat.user_id == user.id).order_by(Chat.updated_at.desc()).all()
......@@ -163,6 +169,71 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
return StreamingResponse(generate(), media_type="text/event-stream")
@router.post("/{chat_id}/commit")
async def commit_from_chat(
chat_id: str,
body: CommitFromChatBody,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Commit code from chat directly to the linked repository."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id:
raise HTTPException(400, "No repository linked to this chat")
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
raise HTTPException(404, "Linked repository not found")
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active:
raise HTTPException(400, "GitLab not configured")
# Build commit actions
actions = []
for f in body.files:
actions.append({
"action": f.get("action", "update"),
"file_path": f["file_path"],
"content": f["content"],
})
if not actions:
raise HTTPException(400, "No files to commit")
try:
result = await gitlab_service.commit_files(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id,
body.branch, body.commit_message, actions,
)
# Invalidate repo context cache so next message sees updated code
gen_manager.invalidate_repo_cache(repo.id)
return {
"ok": True,
"commit": result,
"files_committed": len(actions),
}
except gitlab_service.GitLabError as e:
raise HTTPException(e.status_code, f"Commit failed: {e.detail}")
@router.post("/{chat_id}/refresh-repo")
async def refresh_repo_context(
chat_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Force-refresh the cached repo context for this chat."""
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat or not chat.linked_repo_id:
raise HTTPException(400, "No repo linked")
gen_manager.invalidate_repo_cache(chat.linked_repo_id)
return {"ok": True}
def _sse(data):
return f"data: {json.dumps(data)}\n\n"
......
"""
Background generation manager — v4.0.0
Background generation manager — v4.0.0 Enhanced
Decouples AI generation from the SSE HTTP connection.
Now includes repository context for project-aware conversations.
Full repository awareness + persistent attachment context.
"""
import asyncio
import json
import time
from datetime import datetime
from typing import Optional
from dataclasses import dataclass, field
......@@ -17,6 +16,13 @@ from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service, gitlab_service
# ═══════════════════════════════════════════════════
# Repo context cache — avoids re-fetching every message
# ═══════════════════════════════════════════════════
_repo_cache: dict[str, tuple[float, str]] = {}
REPO_CACHE_TTL = 300 # 5 minutes
@dataclass
class GenerationState:
events: list = field(default_factory=list)
......@@ -78,16 +84,43 @@ class GenerationManager:
break
await asyncio.sleep(0.02)
# ═══════════════════════════════════════════════════
# Build FULL repo context with file contents + cache
# ═══════════════════════════════════════════════════
async def _build_repo_context(self, db, chat) -> Optional[str]:
"""Build repository context string if a repo is linked to this chat."""
"""Load full repository file contents for project-aware conversations."""
if not chat.linked_repo_id:
return None
repo = db.query(LinkedRepo).filter(LinkedRepo.id == chat.linked_repo_id).first()
if not repo:
return None
settings = db.query(GitLabSettings).first()
if not settings or not settings.is_active or not settings.gitlab_url or not settings.gitlab_token:
return None
# Check cache
cache_key = f"{repo.id}:{repo.default_branch}"
now = time.time()
if cache_key in _repo_cache:
ts, ctx = _repo_cache[cache_key]
if now - ts < REPO_CACHE_TTL:
return ctx
try:
# Load full file contents (up to 80 files, 300K chars)
result = await gitlab_service.load_project_files(
settings.gitlab_url, settings.gitlab_token,
repo.gitlab_project_id, ref=repo.default_branch,
)
context = self._format_full_repo_context(result, repo)
_repo_cache[cache_key] = (now, context)
return context
except Exception as e:
# Fallback: try just the tree
try:
tree = await gitlab_service.get_tree(
settings.gitlab_url, settings.gitlab_token,
......@@ -95,7 +128,40 @@ class GenerationManager:
)
return gitlab_service.format_tree_for_prompt(tree, repo.name, repo.default_branch)
except Exception:
return f"[Repository: {repo.name} — could not load file tree]"
return f"[Repository: {repo.name} — could not load: {str(e)[:100]}]"
def _format_full_repo_context(self, result: dict, repo) -> str:
"""Format loaded project files into a prompt-friendly string."""
lines = [
f"Repository: {repo.name}",
f"Branch: {repo.default_branch}",
f"Path: {repo.path_with_namespace}",
f"Total files in tree: {result.get('total_files_in_tree', '?')}",
f"Files loaded: {result.get('files_loaded', '?')}",
f"Total characters: {result.get('total_characters', '?')}",
"",
"FILE CONTENTS:",
"=" * 60,
]
for f in result.get("files", []):
path = f.get("path", "?")
content = f.get("content", "")
lines.append(f"\n━━━ {path} ━━━")
lines.append(content)
lines.append(f"━━━ end {path} ━━━")
return "\n".join(lines)
def invalidate_repo_cache(self, repo_id: str):
"""Call when a commit is made to refresh the cache."""
keys_to_remove = [k for k in _repo_cache if k.startswith(f"{repo_id}:")]
for k in keys_to_remove:
_repo_cache.pop(k, None)
# ═══════════════════════════════════════════════════
# Main generation loop
# ═══════════════════════════════════════════════════
async def _run(
self,
......@@ -118,6 +184,7 @@ class GenerationManager:
db_user = db.query(User).filter(User.id == user_id).first()
# Quota check + reset
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
......@@ -131,6 +198,7 @@ class GenerationManager:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return
# Process current message attachments
attachments = []
if attachment_ids:
attachments = (
......@@ -155,6 +223,7 @@ class GenerationManager:
if attachments:
db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
......@@ -163,17 +232,29 @@ class GenerationManager:
except Exception:
pass
# Build repo context for project-aware conversations
# ── FULL REPO CONTEXT (loads all file contents) ──
repo_context = await self._build_repo_context(db, chat)
system_prompt = build_full_prompt(rag_context, repo_context)
# ── PERSISTENT ATTACHMENT CONTEXT (all files in chat) ──
attachment_context = memory_service.gather_attachment_context(chat_id, db)
# Build system prompt with ALL context
system_prompt = build_full_prompt(
rag_context=rag_context,
repo_context=repo_context,
attachment_context=attachment_context,
)
# Build conversation messages
messages = memory_service.build_messages(chat, db)
# Inject current message's multimodal content blocks
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks
# Thinking / reasoning config
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
......@@ -224,6 +305,7 @@ class GenerationManager:
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
......@@ -240,6 +322,7 @@ class GenerationManager:
state.message_id = assistant_msg.id
# Auto-generate title for first message
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
......
"""
Build the messages list for the Bedrock/Anthropic API from chat history.
Also gathers attachment context for persistent file awareness.
"""
from sqlalchemy.orm import Session
from backend.models import Chat, Message
from backend.models import Chat, Message, ChatAttachment
MAX_CONTEXT_CHARS = 400_000
MAX_MESSAGES = 80
MAX_ATTACHMENT_CONTEXT_CHARS = 200_000
def build_messages(chat: Chat, db: Session) -> list[dict]:
......@@ -47,3 +49,52 @@ def build_messages(chat: Chat, db: Session) -> list[dict]:
result.append({"role": role, "content": content})
return result
def gather_attachment_context(chat_id: str, db: Session) -> str | None:
"""
Collect text extracts from ALL attachments in a chat.
This ensures uploaded files remain "visible" to the AI
throughout the entire conversation, not just in the message
where they were uploaded.
"""
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.chat_id == chat_id)
.order_by(ChatAttachment.created_at)
.all()
)
if not attachments:
return None
parts = []
total_chars = 0
for att in attachments:
if att.file_type in ("image",):
# Images: just note they exist (can't include binary in text)
entry = f"[Uploaded Image: {att.original_filename} ({att.file_size} bytes)]"
elif att.file_type in ("video",):
entry = f"[Uploaded Video: {att.original_filename} ({att.file_size} bytes)]"
elif att.text_extract:
# Text files and PDFs: include full content
text = att.text_extract
remaining = MAX_ATTACHMENT_CONTEXT_CHARS - total_chars
if remaining <= 0:
parts.append(f"[{att.original_filename}: content truncated — context limit reached]")
continue
if len(text) > remaining:
text = text[:remaining] + "\n... [truncated]"
entry = (
f"━━━ {att.original_filename} ({att.file_type}, {att.file_size} bytes) ━━━\n"
f"{text}\n"
f"━━━ end of {att.original_filename} ━━━"
)
else:
entry = f"[Uploaded {att.file_type}: {att.original_filename} ({att.file_size} bytes) — no text extracted]"
parts.append(entry)
total_chars += len(entry)
return "\n\n".join(parts) if parts else None
\ No newline at end of file
......@@ -41,6 +41,11 @@ export const renameChat = (token, chatId, title) => updateChat(token, chatId, {
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);
export const refreshRepoContext = (token, chatId) => request("POST", `/chats/${chatId}/refresh-repo`, token);
// ═══════════ Commit from Chat ═══════════
export const commitFromChat = (token, chatId, data) =>
request("POST", `/chats/${chatId}/commit`, token, data);
// ═══════════ Streaming ═══════════
export async function* streamMessage(token, chatId, body, signal) {
......@@ -133,10 +138,24 @@ export async function downloadZip(token, markdown, chatTitle) {
}
}
// ═══════════════════════════════════════════════════
// GitLab CE Integration — v4.0.0
// ═══════════════════════════════════════════════════
// ═══════════ Utilities ═══════════
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export function extractCodeBlocks(markdown) {
if (!markdown) return [];
const blocks = [];
let match;
const re = new RegExp(CODE_BLOCK_RE.source, "g");
while ((match = re.exec(markdown)) !== null) {
const lang = (match[1] || "text").toLowerCase();
const filename = match[2] || null;
const code = (match[3] || "").trim();
if (code) blocks.push({ language: lang, filename, code });
}
return blocks;
}
// ═══════════ GitLab ═══════════
export const gitlabGetSettings = (token) => request("GET", "/gitlab/settings", token);
export const gitlabUpdateSettings = (token, data) => request("PUT", "/gitlab/settings", token, data);
export const gitlabTestConnection = (token) => request("POST", "/gitlab/test-connection", token);
......
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle } from "../api";
import {
getMessages, downloadZip, listKnowledgeBases, updateChat,
uploadAttachments, gitlabListRepos, gitlabCommitSingle,
refreshRepoContext,
} from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
GitBranch,
GitBranch, RefreshCw,
} from "lucide-react";
const MODELS = [
......@@ -47,6 +51,7 @@ export default function ChatView({ chatId }) {
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [refreshingRepo, setRefreshingRepo] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
......@@ -88,20 +93,23 @@ export default function ChatView({ chatId }) {
}
}, [chatId]);
function onScroll() { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}, []);
async function saveSettings() {
const saveSettings = useCallback(async () => {
try {
await updateChat(state.token, 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, linked_repo_id: selectedRepoId } });
} catch { }
}
}, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, dispatch]);
function toggleSettings() { if (showSettings) saveSettings(); setShowSettings(!showSettings); }
function addFiles(files) { setPendingFiles(prev => [...prev, ...files.map(f => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]); }
function removePending(i) { setPendingFiles(prev => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); }); }
async function handleSend() {
const handleSend = useCallback(async () => {
const content = input.trim();
if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s).";
......@@ -115,7 +123,7 @@ export default function ChatView({ chatId }) {
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto";
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
}
}, [input, pendingFiles, streamData.streaming, state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, dispatch]);
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)); }
......@@ -124,15 +132,22 @@ export default function ChatView({ chatId }) {
const streaming = streamData.streaming;
const linkedRepo = currentChat?.linked_repo;
async function handleCommitFromChat(filePath, code, action) {
const handleCommitFromChat = useCallback(async (filePath, code, action) => {
if (!linkedRepo) return;
const branch = linkedRepo.default_branch;
const msg = prompt("Commit message:", `Update ${filePath} via Son of Anton`);
const msg = prompt("Commit message:", `${action === "create" ? "Create" : "Update"} ${filePath} via Son of Anton`);
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}`); }
// Refresh repo cache so AI sees updated code
try { await refreshRepoContext(state.token, chatId); } catch { }
} catch (e) { alert(`❌ ${e.message}`); throw e; }
}, [linkedRepo, state.token, chatId]);
async function handleRefreshRepo() {
setRefreshingRepo(true);
try { await refreshRepoContext(state.token, chatId); } catch { }
setRefreshingRepo(false);
}
return (
......@@ -149,20 +164,25 @@ export default function ChatView({ chatId }) {
<GitBranch size={12} className="text-orange-400" />
<span className="text-orange-300 font-medium">{linkedRepo.name}</span>
<span className="text-orange-300/60">({linkedRepo.default_branch})</span>
<span className="text-orange-300/40 ml-auto">Project-aware mode</span>
<span className="text-orange-300/40">Full codebase loaded</span>
<button onClick={handleRefreshRepo} disabled={refreshingRepo} className="ml-auto text-orange-300/60 hover:text-orange-300 transition" title="Refresh repo context">
<RefreshCw size={12} className={refreshingRepo ? "animate-spin" : ""} />
</button>
</div>
)}
{/* Messages */}
<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">
{messages.map(m => <MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} />)}
{messages.map(m => (
<MessageBubble key={m.id} message={m} token={state.token} linkedRepo={linkedRepo} onCommit={handleCommitFromChat} chatId={chatId} />
))}
{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} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-3 py-3 animate-fade-in">
<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>
<span className="text-anton-muted text-sm">Thinking…</span>
<span className="text-anton-muted text-sm">{linkedRepo ? "Loading codebase & thinking…" : "Thinking…"}</span>
</div>
)}
</div>
......@@ -198,11 +218,12 @@ export default function ChatView({ chatId }) {
</div>
{isSuperadmin && repos.length > 0 && (
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository (AI sees all files)</label>
<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">
<option value="">None</option>
{repos.map(r => <option key={r.id} value={r.id}>{r.name} ({r.default_branch})</option>)}
{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name} ({r.default_branch})</option>)}
</select>
<p className="text-[9px] text-orange-400/60 mt-1">When linked, AI loads the full codebase into context.</p>
</div>
)}
</div>
......@@ -214,7 +235,7 @@ export default function ChatView({ chatId }) {
const Icon = TYPE_ICONS[pf.type] || FileText;
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? <img src={pf.preview} alt="" className="w-14 h-14 sm:w-16 sm: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" loading="lazy" /> : (
<div className="w-14 h-14 sm:w-16 sm: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`} />
<span className="text-[7px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 8)}</span>
......
import React, { useState } from "react";
import React, { useState, useCallback } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Copy, Check, Download, GitCommitVertical } from "lucide-react";
import { Copy, Check, Download, GitCommitVertical, Loader2, Plus, Pencil } from "lucide-react";
export default function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
export default React.memo(function CodeBlock({ language, filename, code, linkedRepo, onCommit }) {
const [copied, setCopied] = useState(false);
const [committing, setCommitting] = useState(false);
const [commitDone, setCommitDone] = useState(false);
const [showCommitOptions, setShowCommitOptions] = useState(false);
function handleCopy() {
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [code]);
function handleDownload() {
const handleDownload = useCallback(() => {
const blob = new Blob([code], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
......@@ -20,28 +23,65 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
a.download = filename || `code.${language || "txt"}`;
a.click();
URL.revokeObjectURL(url);
}
}, [code, filename, language]);
function handleCommit() {
async function handleCommit(action) {
if (!onCommit || !filename) return;
onCommit(filename, code, "update");
setCommitting(true);
setShowCommitOptions(false);
try {
await onCommit(filename, code, action);
setCommitDone(true);
setTimeout(() => setCommitDone(false), 3000);
} catch { /* error handled in parent */ }
setCommitting(false);
}
const showGit = linkedRepo && filename;
const lineCount = code.split("\n").length;
return (
<div className="my-3 rounded-xl overflow-hidden border border-anton-border bg-[#1a1b26]">
{/* 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 px-3 py-1.5 bg-anton-surface border-b border-anton-border gap-2">
<div className="flex items-center gap-2 min-w-0">
{language && <span className="text-[10px] text-anton-accent font-mono uppercase">{language}</span>}
{language && <span className="text-[10px] text-anton-accent font-mono uppercase shrink-0">{language}</span>}
{filename && <span className="text-[10px] text-anton-muted truncate">{filename}</span>}
</div>
<div className="flex items-center gap-0.5 shrink-0">
{linkedRepo && filename && (
<button onClick={handleCommit} title={`Commit to ${linkedRepo.name}`}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition">
{/* Git commit buttons */}
{showGit && !commitDone && (
<div className="relative">
{committing ? (
<span className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400">
<Loader2 size={11} className="animate-spin" /> Committing…
</span>
) : (
<button onClick={() => setShowCommitOptions(!showCommitOptions)}
className="flex items-center gap-1 px-2 py-1 text-[10px] text-orange-400 hover:bg-orange-400/10 rounded transition"
title={`Commit to ${linkedRepo.name}`}>
<GitCommitVertical size={11} /> Commit
</button>
)}
{showCommitOptions && (
<div className="absolute right-0 top-full mt-1 z-20 bg-anton-card border border-anton-border rounded-lg shadow-xl p-1.5 min-w-[140px] animate-fade-in">
<button onClick={() => handleCommit("update")}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
<Pencil size={11} className="text-blue-400" /> Update file
</button>
<button onClick={() => handleCommit("create")}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-white hover:bg-anton-accent/10 rounded transition">
<Plus size={11} className="text-green-400" /> Create new
</button>
</div>
)}
</div>
)}
{commitDone && (
<span className="flex items-center gap-1 px-2 py-1 text-[10px] text-green-400">
<Check size={11} /> Committed!
</span>
)}
<button onClick={handleDownload} className="p-1.5 text-anton-muted hover:text-white transition" title="Download">
<Download size={12} />
</button>
......@@ -56,7 +96,7 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
language={language || "text"}
style={oneDark}
customStyle={{ margin: 0, padding: "12px 16px", fontSize: "12px", lineHeight: "1.5", background: "transparent" }}
showLineNumbers={code.split("\n").length > 3}
showLineNumbers={lineCount > 3}
lineNumberStyle={{ color: "#555", fontSize: "10px", paddingRight: "12px" }}
wrapLongLines
>
......@@ -64,4 +104,4 @@ export default function CodeBlock({ language, filename, code, linkedRepo, onComm
</SyntaxHighlighter>
</div>
);
}
\ No newline at end of file
});
\ No newline at end of file
This diff is collapsed.
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