11import type { Extension } from "@codemirror/state" ;
22import { Compartment } from "@codemirror/state" ;
33import { debounce } from "@yaakapp-internal/lib" ;
4+ import { gitMutations } from "@yaakapp-internal/git" ;
5+ import type { GitStatus } from "@yaakapp-internal/git" ;
46import type {
57 AnyModel ,
68 Folder ,
@@ -23,13 +25,18 @@ import {
2325} from "@yaakapp-internal/models" ;
2426import classNames from "classnames" ;
2527import { atom , useAtomValue } from "jotai" ;
26- import { atomFamily , selectAtom } from "jotai/utils" ;
28+ import { atomFamily } from "jotai-family" ;
29+ import { selectAtom } from "jotai/utils" ;
2730import { memo , useCallback , useEffect , useMemo , useRef } from "react" ;
2831import { moveToWorkspace } from "../commands/moveToWorkspace" ;
2932import { openFolderSettings } from "../commands/openFolderSettings" ;
3033import { activeFolderIdAtom } from "../hooks/useActiveFolderId" ;
3134import { activeRequestIdAtom } from "../hooks/useActiveRequestId" ;
32- import { activeWorkspaceAtom , activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace" ;
35+ import {
36+ activeWorkspaceAtom ,
37+ activeWorkspaceIdAtom ,
38+ activeWorkspaceMetaAtom ,
39+ } from "../hooks/useActiveWorkspace" ;
3340import { allRequestsAtom } from "../hooks/useAllRequests" ;
3441import { getCreateDropdownItems } from "../hooks/useCreateDropdownItems" ;
3542import { getFolderActions } from "../hooks/useFolderActions" ;
@@ -42,7 +49,13 @@ import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
4249import { useSidebarHidden } from "../hooks/useSidebarHidden" ;
4350import { getWebsocketRequestActions } from "../hooks/useWebsocketRequestActions" ;
4451import { deepEqualAtom } from "../lib/atoms" ;
52+ import { showConfirm } from "../lib/confirm" ;
4553import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm" ;
54+ import { showDialog } from "../lib/dialog" ;
55+ import {
56+ gitWorktreeStatusByModelIdAtom ,
57+ gitWorktreeStatusFamily ,
58+ } from "../lib/gitWorktreeStatus" ;
4659import { jotaiStore } from "../lib/jotai" ;
4760import { resolvedModelName } from "../lib/resolvedModelName" ;
4861import { isSidebarFocused } from "../lib/scopes" ;
@@ -68,6 +81,9 @@ import type { InputHandle } from "./core/Input";
6881import { Input } from "./core/Input" ;
6982import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage" ;
7083import { GitDropdown } from "./git/GitDropdown" ;
84+ import { gitCallbacks } from "./git/callbacks" ;
85+ import { FileHistoryDialog } from "./git/FileHistoryDialog" ;
86+ import { sync } from "../init/sync" ;
7187
7288const collapsedFamily = atomFamily ( ( treeId : string ) => {
7389 const key = [ "sidebar_collapsed" , treeId ?? "n/a" ] ;
@@ -375,6 +391,8 @@ function Sidebar({ className }: { className?: string }) {
375391 }
376392
377393 const workspaces = jotaiStore . get ( workspacesAtom ) ;
394+ const syncDir = jotaiStore . get ( activeWorkspaceMetaAtom ) ?. settingSyncDir ;
395+ const gitItems = getGitContextMenuItems ( { items, syncDir } ) ;
378396 const onlyHttpRequests = items . every ( ( i ) => i . model === "http_request" ) ;
379397 const requestItems = items . filter (
380398 ( i ) =>
@@ -458,8 +476,10 @@ function Sidebar({ className }: { className?: string }) {
458476 ...initialItems ,
459477 {
460478 type : "separator" ,
461- hidden : initialItems . filter ( ( v ) => ! v . hidden ) . length === 0 ,
479+ hidden : initialItems . filter ( ( v ) => ! v . hidden ) . length === 0 || gitItems . length === 0 ,
462480 } ,
481+ ...gitItems ,
482+ { type : "separator" , hidden : gitItems . length === 0 } ,
463483 {
464484 label : "Rename" ,
465485 leftSlot : < Icon icon = "pencil" /> ,
@@ -661,6 +681,73 @@ function Sidebar({ className }: { className?: string }) {
661681
662682export default Sidebar ;
663683
684+ function getGitContextMenuItems ( {
685+ items,
686+ syncDir,
687+ } : {
688+ items : SidebarModel [ ] ;
689+ syncDir : string | null | undefined ;
690+ } ) : DropdownItem [ ] {
691+ if ( syncDir == null ) return [ ] ;
692+
693+ const gitStatusEntries = items . flatMap ( ( item ) => {
694+ const status = jotaiStore . get ( gitWorktreeStatusFamily ( item . id ) ) ;
695+ return status == null || status . status === "current" ? [ ] : [ status ] ;
696+ } ) ;
697+ const historyItem = items . length === 1 ? items [ 0 ] : null ;
698+ const historyPath =
699+ historyItem == null
700+ ? null
701+ : ( jotaiStore . get ( gitWorktreeStatusFamily ( historyItem . id ) ) ?. relaPath ??
702+ syncPathForModel ( historyItem ) ) ;
703+
704+ return [
705+ {
706+ label : "View History" ,
707+ leftSlot : < Icon icon = "history" /> ,
708+ hidden : historyPath == null ,
709+ onSelect : ( ) => {
710+ if ( historyPath == null ) return ;
711+ showDialog ( {
712+ id : "git-history" ,
713+ size : "lg" ,
714+ title : "File History" ,
715+ noPadding : true ,
716+ noScroll : true ,
717+ render : ( ) => < FileHistoryDialog dir = { syncDir } relaPath = { historyPath } /> ,
718+ } ) ;
719+ } ,
720+ } ,
721+ {
722+ label : "Restore Changes" ,
723+ leftSlot : < Icon icon = "rotate_ccw" /> ,
724+ hidden : gitStatusEntries . length === 0 ,
725+ async onSelect ( ) {
726+ const confirmed = await showConfirm ( {
727+ id : "git-restore-sidebar-items" ,
728+ title : "Restore Changes" ,
729+ description :
730+ gitStatusEntries . length === 1
731+ ? "This will discard uncommitted changes for the selected item."
732+ : `This will discard uncommitted changes for ${ gitStatusEntries . length } selected items.` ,
733+ confirmText : "Restore" ,
734+ color : "danger" ,
735+ } ) ;
736+ if ( ! confirmed ) return ;
737+
738+ await gitMutations ( syncDir , gitCallbacks ( syncDir ) ) . restore . mutateAsync ( {
739+ relaPaths : gitStatusEntries . map ( ( entry ) => entry . relaPath ) ,
740+ } ) ;
741+ await sync ( { force : true } ) ;
742+ } ,
743+ } ,
744+ ] ;
745+ }
746+
747+ function syncPathForModel ( item : SidebarModel ) {
748+ return `yaak.${ item . id } .yaml` ;
749+ }
750+
664751const activeIdAtom = atom < string | null > ( ( get ) => {
665752 return get ( activeRequestIdAtom ) || get ( activeFolderIdAtom ) ;
666753} ) ;
@@ -790,6 +877,64 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
790877 return [ root , fields ] as const ;
791878} ) ;
792879
880+ const sidebarGitStatusByModelIdAtom = atom < Record < string , GitStatus > > ( ( get ) => {
881+ const allModels = get ( memoAllPotentialChildrenAtom ) ;
882+ const activeWorkspace = get ( activeWorkspaceAtom ) ;
883+ const gitStatusByModelId = get ( gitWorktreeStatusByModelIdAtom ) ;
884+ const childrenMap : Record < string , Exclude < SidebarModel , Workspace > [ ] > = { } ;
885+ const statusByModelId : Record < string , GitStatus > = { } ;
886+
887+ for ( const item of allModels ) {
888+ if ( "folderId" in item && item . folderId == null ) {
889+ childrenMap [ item . workspaceId ] = childrenMap [ item . workspaceId ] ?? [ ] ;
890+ childrenMap [ item . workspaceId ] ?. push ( item ) ;
891+ } else if ( "folderId" in item && item . folderId != null ) {
892+ childrenMap [ item . folderId ] = childrenMap [ item . folderId ] ?? [ ] ;
893+ childrenMap [ item . folderId ] ?. push ( item ) ;
894+ }
895+ }
896+
897+ const visit = ( item : SidebarModel ) : GitStatus | null => {
898+ const statuses : GitStatus [ ] = [ ] ;
899+ const directStatus = gitStatusByModelId [ item . id ] ?. status ;
900+ if ( directStatus != null && directStatus !== "current" ) {
901+ statuses . push ( directStatus ) ;
902+ }
903+
904+ for ( const child of childrenMap [ item . id ] ?? [ ] ) {
905+ const childStatus = visit ( child ) ;
906+ if ( childStatus != null ) statuses . push ( childStatus ) ;
907+ }
908+
909+ const status = summarizeGitStatuses ( statuses ) ;
910+ if ( status != null ) {
911+ statusByModelId [ item . id ] = status ;
912+ }
913+ return status ;
914+ } ;
915+
916+ if ( activeWorkspace != null ) {
917+ visit ( activeWorkspace ) ;
918+ }
919+
920+ return statusByModelId ;
921+ } ) ;
922+
923+ const sidebarGitStatusFamily = atomFamily (
924+ ( modelId : string ) =>
925+ selectAtom ( sidebarGitStatusByModelIdAtom , ( statusByModelId ) => statusByModelId [ modelId ] ?? null ) ,
926+ Object . is ,
927+ ) ;
928+
929+ function summarizeGitStatuses ( statuses : GitStatus [ ] ) : GitStatus | null {
930+ if ( statuses . length === 0 ) return null ;
931+ const firstStatus = statuses [ 0 ] ;
932+ if ( firstStatus != null && statuses . every ( ( status ) => status === firstStatus ) ) {
933+ return firstStatus ;
934+ }
935+ return "modified" ;
936+ }
937+
793938function getItemKey ( item : SidebarModel ) {
794939 const responses = jotaiStore . get ( httpResponsesAtom ) ;
795940 const latestResponse = responses . find ( ( r ) => r . requestId === item . id ) ?? null ;
@@ -836,6 +981,7 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
836981 treeId : string ;
837982 item : SidebarModel ;
838983} ) {
984+ const gitStatus = useAtomValue ( sidebarGitStatusFamily ( item . id ) ) ;
839985 const response = useAtomValue (
840986 useMemo (
841987 ( ) =>
@@ -854,7 +1000,16 @@ const SidebarInnerItem = memo(function SidebarInnerItem({
8541000
8551001 return (
8561002 < div className = "flex items-center gap-2 min-w-0 h-full w-full text-left" >
857- < div className = "truncate" > { resolvedModelName ( item ) } </ div >
1003+ < div
1004+ className = { classNames (
1005+ "truncate" ,
1006+ gitStatus === "modified" && "text-info" ,
1007+ gitStatus === "untracked" && "text-success" ,
1008+ gitStatus === "removed" && "text-danger" ,
1009+ ) }
1010+ >
1011+ { resolvedModelName ( item ) }
1012+ </ div >
8581013 { response != null && (
8591014 < div className = "ml-auto" >
8601015 { response . state !== "closed" ? (
0 commit comments