diff --git a/src-tauri/src/commands/vcs/git/stash.rs b/src-tauri/src/commands/vcs/git/stash.rs index b5843cc3..1990c56a 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 ee004fa3..bb3f0181 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 d0e5eec5..f6b1d3a2 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 00000000..92fe1300 --- /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 9a68d8aa..5136833e 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 f2b54220..0b9ce9a3 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 3721ba4e..c073232d 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 1222066a..449f8750 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,