From d7c24066490a6d7d8b4ef1af930a3c8838c796a3 Mon Sep 17 00:00:00 2001 From: Gasimov Kamran Date: Fri, 16 Jan 2026 17:30:53 +0900 Subject: [PATCH 1/2] Revamp git interface with advanced stashing and layout improvements - Implement comprehensive stashing system with dedicated panel and granular file control - Split changes view into 'Staged Changes' and 'Changes' sections matching VS Code style - Add dynamic panel resizing and reorder to Status -> History -> Stashes - Fix external git operation consistency by broadcasting file watcher events globally - Add 'Unstage All' and 'Stash All Unstaged' quick actions --- src-tauri/src/commands/vcs/git/stash.rs | 13 +- .../controllers/file-watcher-store.ts | 6 + .../git/components/actions-menu.tsx | 23 -- .../git/components/commit-history.tsx | 16 +- .../git/components/file-item.tsx | 16 +- .../git/components/stash-message-modal.tsx | 96 +++++++ .../git/components/stash-panel.tsx | 166 ++++++++++++ .../git/components/status-panel.tsx | 242 ++++++++++++++---- .../version-control/git/components/view.tsx | 36 ++- .../version-control/git/controllers/git.ts | 2 + .../version-control/git/controllers/store.ts | 31 ++- 11 files changed, 537 insertions(+), 110 deletions(-) create mode 100644 src/features/version-control/git/components/stash-message-modal.tsx create mode 100644 src/features/version-control/git/components/stash-panel.tsx diff --git a/src-tauri/src/commands/vcs/git/stash.rs b/src-tauri/src/commands/vcs/git/stash.rs index b5843cc3c..1990c56a5 100644 --- a/src-tauri/src/commands/vcs/git/stash.rs +++ b/src-tauri/src/commands/vcs/git/stash.rs @@ -49,14 +49,16 @@ pub fn git_create_stash( repo_path: String, message: Option, include_untracked: bool, + files: Option>, ) -> Result<(), String> { - _git_create_stash(repo_path, message, include_untracked).into_string_error() + _git_create_stash(repo_path, message, include_untracked, files).into_string_error() } fn _git_create_stash( repo_path: String, message: Option, include_untracked: bool, + files: Option>, ) -> Result<()> { let repo_dir = Path::new(&repo_path); let mut args = vec!["stash", "push"]; @@ -68,6 +70,15 @@ fn _git_create_stash( args.push(msg); } + if let Some(ref file_list) = files { + if !file_list.is_empty() { + args.push("--"); + for file in file_list { + args.push(file); + } + } + } + let output = Command::new("git") .current_dir(repo_dir) .args(&args) diff --git a/src/features/file-system/controllers/file-watcher-store.ts b/src/features/file-system/controllers/file-watcher-store.ts index ee004fa39..bb3f0181d 100644 --- a/src/features/file-system/controllers/file-watcher-store.ts +++ b/src/features/file-system/controllers/file-watcher-store.ts @@ -144,6 +144,12 @@ export async function initializeFileWatcherListener() { const { path, event_type } = event.payload; const parentDir = await dirname(path); + window.dispatchEvent( + new CustomEvent("file-external-change", { + detail: { path, event_type }, + }), + ); + // Handle deleted files - refresh parent directory if (event_type === "deleted") { scheduleDirectoryRefresh(parentDir); diff --git a/src/features/version-control/git/components/actions-menu.tsx b/src/features/version-control/git/components/actions-menu.tsx index d0e5eec58..f6b1d3a23 100644 --- a/src/features/version-control/git/components/actions-menu.tsx +++ b/src/features/version-control/git/components/actions-menu.tsx @@ -1,5 +1,4 @@ import { - Archive, Download, GitPullRequest, RefreshCw, @@ -27,7 +26,6 @@ interface GitActionsMenuProps { hasGitRepo: boolean; repoPath?: string; onRefresh?: () => void; - onOpenStashManager?: () => void; onOpenRemoteManager?: () => void; onOpenTagManager?: () => void; } @@ -39,7 +37,6 @@ const GitActionsMenu = ({ hasGitRepo, repoPath, onRefresh, - onOpenStashManager, onOpenRemoteManager, onOpenTagManager, }: GitActionsMenuProps) => { @@ -92,11 +89,6 @@ const GitActionsMenu = ({ await onRefresh?.(); }; - const handleStashManager = () => { - onOpenStashManager?.(); - onClose(); - }; - const handleRemoteManager = () => { onOpenRemoteManager?.(); onClose(); @@ -179,21 +171,6 @@ const GitActionsMenu = ({
{/* Advanced Operations */} - - + {file.status !== "untracked" && ( + + + + , + document.body, + ); +}; diff --git a/src/features/version-control/git/components/stash-panel.tsx b/src/features/version-control/git/components/stash-panel.tsx new file mode 100644 index 000000000..92fe13008 --- /dev/null +++ b/src/features/version-control/git/components/stash-panel.tsx @@ -0,0 +1,166 @@ +import { Archive, ChevronDown, ChevronRight, Clock, Download, Trash2, Upload } from "lucide-react"; +import { useState } from "react"; +import { cn } from "@/utils/cn"; +import { applyStash, dropStash, popStash } from "../controllers/git"; +import { useGitStore } from "../controllers/store"; + +interface GitStashPanelProps { + repoPath?: string; + onRefresh?: () => void; +} + +const GitStashPanel = ({ repoPath, onRefresh }: GitStashPanelProps) => { + const { stashes } = useGitStore(); + const [isCollapsed, setIsCollapsed] = useState(true); + const [actionLoading, setActionLoading] = useState>(new Set()); + + const handleStashAction = async ( + action: () => Promise, + stashIndex: number, + actionName: string, + ) => { + if (!repoPath) return; + + setActionLoading((prev) => new Set(prev).add(stashIndex)); + try { + const success = await action(); + if (success) { + onRefresh?.(); + } else { + console.error(`${actionName} failed`); + } + } catch (error) { + console.error(`${actionName} error:`, error); + } finally { + setActionLoading((prev) => { + const newSet = new Set(prev); + newSet.delete(stashIndex); + return newSet; + }); + } + }; + + const handleApplyStash = (stashIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + handleStashAction(() => applyStash(repoPath!, stashIndex), stashIndex, "Apply stash"); + }; + + const handlePopStash = (stashIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + handleStashAction(() => popStash(repoPath!, stashIndex), stashIndex, "Pop stash"); + }; + + const handleDropStash = (stashIndex: number, e: React.MouseEvent) => { + e.stopPropagation(); + handleStashAction(() => dropStash(repoPath!, stashIndex), stashIndex, "Drop stash"); + }; + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + if (diffHours === 0) { + const diffMins = Math.floor(diffMs / (1000 * 60)); + return diffMins <= 1 ? "just now" : `${diffMins}m ago`; + } + return `${diffHours}h ago`; + } else if (diffDays === 1) { + return "yesterday"; + } else if (diffDays < 7) { + return `${diffDays}d ago`; + } else { + return date.toLocaleDateString(); + } + } catch { + return dateString; + } + }; + + return ( +
+
setIsCollapsed(!isCollapsed)} + > +
+ {isCollapsed ? : } + + stashes ({stashes.length}) +
+
+ + {!isCollapsed && ( +
+ {stashes.length === 0 ? ( +
No stashes found
+ ) : ( +
+ {stashes.map((stash) => ( +
+
+
+
+ + stash@{{stash.index}} + + + + {formatDate(stash.date)} + +
+
+ {stash.message || "Stashed changes"} +
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+ )} +
+ ); +}; + +export default GitStashPanel; diff --git a/src/features/version-control/git/components/status-panel.tsx b/src/features/version-control/git/components/status-panel.tsx index 9a68d8aad..5136833e9 100644 --- a/src/features/version-control/git/components/status-panel.tsx +++ b/src/features/version-control/git/components/status-panel.tsx @@ -1,15 +1,18 @@ -import { Check, FileText, Plus } from "lucide-react"; +import { Archive, Check, ChevronDown, ChevronRight, FileText, Minus, Plus } from "lucide-react"; import type React from "react"; import { type RefObject, useMemo, useRef, useState } from "react"; import { useOnClickOutside } from "usehooks-ts"; import { + createStash, discardFileChanges, stageAllFiles, stageFile, + unstageAllFiles, unstageFile, } from "@/features/version-control/git/controllers/git"; import type { GitFile } from "../types/git"; import { GitFileItem } from "./file-item"; +import { StashMessageModal } from "./stash-message-modal"; interface GitStatusPanelProps { files: GitFile[]; @@ -30,7 +33,7 @@ type StatusGroup = "added" | "modified" | "deleted" | "renamed" | "untracked"; const STATUS_ORDER: StatusGroup[] = ["added", "modified", "deleted", "renamed", "untracked"]; -const STATUS_LABELS: Record = { +const _STATUS_LABELS: Record = { added: "Added", modified: "Modified", deleted: "Deleted", @@ -48,12 +51,24 @@ const GitStatusPanel = ({ const contextMenuRef = useRef(null); const [isLoading, setIsLoading] = useState(false); const [contextMenu, setContextMenu] = useState(null); + const [isStagedCollapsed, setIsStagedCollapsed] = useState(false); + const [isChangesCollapsed, setIsChangesCollapsed] = useState(false); - const stagedCount = files.filter((f) => f.staged).length; - const unstagedCount = files.filter((f) => !f.staged).length; + // Stash modal state + const [stashModal, setStashModal] = useState<{ + isOpen: boolean; + type: "file" | "all"; + filePath?: string; + }>({ + isOpen: false, + type: "file", + }); + + const stagedFiles = useMemo(() => files.filter((f) => f.staged), [files]); + const unstagedFiles = useMemo(() => files.filter((f) => !f.staged), [files]); // Group files by status - const groupedFiles = useMemo(() => { + const groupFiles = (files: GitFile[]) => { const groups: Record = { added: [], modified: [], @@ -69,7 +84,10 @@ const GitStatusPanel = ({ } return groups; - }, [files]); + }; + + const groupedStagedFiles = useMemo(() => groupFiles(stagedFiles), [stagedFiles]); + const groupedUnstagedFiles = useMemo(() => groupFiles(unstagedFiles), [unstagedFiles]); const handleStageFile = async (filePath: string) => { if (!repoPath) return; @@ -104,6 +122,17 @@ const GitStatusPanel = ({ } }; + const handleUnstageAll = async () => { + if (!repoPath) return; + setIsLoading(true); + try { + await unstageAllFiles(repoPath); + onRefresh?.(); + } finally { + setIsLoading(false); + } + }; + const handleDiscardFile = async (filePath: string) => { if (!repoPath) return; setIsLoading(true); @@ -115,6 +144,38 @@ const GitStatusPanel = ({ } }; + const handleStashFile = async (filePath: string) => { + setStashModal({ + isOpen: true, + type: "file", + filePath, + }); + }; + + const handleStashAllUnstaged = async () => { + setStashModal({ + isOpen: true, + type: "all", + }); + }; + + const handleConfirmStash = async (message: string) => { + if (!repoPath) return; + + if (stashModal.type === "file" && stashModal.filePath) { + await createStash(repoPath, message || `Stash ${stashModal.filePath}`, false, [ + stashModal.filePath, + ]); + } else if (stashModal.type === "all") { + const paths = unstagedFiles.map((f) => f.path); + if (paths.length === 0) return; + + await createStash(repoPath, message || "Stash all unstaged changes", false, paths); + } + + onRefresh?.(); + }; + const handleContextMenu = (e: React.MouseEvent, filePath: string, isStaged: boolean) => { e.preventDefault(); e.stopPropagation(); @@ -130,65 +191,122 @@ const GitStatusPanel = ({ setContextMenu(null); }); - const hasChanges = files.length > 0; + const renderFileList = (groupedFiles: Record) => { + return STATUS_ORDER.map((status) => { + const statusFiles = groupedFiles[status]; + if (statusFiles.length === 0) return null; + + return ( +
+ {statusFiles.map((file, index) => ( + onFileSelect?.(file.path, file.staged)} + onContextMenu={(e) => handleContextMenu(e, file.path, file.staged)} + onStage={() => handleStageFile(file.path)} + onUnstage={() => handleUnstageFile(file.path)} + onDiscard={() => handleDiscardFile(file.path)} + onStash={() => handleStashFile(file.path)} + disabled={isLoading} + /> + ))} +
+ ); + }); + }; return (
- {/* Changes Section Header */} -
-
- - changes ({files.length}) - {stagedCount > 0 && {stagedCount} staged} - -
- {unstagedCount > 0 && ( - + {stagedFiles.length > 0 && ( +
+
setIsStagedCollapsed(!isStagedCollapsed)} + > + {isStagedCollapsed ? : } + Staged Changes +
+
+ + {stagedFiles.length} + + +
+
+ + {!isStagedCollapsed && ( +
{renderFileList(groupedStagedFiles)}
)}
+ )} - {!hasChanges ? ( -
- - No changes +
+
setIsChangesCollapsed(!isChangesCollapsed)} + > + {isChangesCollapsed ? : } + Changes +
+
+ {unstagedFiles.length > 0 && ( + + {unstagedFiles.length} + + )} + {unstagedFiles.length > 0 && ( + <> + + + + )}
- ) : ( +
+ + {!isChangesCollapsed && (
- {STATUS_ORDER.map((status) => { - const statusFiles = groupedFiles[status]; - if (statusFiles.length === 0) return null; - - return ( -
- {/* Status Group Header */} -
- {STATUS_LABELS[status]} ({statusFiles.length}) + {unstagedFiles.length === 0 + ? stagedFiles.length === 0 && ( +
+ + No changes
- - {/* Files in this group */} - {statusFiles.map((file, index) => ( - onFileSelect?.(file.path, file.staged)} - onContextMenu={(e) => handleContextMenu(e, file.path, file.staged)} - onStage={() => handleStageFile(file.path)} - onUnstage={() => handleUnstageFile(file.path)} - onDiscard={() => handleDiscardFile(file.path)} - disabled={isLoading} - /> - ))} -
- ); - })} + ) + : renderFileList(groupedUnstagedFiles)}
)}
@@ -216,6 +334,18 @@ const GitStatusPanel = ({
)} + + setStashModal((prev) => ({ ...prev, isOpen: false }))} + onConfirm={handleConfirmStash} + title={stashModal.type === "file" ? "Stash File" : "Stash All Unstaged"} + placeholder={ + stashModal.type === "file" + ? `Message (default: Stash ${stashModal.filePath?.split("/").pop()})` + : "Message (default: Stash all unstaged changes)" + } + />
); }; diff --git a/src/features/version-control/git/components/view.tsx b/src/features/version-control/git/components/view.tsx index f2b54220c..0b9ce9a36 100644 --- a/src/features/version-control/git/components/view.tsx +++ b/src/features/version-control/git/components/view.tsx @@ -7,6 +7,7 @@ import { getFileDiff, getGitLog, getGitStatus, + getStashes, } from "@/features/version-control/git/controllers/git"; import { useGitStore } from "@/features/version-control/git/controllers/store"; import { cn } from "@/utils/cn"; @@ -18,7 +19,7 @@ import GitBranchManager from "./branch-manager"; import GitCommitHistory from "./commit-history"; import GitCommitPanel from "./commit-panel"; import GitRemoteManager from "./remote-manager"; -import GitStashManager from "./stash-manager"; +import GitStashPanel from "./stash-panel"; import GitStatusPanel from "./status-panel"; import GitTagManager from "./tag-manager"; @@ -39,7 +40,6 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { } | null>(null); // Modal states - const [showStashManager, setShowStashManager] = useState(false); const [showRemoteManager, setShowRemoteManager] = useState(false); const [showTagManager, setShowTagManager] = useState(false); @@ -52,16 +52,18 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { setIsLoadingGitData(true); try { - const [status, commits, branches] = await Promise.all([ + const [status, commits, branches, stashes] = await Promise.all([ getGitStatus(repoPath), getGitLog(repoPath, 50, 0), getBranches(repoPath), + getStashes(repoPath), ]); actions.loadFreshGitData({ gitStatus: status, commits, branches, + stashes, repoPath, }); } catch (error) { @@ -472,16 +474,20 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
{/* Content */} -
- +
+
+ +
+ +
{/* Commit Panel */} @@ -503,18 +509,10 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { hasGitRepo={!!gitStatus} repoPath={repoPath} onRefresh={handleManualRefresh} - onOpenStashManager={() => setShowStashManager(true)} onOpenRemoteManager={() => setShowRemoteManager(true)} onOpenTagManager={() => setShowTagManager(true)} /> - setShowStashManager(false)} - repoPath={repoPath} - onRefresh={handleManualRefresh} - /> - setShowRemoteManager(false)} diff --git a/src/features/version-control/git/controllers/git.ts b/src/features/version-control/git/controllers/git.ts index 3721ba4e5..c073232da 100644 --- a/src/features/version-control/git/controllers/git.ts +++ b/src/features/version-control/git/controllers/git.ts @@ -344,12 +344,14 @@ export const createStash = async ( repoPath: string, message?: string, includeUntracked: boolean = false, + files?: string[], ): Promise => { try { await tauriInvoke("git_create_stash", { repoPath, message, includeUntracked, + files, }); return true; } catch (error) { diff --git a/src/features/version-control/git/controllers/store.ts b/src/features/version-control/git/controllers/store.ts index 1222066ad..449f87508 100644 --- a/src/features/version-control/git/controllers/store.ts +++ b/src/features/version-control/git/controllers/store.ts @@ -1,12 +1,13 @@ import { create } from "zustand"; import { combine } from "zustand/middleware"; -import type { GitCommit, GitStatus } from "../types/git"; -import { getGitLog } from "./git"; +import type { GitCommit, GitStash, GitStatus } from "../types/git"; +import { getGitLog, getStashes } from "./git"; interface GitState { // Data gitStatus: GitStatus | null; commits: GitCommit[]; + stashes: GitStash[]; branches: string[]; // Loading states @@ -25,6 +26,7 @@ interface GitState { const initialState: GitState = { gitStatus: null, commits: [], + stashes: [], branches: [], isLoadingGitData: false, isRefreshing: false, @@ -47,12 +49,14 @@ export const useGitStore = create( updateGitData: (data: { gitStatus: GitStatus | null; commits: GitCommit[]; + stashes: GitStash[]; branches: string[]; }) => { const state = get(); set({ gitStatus: data.gitStatus, commits: data.commits, + stashes: data.stashes, branches: data.branches, hasMoreCommits: data.commits.length >= state.commitPageSize, }); @@ -61,6 +65,7 @@ export const useGitStore = create( loadFreshGitData: (data: { gitStatus: GitStatus | null; commits: GitCommit[]; + stashes: GitStash[]; branches: string[]; repoPath?: string; }) => { @@ -68,6 +73,7 @@ export const useGitStore = create( set({ gitStatus: data.gitStatus, commits: data.commits, + stashes: data.stashes, branches: data.branches, hasMoreCommits: data.commits.length >= state.commitPageSize, isLoadingMoreCommits: false, @@ -82,12 +88,22 @@ export const useGitStore = create( repoPath: string; }) => { const state = get(); + const currentRepoPath = state.currentRepoPath; + const repoChanged = currentRepoPath !== data.repoPath; - // If repo path changed, reset everything - if (state.currentRepoPath !== data.repoPath) { + let stashes: GitStash[] = []; + try { + stashes = await getStashes(data.repoPath); + } catch (e) { + console.error("Failed to fetch stashes during refresh", e); + stashes = []; + } + + if (repoChanged) { set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, commits: [], hasMoreCommits: true, currentRepoPath: data.repoPath, @@ -108,6 +124,7 @@ export const useGitStore = create( set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, commits: [], hasMoreCommits: false, }); @@ -122,6 +139,7 @@ export const useGitStore = create( set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, commits: initialCommits, hasMoreCommits: initialCommits.length >= state.commitPageSize, }); @@ -138,6 +156,7 @@ export const useGitStore = create( set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, commits: updatedCommits, }); // Dispatch event for git gutter updates @@ -149,6 +168,7 @@ export const useGitStore = create( set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, }); // Dispatch event for git gutter updates window.dispatchEvent( @@ -157,10 +177,10 @@ export const useGitStore = create( } } catch (error) { console.error("Failed to refresh git data:", error); - // Fall back to just updating status and branches set({ gitStatus: data.gitStatus, branches: data.branches, + stashes: stashes, }); // Dispatch event for git gutter updates window.dispatchEvent( @@ -175,6 +195,7 @@ export const useGitStore = create( set({ gitStatus: null, commits: [], + stashes: [], branches: [], currentRepoPath: null, hasMoreCommits: true, From 67846b92300682a85f7bc1de59fe384e50eb9bb9 Mon Sep 17 00:00:00 2001 From: Mehmet Ozgul <91568457+mehmetozguldev@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:56:15 +0300 Subject: [PATCH 2/2] Fix sidebar toggle performance by using CSS visibility Switch from conditional DOM rendering to CSS-based visibility for the sidebar. This keeps the sidebar mounted in the DOM but hides it with CSS when not visible, eliminating remounting overhead and preserving component state (search view, git view, file tree expanded state) when rapidly toggling with Cmd+B. Co-Authored-By: Claude Opus 4.5 --- .../layout/components/main-layout.tsx | 34 +++++++++++-------- .../components/sidebar/main-sidebar.tsx | 4 --- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/features/layout/components/main-layout.tsx b/src/features/layout/components/main-layout.tsx index 292e3c4a8..74949e97d 100644 --- a/src/features/layout/components/main-layout.tsx +++ b/src/features/layout/components/main-layout.tsx @@ -252,10 +252,12 @@ export function MainLayout() { ) - : isSidebarVisible && ( - - - + : sidebarPosition === "left" && ( +
+ + + +
)} {/* Main content area */} @@ -310,17 +312,19 @@ export function MainLayout() {
{/* Right sidebar or AI chat based on settings */} - {sidebarPosition === "right" - ? isSidebarVisible && ( - - - - ) - : settings.isAIChatVisible && ( - - - - )} + {sidebarPosition === "right" ? ( +
+ + + +
+ ) : ( + settings.isAIChatVisible && ( + + + + ) + )}
diff --git a/src/features/layout/components/sidebar/main-sidebar.tsx b/src/features/layout/components/sidebar/main-sidebar.tsx index d76269622..e86363305 100644 --- a/src/features/layout/components/sidebar/main-sidebar.tsx +++ b/src/features/layout/components/sidebar/main-sidebar.tsx @@ -67,10 +67,6 @@ export const MainSidebar = memo(() => { if (searchViewRef.current) { setSearchViewRef(searchViewRef.current); } - - return () => { - setSearchViewRef(null); - }; }, [setSearchViewRef]); // Additional effect to ensure ref is registered when search becomes active