diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d94a32b7..fb4d54ae 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -410,12 +410,262 @@ async fn git_create_branch(path: String, branch: String) -> Result Result { +async fn git_pull(path: String, rebase: Option) -> Result { + let mut args = vec!["pull"]; + if rebase.unwrap_or(false) { + args.push("--rebase"); + } + let output = silent_command("git") + .args(&args) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_fetch(path: String) -> Result { + let output = silent_command("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + // git fetch usually writes progress to stderr but nothing to stdout; on success we return + // the combined text (if any) so the UI can show "ok" without dumping error-looking output. + let combined = format!("{}{}", stdout, stderr); + Ok(combined.trim().to_string()) + } else { + Err(stderr) + } +} + +#[derive(serde::Serialize)] +struct GitLogEntry { + hash: String, + short_hash: String, + author: String, + email: String, + timestamp: i64, + subject: String, +} + +#[tauri::command] +async fn git_log(path: String, limit: Option) -> Result, String> { + let n = limit.unwrap_or(50); + // Unit Separator (0x1F) between fields, Record Separator (0x1E) between entries. + let format = "--pretty=format:%H\x1f%h\x1f%an\x1f%ae\x1f%at\x1f%s\x1e"; + let output = silent_command("git") + .args(["log", format, &format!("-n{}", n)]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + + let raw = String::from_utf8_lossy(&output.stdout).to_string(); + let mut entries = Vec::new(); + for record in raw.split('\x1e') { + let trimmed = record.trim_start_matches(['\n', '\r']); + if trimmed.is_empty() { continue; } + let fields: Vec<&str> = trimmed.splitn(6, '\x1f').collect(); + if fields.len() < 6 { continue; } + entries.push(GitLogEntry { + hash: fields[0].to_string(), + short_hash: fields[1].to_string(), + author: fields[2].to_string(), + email: fields[3].to_string(), + timestamp: fields[4].parse::().unwrap_or(0), + subject: fields[5].trim_end().to_string(), + }); + } + Ok(entries) +} + +#[tauri::command] +async fn git_merge(path: String, branch: String) -> Result { + validate_branch_name(&branch)?; + let output = silent_command("git") + .args(["merge", "--no-edit", &branch]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_reset(path: String, mode: String, target: String) -> Result { + if target.is_empty() || target.starts_with('-') { + return Err("Invalid reset target".to_string()); + } + let mode_flag = match mode.as_str() { + "soft" => "--soft", + "hard" => "--hard", + _ => "--mixed", + }; + let output = silent_command("git") + .args(["reset", mode_flag, &target]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_revert(path: String, commit: String) -> Result { + if commit.is_empty() || commit.starts_with('-') { + return Err("Invalid commit reference".to_string()); + } + let output = silent_command("git") + .args(["revert", "--no-edit", &commit]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[derive(serde::Serialize)] +struct GitStashEntry { + index: u32, + ref_name: String, + message: String, +} + +#[tauri::command] +async fn git_stash_list(path: String) -> Result, String> { + let output = silent_command("git") + .args(["stash", "list", "--pretty=format:%gd\x1f%s"]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).to_string()); + } + + let raw = String::from_utf8_lossy(&output.stdout).to_string(); + let mut entries = Vec::new(); + for (i, line) in raw.lines().enumerate() { + if line.is_empty() { continue; } + let parts: Vec<&str> = line.splitn(2, '\x1f').collect(); + entries.push(GitStashEntry { + index: i as u32, + ref_name: parts.first().copied().unwrap_or("").to_string(), + message: parts.get(1).copied().unwrap_or("").to_string(), + }); + } + Ok(entries) +} + +#[tauri::command] +async fn git_stash(path: String, message: Option) -> Result { + let msg = message.unwrap_or_default(); + let mut args = vec!["stash", "push", "--include-untracked"]; + if !msg.is_empty() { + args.push("-m"); + args.push(&msg); + } + let output = silent_command("git") + .args(&args) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_stash_pop(path: String, index: Option) -> Result { + let idx = index.unwrap_or(0); + let stash_ref = format!("stash@{{{}}}", idx); + let output = silent_command("git") + .args(["stash", "pop", &stash_ref]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_stash_apply(path: String, index: u32) -> Result { + let stash_ref = format!("stash@{{{}}}", index); + let output = silent_command("git") + .args(["stash", "apply", &stash_ref]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_stash_drop(path: String, index: u32) -> Result { + let stash_ref = format!("stash@{{{}}}", index); + let output = silent_command("git") + .args(["stash", "drop", &stash_ref]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn git_commit(path: String, message: String, amend: Option) -> Result { // We no longer automatically stage all changes. // Users must stage changes explicitly. + let mut args = vec!["commit"]; + if amend.unwrap_or(false) { + args.push("--amend"); + } + args.push("-m"); + args.push(&message); + let output = silent_command("git") - .args(["commit", "-m", &message]) + .args(&args) .current_dir(&path) .output() .map_err(|e| e.to_string())?; @@ -475,6 +725,73 @@ async fn git_push(path: String) -> Result { .output() .map_err(|e| e.to_string())?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + // First push of a new branch: fall back to --set-upstream origin . + let needs_upstream = stderr.contains("has no upstream branch") + || stderr.contains("--set-upstream") + || stderr.contains("The current branch"); + if needs_upstream { + let branch_output = silent_command("git") + .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + if branch_output.status.success() { + let branch = String::from_utf8_lossy(&branch_output.stdout).trim().to_string(); + if !branch.is_empty() { + let retry = silent_command("git") + .args(["push", "--set-upstream", "origin", &branch]) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + if retry.status.success() { + return Ok(String::from_utf8_lossy(&retry.stdout).to_string()); + } + return Err(String::from_utf8_lossy(&retry.stderr).to_string()); + } + } + } + Err(stderr) +} + +#[tauri::command] +async fn git_restore(path: String, files: Vec) -> Result { + // Discards working-tree changes for the given files (like `git restore `). + let mut args = vec!["restore", "--"]; + let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect(); + args.extend(file_refs); + + let output = silent_command("git") + .args(&args) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).to_string()) + } +} + +#[tauri::command] +async fn get_git_file_diff(path: String, file: String, staged: bool) -> Result { + let mut args = vec!["diff"]; + if staged { + args.push("--staged"); + } + args.push("--"); + args.push(&file); + let output = silent_command("git") + .args(&args) + .current_dir(&path) + .output() + .map_err(|e| e.to_string())?; + if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } else { @@ -780,6 +1097,19 @@ pub fn run() { get_git_branches, git_checkout_branch, git_create_branch, + git_pull, + git_fetch, + git_log, + git_merge, + git_reset, + git_revert, + git_stash, + git_stash_pop, + git_stash_apply, + git_stash_drop, + git_stash_list, + git_restore, + get_git_file_diff, git_commit, git_add, git_unstage, diff --git a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx index 1e52e655..d0461bca 100644 --- a/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx +++ b/apps/desktop/src/addons/builtin.git-explorer/GitExplorerComponent.tsx @@ -1,13 +1,14 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useRef } from "react"; import { Folder, File, ChevronRight, ChevronDown, RefreshCw, FolderOpen, Search, GitBranch, GitCommit, Upload, Plus, Sparkles, ChevronUp, FilePlus, FileX, FileEdit, Package, Terminal as TerminalIcon, Eye, Copy, ExternalLink, Settings, History, ClipboardPaste, FileCode, Trash2, - Minus + Minus, ArrowDown, Download, GitMerge, Archive, RotateCcw, Undo2, Check, AlertTriangle } from "lucide-react"; import { safeInvoke as invoke } from "@/api/tauri"; +import type { GitLogEntry, GitStashEntry } from "@/api/tauri"; import { open, ask } from "@tauri-apps/plugin-dialog"; import { useApp } from "@/context/AppContext"; import { useL10n } from "@/hooks/useL10n"; @@ -26,6 +27,16 @@ const STATUS_META: Record = "R": { icon: FileEdit, color: "text-blue-400/80" }, }; +function relativeParts(unixSeconds: number): { unit: "s" | "m" | "h" | "d" | "mo" | "y"; n: number } { + const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds); + if (diff < 60) return { unit: "s", n: diff }; + if (diff < 3600) return { unit: "m", n: Math.floor(diff / 60) }; + if (diff < 86400) return { unit: "h", n: Math.floor(diff / 3600) }; + if (diff < 2592000) return { unit: "d", n: Math.floor(diff / 86400) }; + if (diff < 31536000) return { unit: "mo", n: Math.floor(diff / 2592000) }; + return { unit: "y", n: Math.floor(diff / 31536000) }; +} + const GitExplorerComponent: React.FC = () => { const { openFile, activeSidebarTab, rootPath, setRootPath, openTerminal, currentFile, closeFile, aiSettings, systemSettings } = useApp(); const { t } = useL10n(); @@ -49,6 +60,29 @@ const GitExplorerComponent: React.FC = () => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entry: FileEntry } | null>(null); const [newEntry, setNewEntry] = useState<{ parentPath: string; type: "file" | "folder" } | null>(null); const [newEntryName, setNewEntryName] = useState(""); + const [gitLog, setGitLog] = useState([]); + const [stashes, setStashes] = useState([]); + const [showHistory, setShowHistory] = useState(false); + const [showStash, setShowStash] = useState(false); + const [stashMessage, setStashMessage] = useState(""); + const [hasConflicts, setHasConflicts] = useState(false); + const [amendMode, setAmendMode] = useState(false); + const [branchFilter, setBranchFilter] = useState(""); + const [logLimit, setLogLimit] = useState(30); + const [diffModal, setDiffModal] = useState<{ file: string; staged: boolean; content: string } | null>(null); + const [commitMenuOpen, setCommitMenuOpen] = useState(false); + const gitLoadingRef = useRef(false); + const flashTimeoutRef = useRef | null>(null); + const commitMenuRef = useRef(null); + + useEffect(() => { + if (!commitMenuOpen) return; + const onClickOutside = (e: MouseEvent) => { + if (commitMenuRef.current && !commitMenuRef.current.contains(e.target as Node)) setCommitMenuOpen(false); + }; + window.addEventListener("mousedown", onClickOutside); + return () => window.removeEventListener("mousedown", onClickOutside); + }, [commitMenuOpen]); const loadDirectory = useCallback(async (path: string, parentPath?: string) => { setLoading(true); @@ -162,20 +196,36 @@ const GitExplorerComponent: React.FC = () => { // X = staged status, Y = unstaged status const staged: GitFileChange[] = []; const unstaged: GitFileChange[] = []; + let conflicts = false; for (const l of lines) { const x = l[0]; // staged const y = l[1]; // unstaged const file = l.substring(3).trim(); + // Unmerged/conflict codes per porcelain v1: DD AU UD UA DU AA UU. + if ((x === "U" || y === "U") || (x === "A" && y === "A") || (x === "D" && y === "D")) { + conflicts = true; + } if (x !== " " && x !== "?") staged.push({ status: x, file }); if (y !== " " && y !== "?") unstaged.push({ status: y, file }); // Untracked files (??) go to unstaged if (x === "?" && y === "?") unstaged.push({ status: "??", file }); } + setHasConflicts(conflicts); setStagedChanges(staged); setGitChanges(unstaged); - const { branches: bl, current } = await invoke("get_git_branches", { path: rootPath }); - setBranches(bl); - setCurrentBranch(current); + const payload = await invoke("get_git_branches", { path: rootPath }); + const branchList = Array.isArray(payload?.branches) ? payload.branches : []; + setBranches(branchList); + setCurrentBranch(payload?.current ?? ""); + + try { + const log = await invoke("git_log", { path: rootPath, limit: logLimit }, { silent: true }); + setGitLog(Array.isArray(log) ? log : []); + } catch { setGitLog([]); } + try { + const sl = await invoke("git_stash_list", { path: rootPath }, { silent: true }); + setStashes(Array.isArray(sl) ? sl : []); + } catch { setStashes([]); } } catch (err) { const errStr = String(err).toLowerCase(); const isNotGitRepoError = @@ -203,8 +253,17 @@ const GitExplorerComponent: React.FC = () => { setGitChanges([]); setBranches([]); setCurrentBranch(""); + setGitLog([]); + setStashes([]); + setHasConflicts(false); } - }, [rootPath]); + }, [rootPath, logLimit]); + + useEffect(() => { gitLoadingRef.current = gitLoading; }, [gitLoading]); + + useEffect(() => () => { + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + }, []); useEffect(() => { if (activeSidebarTab !== "git" || !rootPath) return; @@ -213,7 +272,7 @@ const GitExplorerComponent: React.FC = () => { // Poll every 5 seconds while the git tab is active and the window is visible const interval = setInterval(() => { - if (document.visibilityState === "visible") { + if (document.visibilityState === "visible" && !gitLoadingRef.current) { refreshGit(); } }, 5000); @@ -245,19 +304,118 @@ const GitExplorerComponent: React.FC = () => { setNewBranchName(""); setShowBranchMenu(false); await refreshGit(); - flash(t('git.status.checkout_success', { branch: name })); + flash(t('git.status.branch_created')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; - const handleCommit = async () => { if (!rootPath || !commitMessage.trim()) return; setGitLoading(true); try { await invoke("git_commit", { path: rootPath, message: commitMessage }); setCommitMessage(""); await refreshGit(); flash(t('git.status.commit_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; + const handleCommit = async () => { if (!rootPath || !commitMessage.trim()) return; setGitLoading(true); try { await invoke("git_commit", { path: rootPath, message: commitMessage, amend: amendMode }); setCommitMessage(""); setAmendMode(false); await refreshGit(); flash(t('git.status.commit_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; const handlePush = async () => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_push", { path: rootPath }); flash(t('git.status.push_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; + const handlePull = async (rebase = false) => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_pull", { path: rootPath, rebase }); await refreshGit(); flash(t('git.status.pull_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; + const handleDiscard = async (file: string) => { + if (!rootPath) return; + if (!(await ask(t('git.discard.confirm', { file }), { title: 'Trixty IDE', kind: 'warning' }))) return; + setGitLoading(true); + try { + await invoke("git_restore", { path: rootPath, files: [file] }); + await refreshGit(); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleViewDiff = async (file: string, staged: boolean) => { + if (!rootPath) return; + try { + const content = await invoke("get_git_file_diff", { path: rootPath, file, staged }); + setDiffModal({ file, staged, content }); + } catch (e) { flash(t('git.error', { message: String(e) })); } + }; + const handleFetch = async () => { if (!rootPath) return; setGitLoading(true); try { await invoke("git_fetch", { path: rootPath }); await refreshGit(); flash(t('git.status.fetch_success')); } catch (e) { flash(t('git.error', { message: String(e) })); } finally { setGitLoading(false); } }; + const handleMerge = async (branch: string) => { + if (!rootPath) return; + if (!(await ask(t('git.merge.confirm', { branch }), { title: 'Trixty IDE', kind: 'warning' }))) return; + setGitLoading(true); + try { + await invoke("git_merge", { path: rootPath, branch }); + setShowBranchMenu(false); + await refreshGit(); + flash(t('git.status.merge_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleStash = async () => { + if (!rootPath) return; + setGitLoading(true); + try { + await invoke("git_stash", { path: rootPath, message: stashMessage.trim() || undefined }); + setStashMessage(""); + await refreshGit(); + flash(t('git.status.stash_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleStashPop = async (index: number) => { + if (!rootPath) return; + setGitLoading(true); + try { + await invoke("git_stash_pop", { path: rootPath, index }); + await refreshGit(); + flash(t('git.status.stash_pop_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleStashApply = async (index: number) => { + if (!rootPath) return; + setGitLoading(true); + try { + await invoke("git_stash_apply", { path: rootPath, index }); + await refreshGit(); + flash(t('git.status.stash_pop_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleStashDrop = async (entry: GitStashEntry) => { + if (!rootPath) return; + if (!(await ask(t('git.stash.drop_confirm', { ref: entry.ref_name }), { title: 'Trixty IDE', kind: 'warning' }))) return; + setGitLoading(true); + try { + await invoke("git_stash_drop", { path: rootPath, index: entry.index }); + await refreshGit(); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleReset = async (mode: "soft" | "mixed" | "hard", target: string) => { + if (!rootPath) return; + if (mode === "hard" && !(await ask(t('git.reset.confirm_hard', { target }), { title: 'Trixty IDE', kind: 'warning' }))) return; + if (mode === "mixed" && !(await ask(t('git.reset.confirm_mixed', { target }), { title: 'Trixty IDE', kind: 'warning' }))) return; + setGitLoading(true); + try { + await invoke("git_reset", { path: rootPath, mode, target }); + await refreshGit(); + flash(t('git.status.reset_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; + const handleRevert = async (commit: string, shortHash: string) => { + if (!rootPath) return; + if (!(await ask(t('git.revert.confirm', { hash: shortHash }), { title: 'Trixty IDE', kind: 'warning' }))) return; + setGitLoading(true); + try { + await invoke("git_revert", { path: rootPath, commit }); + await refreshGit(); + flash(t('git.status.revert_success')); + } catch (e) { flash(t('git.error', { message: String(e) })); } + finally { setGitLoading(false); } + }; const handleStage = async (file: string) => { if (!rootPath) return; try { await invoke("git_add", { path: rootPath, files: [file] }); await refreshGit(); } catch (e) { flash(t('git.error', { message: String(e) })); } }; const handleUnstage = async (file: string) => { if (!rootPath) return; try { await invoke("git_unstage", { path: rootPath, files: [file] }); await refreshGit(); } catch (e) { flash(t('git.error', { message: String(e) })); } }; const handleStageAll = async () => { if (!rootPath) return; try { await invoke("git_add", { path: rootPath, files: ["."] }); await refreshGit(); flash(t('git.status.all_staged')); } catch (e) { flash(t('git.error', { message: String(e) })); } }; - const flash = (msg: string) => { setGitFeedback(msg); setTimeout(() => setGitFeedback(""), 3000); }; + const flash = (msg: string) => { + setGitFeedback(msg); + if (flashTimeoutRef.current) clearTimeout(flashTimeoutRef.current); + flashTimeoutRef.current = setTimeout(() => setGitFeedback(""), 3000); + }; const handleAiSuggest = async () => { if (!rootPath) return; @@ -538,9 +696,14 @@ const GitExplorerComponent: React.FC = () => { // ============ GIT ============ if (activeSidebarTab === "git") { return ( -
+
+
+ + + + +
) : undefined} /> {!rootPath ? } /> : !isGitRepo ? ( @@ -556,49 +719,69 @@ const GitExplorerComponent: React.FC = () => {
) : (
- {gitFeedback &&
{gitFeedback}
} + {gitFeedback && ( +
+ {gitFeedback} +
+ )} + {hasConflicts && ( +
+ + {t('git.conflicts.banner')} +
+ )} {/* Branch */}
{showBranchMenu && (
- {branches.map((b) => { - const isCurrent = b === currentBranch; - return ( - - ); - })} + {branches.length > 6 && ( +
+ setBranchFilter(e.target.value)} + placeholder={t('git.filter.branches')} + className="w-full bg-[#0e0e0e] border border-[#222] rounded-md px-2 py-1 text-[11px] text-white placeholder-[#444] focus:outline-none focus:border-[#444]" /> +
+ )} +
+ {branches.filter((b) => !branchFilter || b.toLowerCase().includes(branchFilter.toLowerCase())).map((b) => { + const isCurrent = b === currentBranch; + return ( +
+ + {!isCurrent && ( + + )} +
+ ); + })} +
- setNewBranchName(e.target.value)} + setNewBranchName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleCreateBranch(); }} placeholder={t('git.new_branch')} - className="flex-1 bg-[#0e0e0e] border border-[#222] rounded-md px-2 py-1 text-[11px] text-white placeholder-[#444] focus:outline-none focus:border-[#444]" - /> - + className="flex-1 bg-[#0e0e0e] border border-[#222] rounded-md px-2 py-1 text-[11px] text-white placeholder-[#444] focus:outline-none focus:border-[#444]" /> +
)} @@ -607,13 +790,36 @@ const GitExplorerComponent: React.FC = () => { {/* Commit */}