diff --git a/packages/insomnia/src/models/workspace-meta.ts b/packages/insomnia/src/models/workspace-meta.ts index f9ba50b8a..f6135866b 100644 --- a/packages/insomnia/src/models/workspace-meta.ts +++ b/packages/insomnia/src/models/workspace-meta.ts @@ -19,6 +19,8 @@ export interface BaseWorkspaceMeta { gitRepositoryId: string | null; parentId: string | null; pushSnapshotOnInitialize: boolean; + hasUncommittedChanges: boolean; + hasUnpushedChanges: boolean; } export type WorkspaceMeta = BaseWorkspaceMeta & BaseModel; @@ -40,6 +42,8 @@ export function init(): BaseWorkspaceMeta { gitRepositoryId: null, parentId: null, pushSnapshotOnInitialize: false, + hasUncommittedChanges: false, + hasUnpushedChanges: false, }; } diff --git a/packages/insomnia/src/sync/types.ts b/packages/insomnia/src/sync/types.ts index d82159c45..178b6ae63 100644 --- a/packages/insomnia/src/sync/types.ts +++ b/packages/insomnia/src/sync/types.ts @@ -107,3 +107,8 @@ export interface Status { stage: Stage; unstaged: Record; } + +export interface Compare { + ahead: number; + behind: number; +} diff --git a/packages/insomnia/src/sync/vcs/util.ts b/packages/insomnia/src/sync/vcs/util.ts index 06a8289a5..dd5eb2015 100644 --- a/packages/insomnia/src/sync/vcs/util.ts +++ b/packages/insomnia/src/sync/vcs/util.ts @@ -7,6 +7,7 @@ import { deleteKeys, resetKeys, shouldIgnoreKey } from '../ignore-keys'; import { deterministicStringify } from '../lib/deterministicStringify'; import type { Branch, + Compare, DocumentKey, MergeConflict, Snapshot, @@ -241,10 +242,7 @@ export function threeWayMerge( export function compareBranches( a: Branch | null, b: Branch | null, -): { - ahead: number; - behind: number; -} { +): Compare { const snapshotsA = a ? a.snapshots : []; const snapshotsB = b ? b.snapshots : []; const latestA = snapshotsA[snapshotsA.length - 1] || null; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx index 6f585e7bd..5815861b1 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-sync-dropdown.tsx @@ -9,6 +9,8 @@ import type { GitRepository } from '../../../models/git-repository'; import { deleteGitRepository } from '../../../models/helpers/git-repository-operations'; import { getOauth2FormatName } from '../../../sync/git/utils'; import type { + GitCanPushLoaderData, + GitChangesLoaderData, GitFetchLoaderData, GitRepoLoaderData, GitStatusResult, @@ -56,6 +58,8 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable const gitRepoDataFetcher = useFetcher(); const gitFetchFetcher = useFetcher(); const gitStatusFetcher = useFetcher(); + const gitChangesFetcher = useFetcher(); + const gitCanPushFetcher = useFetcher(); const loadingPush = gitPushFetcher.state === 'loading'; const loadingPull = gitPullFetcher.state === 'loading'; @@ -81,6 +85,18 @@ export const GitSyncDropdown: FC = ({ gitRepository, isInsomniaSyncEnable workspaceId, ]); + useEffect(() => { + if (gitRepository?.uri && gitRepository?._id && gitChangesFetcher.state === 'idle' && !gitChangesFetcher.data && gitRepoDataFetcher.data) { + gitChangesFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/changes`); + } + }, [gitChangesFetcher, gitRepoDataFetcher.data, gitRepository?._id, gitRepository?.uri, organizationId, projectId, workspaceId]); + + useEffect(() => { + if (gitRepository?.uri && gitRepository?._id && gitCanPushFetcher.state === 'idle' && !gitCanPushFetcher.data && gitRepoDataFetcher.data) { + gitCanPushFetcher.load(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/can-push`); + } + }, [gitCanPushFetcher, gitRepoDataFetcher.data, gitRepository?._id, gitRepository?.uri, organizationId, projectId, workspaceId]); + // Only fetch the repo status if we have a repo uri and we don't have the status already const shouldFetchGitRepoStatus = Boolean(gitRepository?.uri && gitRepository?._id && gitStatusFetcher.state === 'idle' && !gitStatusFetcher.data && gitRepoDataFetcher.data); diff --git a/packages/insomnia/src/ui/index.tsx b/packages/insomnia/src/ui/index.tsx index ebc874d9c..0639e52d0 100644 --- a/packages/insomnia/src/ui/index.tsx +++ b/packages/insomnia/src/ui/index.tsx @@ -853,6 +853,11 @@ async function renderApp() { loader: async (...args) => (await import('./routes/git-actions')).gitChangesLoader(...args), }, + { + path: 'can-push', + loader: async (...args) => + (await import('./routes/git-actions')).canPushLoader(...args), + }, { path: 'log', loader: async (...args) => diff --git a/packages/insomnia/src/ui/routes/git-actions.tsx b/packages/insomnia/src/ui/routes/git-actions.tsx index f4fc836af..1ce018520 100644 --- a/packages/insomnia/src/ui/routes/git-actions.tsx +++ b/packages/insomnia/src/ui/routes/git-actions.tsx @@ -302,7 +302,10 @@ export const gitChangesLoader: LoaderFunction = async ({ const branch = await GitVCS.getCurrentBranch(); try { const { changes, statusNames } = await getGitChanges(GitVCS, workspace); - + // update workspace meta with git sync data, use for show uncommit changes on collection card + models.workspaceMeta.updateByParentId(workspaceId, { + hasUncommittedChanges: changes.length > 0, + }); return { branch, changes, @@ -318,6 +321,35 @@ export const gitChangesLoader: LoaderFunction = async ({ } }; +export interface GitCanPushLoaderData { + canPush: boolean; +} + +export const canPushLoader: LoaderFunction = async ({ params }): Promise => { + const { workspaceId } = params; + invariant(workspaceId, 'Workspace ID is required'); + + const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId); + + const repoId = workspaceMeta?.gitRepositoryId; + + invariant(repoId, 'Workspace is not linked to a git repository'); + + const gitRepository = await models.gitRepository.getById(repoId); + + invariant(gitRepository, 'Git Repository not found'); + let canPush = false; + try { + canPush = await GitVCS.canPush(gitRepository.credentials); + // update workspace meta with git sync data, use for show unpushed changes on collection card + models.workspaceMeta.update(workspaceMeta, { + hasUnpushedChanges: canPush, + }); + } catch (err) { } + + return { canPush }; +}; + // Actions type CloneGitActionResult = | Response diff --git a/packages/insomnia/src/ui/routes/project.tsx b/packages/insomnia/src/ui/routes/project.tsx index ae606c88c..57b06fbaf 100644 --- a/packages/insomnia/src/ui/routes/project.tsx +++ b/packages/insomnia/src/ui/routes/project.tsx @@ -263,6 +263,8 @@ export interface InsomniaFile { mockServer?: MockServer; workspace?: Workspace; apiSpec?: ApiSpec; + hasUncommittedChanges?: boolean; + hasUnpushedChanges?: boolean; } export interface ProjectIdLoaderData { @@ -371,9 +373,10 @@ async function getAllLocalFiles({ mockServer, apiSpec, workspace, + hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges, + hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges, }; }); - return files; } @@ -1008,6 +1011,10 @@ const ProjectRoute: FC = () => { } }, [projectId]); + const showUnCommitOrUnpushIndicator = useMemo(() => { + return filesWithPresence.some(file => file?.hasUncommittedChanges || file?.hasUnpushedChanges); + }, [filesWithPresence]); + return ( @@ -1123,6 +1130,7 @@ const ProjectRoute: FC = () => { icon={ isRemoteProject(item) ? 'globe-americas' : 'laptop' } + className={(showUnCommitOrUnpushIndicator && item._id === activeProject?._id) ? 'text-[--color-warning]' : ''} /> {item.name} @@ -1453,6 +1461,14 @@ const ProjectRoute: FC = () => { )} + {(item.hasUncommittedChanges || item.hasUnpushedChanges) && ( +
+
+ + {item.hasUncommittedChanges ? 'Uncommitted changes' : 'Unpushed changes'} + +
+ )}
); diff --git a/packages/insomnia/src/ui/routes/remote-collections.tsx b/packages/insomnia/src/ui/routes/remote-collections.tsx index 8bc6ad49f..6b665df58 100644 --- a/packages/insomnia/src/ui/routes/remote-collections.tsx +++ b/packages/insomnia/src/ui/routes/remote-collections.tsx @@ -19,6 +19,7 @@ import type { WebSocketRequest } from '../../models/websocket-request'; import { scopeToActivity, type Workspace } from '../../models/workspace'; import type { BackendProject, + Compare, Snapshot, Status, StatusCandidate, @@ -250,14 +251,11 @@ interface SyncData { historyCount: number; status: Status; syncItems: StatusCandidate[]; - compare: { - ahead: number; - behind: number; - }; + compare: Compare; } const remoteBranchesCache: Record = {}; -const remoteCompareCache: Record = +const remoteCompareCache: Record = {}; const remoteBackendProjectsCache: Record = {}; @@ -332,17 +330,29 @@ export const syncDataLoader: LoaderFunction = async ({ remoteBranches = ( remoteBranchesCache[workspaceId] || (await vcs.getRemoteBranchNames()) ).sort(); - compare = - remoteCompareCache[workspaceId] || (await vcs.compareRemoteBranch()); - const remoteBackendProjects = - remoteBackendProjectsCache[workspaceId] || - (await vcs.remoteBackendProjects({ - teamId: project.parentId, - teamProjectId: project.remoteId, - })); - remoteBranchesCache[workspaceId] = remoteBranches; - remoteCompareCache[workspaceId] = compare; - remoteBackendProjectsCache[workspaceId] = remoteBackendProjects; + compare = remoteCompareCache[workspaceId] || (await vcs.compareRemoteBranch()); + const remoteBackendProjects = + remoteBackendProjectsCache[workspaceId] || + (await vcs.remoteBackendProjects({ + teamId: project.parentId, + teamProjectId: project.remoteId, + })); + remoteBranchesCache[workspaceId] = remoteBranches; + remoteCompareCache[workspaceId] = compare; + remoteBackendProjectsCache[workspaceId] = remoteBackendProjects; + + let hasUncommittedChanges = false; + if (status?.unstaged && Object.keys(status.unstaged).length > 0) { + hasUncommittedChanges = true; + } + if (status?.stage && Object.keys(status.stage).length > 0) { + hasUncommittedChanges = true; + } + // update workspace meta with sync data, use for show unpushed changes on collection card + models.workspaceMeta.updateByParentId(workspaceId, { + hasUncommittedChanges, + hasUnpushedChanges: compare?.ahead > 0, + }); } catch (e) { } return {