diff --git a/packages/insomnia/src/common/project.ts b/packages/insomnia/src/common/project.ts index dd0649794e..d73d0800ae 100644 --- a/packages/insomnia/src/common/project.ts +++ b/packages/insomnia/src/common/project.ts @@ -1,3 +1,41 @@ +import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; +import { scopeToLabelMap } from '~/common/get-workspace-label'; +import { isNotNullOrUndefined } from '~/common/misc'; +import { descendingNumberSort } from '~/common/sorting'; +import { + type ApiSpec, + database, + type GitRepository, + type MockServer, + models, + type Project, + services, + type Workspace, + type WorkspaceMeta, + type WorkspaceScope, +} from '~/insomnia-data'; +import { VCSInstance } from '~/sync/vcs/insomnia-sync'; + +export interface InsomniaFile { + id: string; + name: string; + remoteId?: string; + scope: WorkspaceScope | 'unsynced'; + label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; + created: number; + lastModifiedTimestamp: number; + branch?: string; + lastCommit?: string; + version?: string; + oasFormat?: string; + mockServer?: MockServer; + workspace?: Workspace; + apiSpec?: ApiSpec; + hasUncommittedChanges?: boolean; + hasUnpushedChanges?: boolean; + gitFilePath?: string | null; +} + const lockGenerator = () => { // Simple mutex lock implementation let isLocked = false; @@ -45,3 +83,223 @@ const lockGenerator = () => { // otherwise they may interfere with each other, which may cause duplicate projects or other inconsistencies. // TODO: move all project operations to this file to ensure they are properly wrapped with locks export const projectLock = lockGenerator(); + +export const checkSingleProjectSyncStatus = async (projectId: string) => { + const projectWorkspaces = await services.workspace.findByParentId(projectId); + const workspaceMetas = await database.find(models.workspaceMeta.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }); + return workspaceMetas.some(item => item.hasUncommittedChanges || item.hasUnpushedChanges); +}; + +export const CheckAllProjectSyncStatus = async (projects: Project[]) => { + const taskList = projects.map(project => checkSingleProjectSyncStatus(project._id)); + const res = await Promise.all(taskList); + const obj: Record = {}; + projects.forEach((project, index) => { + obj[project._id] = res[index]; + }); + return obj; +}; + +export async function getAllLocalFiles({ projectId }: { projectId: string }) { + const projectWorkspaces = await services.workspace.findByParentId(projectId); + const [workspaceMetas, apiSpecs, mockServers] = await Promise.all([ + database.find(models.workspaceMeta.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + database.find(models.apiSpec.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + database.find(models.mockServer.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + ]); + + const gitRepositories = await database.find(models.gitRepository.type, { + parentId: { + $in: workspaceMetas.map(wm => wm.gitRepositoryId).filter(isNotNullOrUndefined), + }, + }); + + const files: InsomniaFile[] = projectWorkspaces.map(workspace => { + const apiSpec = apiSpecs.find(spec => spec.parentId === workspace._id); + const mockServer = mockServers.find(mock => mock.parentId === workspace._id); + let spec: ParsedApiSpec['contents'] = null; + let specFormat: ParsedApiSpec['format'] = null; + let specFormatVersion: ParsedApiSpec['formatVersion'] = null; + if (apiSpec) { + try { + const result = parseApiSpec(apiSpec.contents); + spec = result.contents; + specFormat = result.format; + specFormatVersion = result.formatVersion; + } catch { + // Assume there is no spec + // TODO: Check for parse errors if it's an invalid spec + } + } + const workspaceMeta = workspaceMetas.find(wm => wm.parentId === workspace._id); + const gitRepository = gitRepositories.find(gr => gr._id === workspaceMeta?.gitRepositoryId); + + const lastActiveBranch = gitRepository?.cachedGitRepositoryBranch; + + const lastCommitAuthor = gitRepository?.cachedGitLastAuthor; + + // WorkspaceMeta is a good proxy for last modified time + const workspaceModified = workspaceMeta?.modified || workspace.modified; + + const modifiedLocally = models.workspace.isDesign(workspace) ? apiSpec?.modified || 0 : workspaceModified; + + // Span spec, workspace and sync related timestamps for card last modified label and sort order + const lastModifiedFrom = [ + workspace?.modified, + workspaceMeta?.modified, + modifiedLocally, + gitRepository?.cachedGitLastCommitTime, + ]; + + const lastModifiedTimestamp = lastModifiedFrom.filter(isNotNullOrUndefined).sort(descendingNumberSort)[0]; + + const hasUnsavedChanges = Boolean( + models.workspace.isDesign(workspace) && + gitRepository?.cachedGitLastCommitTime && + modifiedLocally > gitRepository?.cachedGitLastCommitTime, + ); + + const specVersion = spec?.info?.version ? String(spec?.info?.version) : ''; + + return { + id: workspace._id, + name: workspace.name, + scope: workspace.scope, + label: scopeToLabelMap[workspace.scope], + created: workspace.created, + lastModifiedTimestamp: + (hasUnsavedChanges && modifiedLocally) || gitRepository?.cachedGitLastCommitTime || lastModifiedTimestamp, + branch: lastActiveBranch || '', + lastCommit: + hasUnsavedChanges && gitRepository?.cachedGitLastCommitTime && lastCommitAuthor ? `by ${lastCommitAuthor}` : '', + version: specVersion ? `${specVersion?.startsWith('v') ? '' : 'v'}${specVersion}` : '', + oasFormat: specFormat ? `${specFormat === 'openapi' ? 'OpenAPI' : 'Swagger'} ${specFormatVersion || ''}` : '', + mockServer, + apiSpec, + workspace, + hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges, + hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges, + gitFilePath: workspaceMeta?.gitFilePath, + }; + }); + return files; +} + +export const getAllRemoteBackendProjectsByProjectId = async ({ + teamProjectId, + organizationId, +}: { + teamProjectId: string; + organizationId: string; +}) => { + const vcs = VCSInstance(); + return vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId }); +}; + +export const getUnsyncedRemoteWorkspaces = (remoteFiles: InsomniaFile[], workspaces: Workspace[]) => + remoteFiles.filter(remoteFile => !workspaces.find(w => w._id === remoteFile.id)); + +export async function getAllRemoteFiles({ projectId, organizationId }: { projectId: string; organizationId: string }) { + try { + const project = await services.project.getById(projectId); + + const remoteId = project?.remoteId; + if (!remoteId) { + return []; + } + + console.log( + '[getAllRemoteFiles] start fetching remote backend workspaces for project', + projectId, + `remoteId: ${remoteId}`, + ); + + const vcs = VCSInstance(); + + const [allPulledBackendProjectsForRemoteId, allFetchedRemoteBackendProjectsForRemoteId] = await Promise.all([ + vcs.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), + // Remote backend projects are fetched from the backend since they are not stored locally + vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), + ]); + console.log( + `[getAllRemoteFiles] found allPulledBackendProjectsForRemoteId: ${allPulledBackendProjectsForRemoteId.length} and allFetchedRemoteBackendProjectsForRemoteId: ${allFetchedRemoteBackendProjectsForRemoteId.length} for remoteId: ${remoteId}`, + ); + // Get all workspaces that are connected to backend projects and under the current project + const workspacesWithBackendProjects = await database.find(models.workspace.type, { + _id: { + $in: [...allPulledBackendProjectsForRemoteId, ...allFetchedRemoteBackendProjectsForRemoteId].map( + p => p.rootDocumentId, + ), + }, + parentId: project._id, + }); + console.log(`[getAllRemoteFiles] found workspacesWithBackendProjects: ${workspacesWithBackendProjects.length}`); + // Get the list of remote backend projects that we need to pull + const backendProjectsToPull = allFetchedRemoteBackendProjectsForRemoteId.filter( + p => !workspacesWithBackendProjects.find(w => w._id === p.rootDocumentId), + ); + console.log(`[getAllRemoteFiles] get ${backendProjectsToPull.length} unsynced files`); + return backendProjectsToPull.map(backendProject => { + const file: InsomniaFile = { + id: backendProject.rootDocumentId, + name: backendProject.name, + scope: 'unsynced', + label: 'Unsynced', + remoteId: backendProject.id, + created: 0, + lastModifiedTimestamp: 0, + }; + + return file; + }); + } catch (e) { + console.warn('Failed to load backend projects', e); + } + + return []; +} + +/** + * Get all projects for an organization with their associated git repositories + */ +export async function getProjectsWithGitRepositories({ + organizationId, +}: { + organizationId: string; +}): Promise<(Project & { gitRepository?: GitRepository })[]> { + const projects = await database.find('Project', { + parentId: organizationId, + }); + + const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); + + const gitRepositories = await database.find('GitRepository', { + _id: { + $in: gitRepositoryIds, + }, + }); + + return projects.map(project => { + const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId); + return { + ...project, + gitRepository, + }; + }); +} diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 95d1419c9f..2bbff592f3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -16,41 +16,28 @@ import { Tooltip, TooltipTrigger, } from 'react-aria-components'; -import type { LoaderFunctionArgs } from 'react-router'; -import { href, redirect, useFetchers, useLoaderData, useParams, useRouteLoaderData } from 'react-router'; +import { useFetchers, useParams } from 'react-router'; import * as reactUse from 'react-use'; -import { logout } from '~/account/session'; -import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; import { DASHBOARD_SORT_ORDERS, type DashboardSortOrder, dashboardSortOrderName, getAppWebsiteBaseURL, } from '~/common/constants'; -import { database } from '~/common/database'; -import { scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '~/common/get-workspace-label'; -import { fuzzyMatchAll, isNotNullOrUndefined } from '~/common/misc'; -import { descendingNumberSort, sortMethodMap } from '~/common/sorting'; -import type { - ApiSpec, - GitRepository, - MockServer, - Project, - Workspace, - WorkspaceMeta, - WorkspaceScope, -} from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; +import { fuzzyMatchAll } from '~/common/misc'; +import type { InsomniaFile } from '~/common/project'; +import { sortMethodMap } from '~/common/sorting'; +import type { GitRepository, Project, WorkspaceScope } from '~/insomnia-data'; import * as models from '~/models'; -import { sortProjects } from '~/models/helpers/project'; import { isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganizationId } from '~/models/organization'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file'; +import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { SegmentEvent, trackOnceDaily } from '~/ui/analytics'; import { AvatarGroup } from '~/ui/components/avatar'; import { WorkspaceCardDropdown } from '~/ui/components/dropdowns/workspace-card-dropdown'; @@ -71,27 +58,6 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { isPrimaryClickModifier } from '~/ui/utils'; -import { invariant } from '~/utils/invariant'; - -export interface InsomniaFile { - id: string; - name: string; - remoteId?: string; - scope: WorkspaceScope | 'unsynced'; - label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; - created: number; - lastModifiedTimestamp: number; - branch?: string; - lastCommit?: string; - version?: string; - oasFormat?: string; - mockServer?: MockServer; - workspace?: Workspace; - apiSpec?: ApiSpec; - hasUncommittedChanges?: boolean; - hasUnpushedChanges?: boolean; - gitFilePath?: string | null; -} export interface ProjectLoaderData { localFiles: InsomniaFile[]; @@ -109,281 +75,9 @@ export interface ProjectLoaderData { projectsSyncStatusPromise?: Promise>; } -/** - * Get all projects for an organization with their associated git repositories - */ -export async function getProjectsWithGitRepositories({ - organizationId, -}: { - organizationId: string; -}): Promise<(Project & { gitRepository?: GitRepository })[]> { - const projects = await database.find('Project', { - parentId: organizationId, - }); - - const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); - - const gitRepositories = await database.find('GitRepository', { - _id: { - $in: gitRepositoryIds, - }, - }); - - return projects.map(project => { - const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId); - return { - ...project, - gitRepository, - }; - }); -} - -async function getAllLocalFiles({ projectId }: { projectId: string }) { - const projectWorkspaces = await services.workspace.findByParentId(projectId); - const [workspaceMetas, apiSpecs, mockServers] = await Promise.all([ - database.find(models.workspaceMeta.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - database.find(models.apiSpec.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - database.find(models.mockServer.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - ]); - - const gitRepositories = await database.find(models.gitRepository.type, { - parentId: { - $in: workspaceMetas.map(wm => wm.gitRepositoryId).filter(isNotNullOrUndefined), - }, - }); - - const files: InsomniaFile[] = projectWorkspaces.map(workspace => { - const apiSpec = apiSpecs.find(spec => spec.parentId === workspace._id); - const mockServer = mockServers.find(mock => mock.parentId === workspace._id); - let spec: ParsedApiSpec['contents'] = null; - let specFormat: ParsedApiSpec['format'] = null; - let specFormatVersion: ParsedApiSpec['formatVersion'] = null; - if (apiSpec) { - try { - const result = parseApiSpec(apiSpec.contents); - spec = result.contents; - specFormat = result.format; - specFormatVersion = result.formatVersion; - } catch { - // Assume there is no spec - // TODO: Check for parse errors if it's an invalid spec - } - } - const workspaceMeta = workspaceMetas.find(wm => wm.parentId === workspace._id); - const gitRepository = gitRepositories.find(gr => gr._id === workspaceMeta?.gitRepositoryId); - - const lastActiveBranch = gitRepository?.cachedGitRepositoryBranch; - - const lastCommitAuthor = gitRepository?.cachedGitLastAuthor; - - // WorkspaceMeta is a good proxy for last modified time - const workspaceModified = workspaceMeta?.modified || workspace.modified; - - const modifiedLocally = models.workspace.isDesign(workspace) ? apiSpec?.modified || 0 : workspaceModified; - - // Span spec, workspace and sync related timestamps for card last modified label and sort order - const lastModifiedFrom = [ - workspace?.modified, - workspaceMeta?.modified, - modifiedLocally, - gitRepository?.cachedGitLastCommitTime, - ]; - - const lastModifiedTimestamp = lastModifiedFrom.filter(isNotNullOrUndefined).sort(descendingNumberSort)[0]; - - const hasUnsavedChanges = Boolean( - models.workspace.isDesign(workspace) && - gitRepository?.cachedGitLastCommitTime && - modifiedLocally > gitRepository?.cachedGitLastCommitTime, - ); - - const specVersion = spec?.info?.version ? String(spec?.info?.version) : ''; - - return { - id: workspace._id, - name: workspace.name, - scope: workspace.scope, - label: scopeToLabelMap[workspace.scope], - created: workspace.created, - lastModifiedTimestamp: - (hasUnsavedChanges && modifiedLocally) || gitRepository?.cachedGitLastCommitTime || lastModifiedTimestamp, - branch: lastActiveBranch || '', - lastCommit: - hasUnsavedChanges && gitRepository?.cachedGitLastCommitTime && lastCommitAuthor ? `by ${lastCommitAuthor}` : '', - version: specVersion ? `${specVersion?.startsWith('v') ? '' : 'v'}${specVersion}` : '', - oasFormat: specFormat ? `${specFormat === 'openapi' ? 'OpenAPI' : 'Swagger'} ${specFormatVersion || ''}` : '', - mockServer, - apiSpec, - workspace, - hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges, - hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges, - gitFilePath: workspaceMeta?.gitFilePath, - }; - }); - return files; -} - -async function getAllRemoteFiles({ projectId, organizationId }: { projectId: string; organizationId: string }) { - try { - const project = await services.project.getById(projectId); - - const remoteId = project?.remoteId; - if (!remoteId) { - return []; - } - - console.log( - '[getAllRemoteFiles] start fetching remote backend workspaces for project', - projectId, - `remoteId: ${remoteId}`, - ); - - const vcs = VCSInstance(); - - const [allPulledBackendProjectsForRemoteId, allFetchedRemoteBackendProjectsForRemoteId] = await Promise.all([ - vcs.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), - // Remote backend projects are fetched from the backend since they are not stored locally - vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), - ]); - console.log( - `[getAllRemoteFiles] found allPulledBackendProjectsForRemoteId: ${allPulledBackendProjectsForRemoteId.length} and allFetchedRemoteBackendProjectsForRemoteId: ${allFetchedRemoteBackendProjectsForRemoteId.length} for remoteId: ${remoteId}`, - ); - // Get all workspaces that are connected to backend projects and under the current project - const workspacesWithBackendProjects = await database.find(models.workspace.type, { - _id: { - $in: [...allPulledBackendProjectsForRemoteId, ...allFetchedRemoteBackendProjectsForRemoteId].map( - p => p.rootDocumentId, - ), - }, - parentId: project._id, - }); - console.log(`[getAllRemoteFiles] found workspacesWithBackendProjects: ${workspacesWithBackendProjects.length}`); - // Get the list of remote backend projects that we need to pull - const backendProjectsToPull = allFetchedRemoteBackendProjectsForRemoteId.filter( - p => !workspacesWithBackendProjects.find(w => w._id === p.rootDocumentId), - ); - console.log(`[getAllRemoteFiles] get ${backendProjectsToPull.length} unsynced files`); - return backendProjectsToPull.map(backendProject => { - const file: InsomniaFile = { - id: backendProject.rootDocumentId, - name: backendProject.name, - scope: 'unsynced', - label: 'Unsynced', - remoteId: backendProject.id, - created: 0, - lastModifiedTimestamp: 0, - }; - - return file; - }); - } catch (e) { - console.warn('Failed to load backend projects', e); - } - - return []; -} - -const checkSingleProjectSyncStatus = async (projectId: string) => { - const projectWorkspaces = await services.workspace.findByParentId(projectId); - const workspaceMetas = await database.find(models.workspaceMeta.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }); - return workspaceMetas.some(item => item.hasUncommittedChanges || item.hasUnpushedChanges); -}; - -const CheckAllProjectSyncStatus = async (projects: Project[]) => { - const taskList = projects.map(project => checkSingleProjectSyncStatus(project._id)); - const res = await Promise.all(taskList); - const obj: Record = {}; - projects.forEach((project, index) => { - obj[project._id] = res[index]; - }); - return obj; -}; - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { organizationId, projectId } = params; - invariant(organizationId, 'Organization ID is required'); - const { id: sessionId } = await services.userSession.getOrCreate(); - - if (!projectId) { - return { - localFiles: [], - allFilesCount: 0, - documentsCount: 0, - environmentsCount: 0, - collectionsCount: 0, - mockServersCount: 0, - mcpClientsCount: 0, - projectsCount: 0, - activeProject: undefined, - projects: [], - }; - } - - if (!sessionId) { - await logout(); - throw redirect(href('/auth/login')); - } - - invariant(projectId, 'projectId parameter is required'); - - const project = await services.project.getById(projectId); - console.log('[project loader] Loading project:', project?.name, projectId); - const [localFiles, organizationProjects = []] = await Promise.all([ - getAllLocalFiles({ projectId }), - getProjectsWithGitRepositories({ organizationId }), - ]); - - const remoteFilesPromise = getAllRemoteFiles({ projectId, organizationId }); - - const projects = sortProjects(organizationProjects); - - const projectsSyncStatusPromise = CheckAllProjectSyncStatus(projects); - - const activeProjectGitRepository = - project && models.project.isGitProject(project) - ? await services.gitRepository.getById(project.gitRepositoryId || '') - : null; - - return { - localFiles, - remoteFilesPromise, - projects, - projectsCount: organizationProjects.length, - activeProject: project, - activeProjectGitRepository, - allFilesCount: localFiles.length, - environmentsCount: localFiles.filter(file => file.scope === 'environment').length, - documentsCount: localFiles.filter(file => file.scope === 'design').length, - collectionsCount: localFiles.filter(file => file.scope === 'collection').length, - mockServersCount: localFiles.filter(file => file.scope === 'mock-server').length, - mcpClientsCount: localFiles.filter(file => file.scope === 'mcp').length, - projectsSyncStatusPromise, - }; -} - -export function useProjectIndexLoaderData() { - return useRouteLoaderData('routes/organization.$organizationId.project.$projectId._index'); -} - const Component = () => { const { localFiles, activeProject, activeProjectGitRepository, projects, remoteFilesPromise } = - useLoaderData() as ProjectLoaderData; + useProjectLoaderData()!; const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx index e2e5a0750d..3dffc9aa0f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx @@ -4,6 +4,7 @@ import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; import { database } from '~/common/database'; import { scopeToLabelMap } from '~/common/get-workspace-label'; import { isNotNullOrUndefined } from '~/common/misc'; +import type { InsomniaFile } from '~/common/project'; import { descendingNumberSort } from '~/common/sorting'; import type { ApiSpec, GitRepository, MockServer, Project, WorkspaceMeta } from '~/insomnia-data'; import { services } from '~/insomnia-data'; @@ -13,7 +14,6 @@ import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.list-workspaces'; -import { type InsomniaFile } from './organization.$organizationId.project.$projectId._index'; async function getAllLocalFiles({ projectId }: { projectId: string }) { const projectWorkspaces = await services.workspace.findByParentId(projectId); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 9ff15fe1cb..c5d665823b 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -4,12 +4,18 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { href, Outlet, redirect, useParams, useRouteLoaderData } from 'react-router'; import * as reactUse from 'react-use'; +import { logout } from '~/account/session'; import { Icon } from '~/basic-components/icon'; import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; -import { database } from '~/common/database'; -import { models, type Project, services, type WorkspaceMeta } from '~/insomnia-data'; +import { + CheckAllProjectSyncStatus, + getAllLocalFiles, + getAllRemoteFiles, + getProjectsWithGitRepositories, + type InsomniaFile, +} from '~/common/project'; +import { models, services } from '~/insomnia-data'; import { sortProjects } from '~/models/helpers/project'; -import { getProjectsWithGitRepositories } from '~/routes/organization.$organizationId.project.$projectId._index'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { CloudSyncProjectBar } from '~/ui/components/dropdowns/cloud-sync-project-bar'; import { GitProjectSyncDropdown } from '~/ui/components/dropdowns/git-project-sync-dropdown'; @@ -31,26 +37,6 @@ interface LearningFeature { url: string; } -const checkSingleProjectSyncStatus = async (projectId: string) => { - const projectWorkspaces = await services.workspace.findByParentId(projectId); - const workspaceMetas = await database.find(models.workspaceMeta.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }); - return workspaceMetas.some(item => item.hasUncommittedChanges || item.hasUnpushedChanges); -}; - -const CheckAllProjectSyncStatus = async (projects: Project[]) => { - const taskList = projects.map(project => checkSingleProjectSyncStatus(project._id)); - const res = await Promise.all(taskList); - const obj: Record = {}; - projects.forEach((project, index) => { - obj[project._id] = res[index]; - }); - return obj; -}; - const getInsomniaLearningFeature = async (fallbackLearningFeature: LearningFeature) => { let learningFeature = fallbackLearningFeature; const lastFetchedString = window.localStorage.getItem('learning-feature-last-fetch'); @@ -73,8 +59,23 @@ const getInsomniaLearningFeature = async (fallbackLearningFeature: LearningFeatu export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId } = params; invariant(projectId, 'Project ID is required'); + invariant(organizationId, 'Organization ID is required'); + + if (!models.project.isScratchpadProject({ _id: projectId })) { + const { id: sessionId } = await services.userSession.getOrCreate(); + + if (!sessionId) { + await logout(); + throw redirect(href('/auth/login')); + } + } const project = await services.project.getById(projectId); + + if (!project) { + return redirect(href('/organization/:organizationId', { organizationId })); + } + const fallbackLearningFeature = { active: false, title: '', @@ -82,24 +83,37 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { cta: '', url: '', }; + console.log('[project loader] Loading project:', project?.name, projectId); - if (!project) { - return redirect(href('/organization/:organizationId', { organizationId })); - } - const organizationProjects = await getProjectsWithGitRepositories({ organizationId }); + const [localFiles, organizationProjects = []] = await Promise.all([ + getAllLocalFiles({ projectId }), + getProjectsWithGitRepositories({ organizationId }), + ]); const projects = sortProjects(organizationProjects); - const projectsSyncStatusPromise = CheckAllProjectSyncStatus(projects); + + const remoteFilesPromise = getAllRemoteFiles({ projectId, organizationId }); const learningFeaturePromise = getInsomniaLearningFeature(fallbackLearningFeature); + const projectsSyncStatusPromise = CheckAllProjectSyncStatus(projects); + const activeProjectGitRepository = project && models.project.isGitProject(project) ? await services.gitRepository.getById(project.gitRepositoryId || '') : undefined; return { - activeProject: project, + localFiles, + remoteFilesPromise, projects, + projectsCount: organizationProjects.length, + activeProject: project, activeProjectGitRepository, + allFilesCount: localFiles.length, + environmentsCount: localFiles.filter(file => file.scope === 'environment').length, + documentsCount: localFiles.filter(file => file.scope === 'design').length, + collectionsCount: localFiles.filter(file => file.scope === 'collection').length, + mockServersCount: localFiles.filter(file => file.scope === 'mock-server').length, + mcpClientsCount: localFiles.filter(file => file.scope === 'mcp').length, projectsSyncStatusPromise, learningFeaturePromise, }; @@ -110,11 +124,11 @@ export function useProjectLoaderData() { } const Component = ({ loaderData }: Route.ComponentProps) => { - const { organizationId } = useParams() as { + const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string; }; - const { activeProject, activeProjectGitRepository, learningFeaturePromise } = loaderData; + const { activeProject, activeProjectGitRepository, learningFeaturePromise, remoteFilesPromise } = loaderData; const storageRuleFetcher = useStorageRulesLoaderFetcher({ key: `storage-rule:${organizationId}` }); const [isLearningFeatureDismissed, setIsLearningFeatureDismissed] = reactUse.useLocalStorage( @@ -124,6 +138,8 @@ const Component = ({ loaderData }: Route.ComponentProps) => { const { storagePromise } = storageRuleFetcher.data || {}; const [storageRules = DEFAULT_STORAGE_RULES] = useLoaderDeferData(storagePromise, organizationId); const [learningFeature] = useLoaderDeferData(learningFeaturePromise); + const [remoteFiles] = useLoaderDeferData(remoteFilesPromise, projectId); + const { features } = useOrganizationPermissions(); const isScratchPad = models.project.isScratchpadProject(activeProject); @@ -182,7 +198,10 @@ const Component = ({ loaderData }: Route.ComponentProps) => { {activeProject && models.project.isRemoteProject(activeProject) && } - + diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx index 23e01b9960..8f1ba88e86 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx @@ -37,11 +37,11 @@ import { useParams, useSearchParams, } from 'react-router'; -import { useLocalStorage } from 'react-use'; +import * as reactUse from 'react-use'; import { DEFAULT_SIDEBAR_SIZE, getProductName, SORT_ORDERS, type SortOrder, sortOrderName } from '~/common/constants'; import { type ChangeBufferEvent } from '~/common/database'; -import { generateId, isNotNullOrUndefined } from '~/common/misc'; +import { generateId } from '~/common/misc'; import type { PlatformKeyCombinations } from '~/common/settings'; import type { Environment, @@ -76,24 +76,19 @@ import { DocumentTab } from '~/ui/components/document-tab'; import { RequestActionsDropdown } from '~/ui/components/dropdowns/request-actions-dropdown'; import { RequestGroupActionsDropdown } from '~/ui/components/dropdowns/request-group-actions-dropdown'; import { WorkspaceDropdown } from '~/ui/components/dropdowns/workspace-dropdown'; -import { WorkspaceSyncDropdown } from '~/ui/components/dropdowns/workspace-sync-dropdown'; import { EditableInput } from '~/ui/components/editable-input'; -import { EnvironmentPicker } from '~/ui/components/environment-picker'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { Icon } from '~/ui/components/icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { McpPane } from '~/ui/components/mcp/mcp-pane'; import { showModal } from '~/ui/components/modals'; import { AskModal } from '~/ui/components/modals/ask-modal'; -import { CookiesModal } from '~/ui/components/modals/cookies-modal'; import { ErrorModal } from '~/ui/components/modals/error-modal'; import { GenerateCodeModal } from '~/ui/components/modals/generate-code-modal'; import { ImportModal } from '~/ui/components/modals/import-modal/import-modal'; import { PasteCurlModal } from '~/ui/components/modals/paste-curl-modal'; import { PromptModal } from '~/ui/components/modals/prompt-modal'; import { RequestSettingsModal } from '~/ui/components/modals/request-settings-modal'; -import { CertificatesModal } from '~/ui/components/modals/workspace-certificates-modal'; -import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal'; import { GrpcRequestPane } from '~/ui/components/panes/grpc-request-pane'; import { GrpcResponsePane } from '~/ui/components/panes/grpc-response-pane'; import { PlaceholderRequestPane } from '~/ui/components/panes/placeholder-request-pane'; @@ -271,7 +266,7 @@ const Debug = () => { panel?: string; }; - const [filter, setFilter] = useLocalStorage(`${workspaceId}:collection-list-filter`); + const [filter, setFilter] = reactUse.useLocalStorage(`${workspaceId}:collection-list-filter`); const collection = useFilteredRequests(_collection, filter ?? ''); const isDesignWorkspace = models.workspace.isDesign(activeWorkspace); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx index 936ebccac6..fd5d1287af 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx @@ -5,6 +5,7 @@ import { services } from '~/insomnia-data'; import * as models from '~/models'; import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { SegmentEvent } from '~/ui/analytics'; +import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook } from '~/utils/router'; @@ -20,6 +21,8 @@ async function deleteCloudSyncWorkspace(workspace: Workspace, project: Project, await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); // For cloud sync workspaces, delete only local file or also delete remote copy await (localOnly ? vcs.removeBackendProjectsForRoot(workspace._id) : vcs.archiveProject()); + // Emit cloud sync file change event when cloud sync workspace is deleted to refresh the remote projects list cache + uiEventBus.emit(CLOUD_SYNC_FILE_CHANGE); } catch (err) { return { error: diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 8a7a16f8a0..4f77bb0b31 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -3,11 +3,11 @@ import type { LoaderFunctionArgs } from 'react-router'; import { href, redirect, useParams } from 'react-router'; import { logout } from '~/account/session'; +import { getProjectsWithGitRepositories } from '~/common/project'; import type { GitRepository, Project } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { sortProjects } from '~/models/helpers/project'; import { isScratchpadOrganizationId } from '~/models/organization'; -import { getProjectsWithGitRepositories } from '~/routes/organization.$organizationId.project.$projectId._index'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { NoProjectView } from '~/ui/components/panes/no-project-view'; diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 0adc1f2a3e..7e23bb7f68 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -39,7 +39,6 @@ interface Props { apiSpec?: ApiSpec; mockServer?: MockServer; project: Project; - projects: Project[]; } const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => { diff --git a/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx b/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx index 0e7445cecb..95e873bb80 100644 --- a/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx @@ -1,13 +1,13 @@ import { useMemo, useState } from 'react'; import { Button, GridList, GridListItem } from 'react-aria-components'; import { href, useNavigate, useParams } from 'react-router'; -import { useLocalStorage } from 'react-use'; +import * as reactUse from 'react-use'; import { scratchPadTutorialList } from '~/routes/organization.$organizationId.project.$projectId.tutorial.$panel'; import { Icon } from '~/ui/components/icon'; export const ScratchPadTutorialPanel = () => { - const [signUpTipDismissedState, setSignUpTipDismissedState] = useLocalStorage<{ + const [signUpTipDismissedState, setSignUpTipDismissedState] = reactUse.useLocalStorage<{ dismissed: boolean; dismissedAt: number; }>('scratchpad-sign-up-tip-dismissed', { dismissed: false, dismissedAt: 0 }); @@ -21,7 +21,6 @@ export const ScratchPadTutorialPanel = () => { const { organizationId, projectId, - workspaceId, panel = 'all', } = useParams() as { organizationId: string; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts new file mode 100644 index 0000000000..97ef087778 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts @@ -0,0 +1,466 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { database as db } from '~/common/database'; +import { services } from '~/insomnia-data'; + +import { + type AllRequestsAndMetaInWorkspace, + filterCollection, + flattenCollectionChildren, + getAllRequestsAndMetaByWorkspace, + getWorkspacesByProjectIds, +} from './project-navigation-sidebar-utils'; + +// ── Helpers for pure-function test fixtures ─────────────────────────────── + +type AnyDoc = AllRequestsAndMetaInWorkspace['allRequests'][number]; +type AnyMeta = AllRequestsAndMetaInWorkspace['allRequestMetas'][number]; +type FolderMeta = AllRequestsAndMetaInWorkspace['requestGroupMetas'][number]; + +const mkReq = (id: string, parentId: string, extra: Record = {}): AnyDoc => + ({ + _id: id, + type: 'Request', + parentId, + name: `${id}-name`, + url: '', + method: 'GET', + metaSortKey: 0, + isPrivate: false, + description: '', + created: 0, + modified: 0, + ...extra, + }) as unknown as AnyDoc; + +const mkFolder = (id: string, parentId: string, extra: Record = {}): AnyDoc => + ({ + _id: id, + type: 'RequestGroup', + parentId, + name: `${id}-name`, + metaSortKey: 0, + isPrivate: false, + description: '', + created: 0, + modified: 0, + ...extra, + }) as unknown as AnyDoc; + +const mkReqMeta = (parentId: string, pinned = false): AnyMeta => + ({ _id: `meta_${parentId}`, type: 'RequestMeta', parentId, pinned }) as unknown as AnyMeta; + +const mkFolderMeta = (parentId: string, collapsed = false): FolderMeta => + ({ _id: `fmeta_${parentId}`, type: 'RequestGroupMeta', parentId, collapsed }) as unknown as FolderMeta; + +// filterCollection works on Child[] – create minimal compatible objects +type ChildLike = Parameters[0][number]; + +const mkChild = ( + id: string, + name: string, + ancestors: string[] = [], + extra: { url?: string; description?: string } = {}, +): ChildLike => ({ + doc: { _id: id, type: 'Request', name, url: extra.url ?? '', description: extra.description ?? '' } as any, + hidden: false, + collapsed: false, + pinned: false, + level: 0, + ancestors, + children: [], +}); + +const mkFolderChild = ( + id: string, + name: string, + ancestors: string[] = [], + extra: { collapsed?: boolean } = {}, +): ChildLike => ({ + doc: { _id: id, type: 'RequestGroup', name, description: '' } as any, + hidden: false, + collapsed: extra.collapsed ?? false, + pinned: false, + level: 0, + ancestors, + children: [], +}); + +// ── DB-backed tests ──────────────────────────────────────────────────────── + +describe('getWorkspacesByProjectIds', () => { + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + }); + + it('returns an empty workspace list for a project with no workspaces', async () => { + const result = await getWorkspacesByProjectIds(['proj_empty']); + expect(result.get('proj_empty')).toEqual([]); + }); + + it('groups workspaces under the correct project', async () => { + await services.workspace.create({ _id: 'wrk_a', name: 'A', parentId: 'proj_1', scope: 'collection' }); + await services.workspace.create({ _id: 'wrk_b', name: 'B', parentId: 'proj_1', scope: 'design' }); + await services.workspace.create({ _id: 'wrk_c', name: 'C', parentId: 'proj_2', scope: 'collection' }); + + const result = await getWorkspacesByProjectIds(['proj_1', 'proj_2']); + + const proj1Ids = result.get('proj_1')!.map(w => w._id); + expect(proj1Ids).toHaveLength(2); + expect(proj1Ids).toEqual(expect.arrayContaining(['wrk_a', 'wrk_b'])); + + expect(result.get('proj_2')!.map(w => w._id)).toEqual(['wrk_c']); + }); + + it('does not include workspaces belonging to unqueried projects', async () => { + await services.workspace.create({ _id: 'wrk_other', name: 'Other', parentId: 'proj_other', scope: 'collection' }); + + const result = await getWorkspacesByProjectIds(['proj_1']); + + expect(result.get('proj_1')).toEqual([]); + expect(result.has('proj_other')).toBe(false); + }); + + it('returns an entry for every requested project ID even when some have no workspaces', async () => { + await services.workspace.create({ _id: 'wrk_x', name: 'X', parentId: 'proj_has_ws', scope: 'collection' }); + + const result = await getWorkspacesByProjectIds(['proj_has_ws', 'proj_no_ws']); + + expect(result.get('proj_has_ws')).toHaveLength(1); + expect(result.get('proj_no_ws')).toEqual([]); + }); +}); + +describe('getAllRequestsAndMetaByWorkspace', () => { + beforeEach(async () => { + await db.init({ inMemoryOnly: true }, true); + }); + + it('returns empty collections for a workspace with no requests', async () => { + const result = await getAllRequestsAndMetaByWorkspace(['wrk_empty']); + const data = result.get('wrk_empty')!; + + expect(data.allRequests).toHaveLength(0); + expect(data.allRequestMetas).toHaveLength(0); + expect(data.requestGroupMetas).toHaveLength(0); + }); + + it('returns requests that are direct children of the workspace', async () => { + await services.request.create({ _id: 'req_1', name: 'R1', parentId: 'wrk_1' }); + await services.request.create({ _id: 'req_2', name: 'R2', parentId: 'wrk_1' }); + + const ids = (await getAllRequestsAndMetaByWorkspace(['wrk_1'])).get('wrk_1')!.allRequests.map(r => r._id); + + expect(ids).toContain('req_1'); + expect(ids).toContain('req_2'); + }); + + it('returns requests nested inside a request group', async () => { + await services.requestGroup.create({ _id: 'fld_1', name: 'Folder', parentId: 'wrk_1' }); + await services.request.create({ _id: 'req_nested', name: 'Nested', parentId: 'fld_1' }); + + const ids = (await getAllRequestsAndMetaByWorkspace(['wrk_1'])).get('wrk_1')!.allRequests.map(r => r._id); + + expect(ids).toContain('fld_1'); + expect(ids).toContain('req_nested'); + }); + + it('traverses multiple levels of nesting', async () => { + await services.requestGroup.create({ _id: 'fld_l1', name: 'L1', parentId: 'wrk_1' }); + await services.requestGroup.create({ _id: 'fld_l2', name: 'L2', parentId: 'fld_l1' }); + await services.request.create({ _id: 'req_deep', name: 'Deep', parentId: 'fld_l2' }); + + const ids = (await getAllRequestsAndMetaByWorkspace(['wrk_1'])).get('wrk_1')!.allRequests.map(r => r._id); + + expect(ids).toContain('fld_l1'); + expect(ids).toContain('fld_l2'); + expect(ids).toContain('req_deep'); + }); + + it('does not mix requests across workspaces', async () => { + await services.request.create({ _id: 'req_a', name: 'In A', parentId: 'wrk_A' }); + await services.request.create({ _id: 'req_b', name: 'In B', parentId: 'wrk_B' }); + + const result = await getAllRequestsAndMetaByWorkspace(['wrk_A', 'wrk_B']); + + const aIds = result.get('wrk_A')!.allRequests.map(r => r._id); + const bIds = result.get('wrk_B')!.allRequests.map(r => r._id); + + expect(aIds).toContain('req_a'); + expect(aIds).not.toContain('req_b'); + expect(bIds).toContain('req_b'); + expect(bIds).not.toContain('req_a'); + }); + + it('includes request meta for requests in the workspace', async () => { + await services.request.create({ _id: 'req_pin', name: 'Pinned', parentId: 'wrk_1' }); + await services.requestMeta.create({ parentId: 'req_pin', pinned: true }); + + const data = (await getAllRequestsAndMetaByWorkspace(['wrk_1'])).get('wrk_1')!; + const meta = data.allRequestMetas.find(m => m.parentId === 'req_pin'); + + expect(meta?.pinned).toBe(true); + }); + + it('includes request group meta for folders in the workspace', async () => { + await services.requestGroup.create({ _id: 'fld_col', name: 'Collapsed', parentId: 'wrk_1' }); + await services.requestGroupMeta.create({ parentId: 'fld_col', collapsed: true }); + + const data = (await getAllRequestsAndMetaByWorkspace(['wrk_1'])).get('wrk_1')!; + const meta = data.requestGroupMetas.find(m => m.parentId === 'fld_col'); + + expect(meta?.collapsed).toBe(true); + }); +}); + +// ── Pure-function tests ──────────────────────────────────────────────────── + +describe('flattenCollectionChildren', () => { + const WS = 'wrk_test'; + + it('returns an empty array when there are no requests', () => { + const data: AllRequestsAndMetaInWorkspace = { allRequests: [], allRequestMetas: [], requestGroupMetas: [] }; + expect(flattenCollectionChildren(WS, false, data)).toEqual([]); + }); + + it('returns top-level requests when the workspace is not collapsed', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkReq('req_1', WS), mkReq('req_2', WS)], + allRequestMetas: [], + requestGroupMetas: [], + }; + const ids = flattenCollectionChildren(WS, false, data).map(c => c.doc._id); + + expect(ids).toEqual(expect.arrayContaining(['req_1', 'req_2'])); + }); + + it('marks direct workspace children hidden when the workspace is collapsed', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkReq('req_1', WS), mkFolder('fld_1', WS), mkReq('req_2', 'fld_1')], + allRequestMetas: [], + requestGroupMetas: [], + }; + const result = flattenCollectionChildren(WS, true, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + // root items are directly hidden by the collapsed workspace + expect(byId['req_1'].hidden).toBe(true); + expect(byId['fld_1'].hidden).toBe(true); + // fld_1 inherits collapsed=true from the workspace, so req_2 inside it is also hidden + expect(byId['req_2'].hidden).toBe(true); + }); + + it('hides grandchildren when a parent folder is collapsed even if the child folder has no collapsed meta', () => { + // Bug: before the fix, fld_child had collapsed=false (no meta), so req_deep got + // parentIsCollapsed=false and was incorrectly visible despite fld_parent being collapsed. + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_parent', WS), mkFolder('fld_child', 'fld_parent'), mkReq('req_deep', 'fld_child')], + allRequestMetas: [], + requestGroupMetas: [mkFolderMeta('fld_parent', true)], + }; + const result = flattenCollectionChildren(WS, false, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['fld_parent'].collapsed).toBe(true); + expect(byId['fld_parent'].hidden).toBe(false); + expect(byId['fld_child'].hidden).toBe(true); + expect(byId['fld_child'].collapsed).toBe(true); + expect(byId['req_deep'].hidden).toBe(true); + }); + + it('places a folder before its children in the flat list', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkReq('req_1', 'fld_1')], + allRequestMetas: [], + requestGroupMetas: [], + }; + const ids = flattenCollectionChildren(WS, false, data).map(c => c.doc._id); + + expect(ids.indexOf('fld_1')).toBeLessThan(ids.indexOf('req_1')); + }); + + it('assigns correct nesting levels for each depth', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkFolder('fld_2', 'fld_1'), mkReq('req_1', 'fld_2')], + allRequestMetas: [], + requestGroupMetas: [], + }; + const result = flattenCollectionChildren(WS, false, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['fld_1'].level).toBe(0); + expect(byId['fld_2'].level).toBe(1); + expect(byId['req_1'].level).toBe(2); + }); + + it('populates the ancestors array for nested items', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkFolder('fld_2', 'fld_1'), mkReq('req_1', 'fld_2')], + allRequestMetas: [], + requestGroupMetas: [], + }; + const result = flattenCollectionChildren(WS, false, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['req_1'].ancestors).toEqual(expect.arrayContaining(['fld_1', 'fld_2'])); + expect(byId['fld_2'].ancestors).toContain('fld_1'); + expect(byId['fld_1'].ancestors).toEqual([]); + }); + + it('hides children of a collapsed folder', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkReq('req_1', 'fld_1'), mkReq('req_2', 'fld_1')], + allRequestMetas: [], + requestGroupMetas: [mkFolderMeta('fld_1', true)], + }; + const result = flattenCollectionChildren(WS, false, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['fld_1'].collapsed).toBe(true); + expect(byId['fld_1'].hidden).toBe(false); + expect(byId['req_1'].hidden).toBe(true); + expect(byId['req_2'].hidden).toBe(true); + }); + + it('does not hide children of an expanded folder', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkReq('req_1', 'fld_1')], + allRequestMetas: [], + requestGroupMetas: [mkFolderMeta('fld_1', false)], + }; + const result = flattenCollectionChildren(WS, false, data); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['fld_1'].collapsed).toBe(false); + expect(byId['req_1'].hidden).toBe(false); + }); + + it('marks a request as pinned when its meta has pinned=true', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkReq('req_pinned', WS)], + allRequestMetas: [mkReqMeta('req_pinned', true)], + requestGroupMetas: [], + }; + const [item] = flattenCollectionChildren(WS, false, data); + + expect(item.pinned).toBe(true); + }); + + it('never marks a request group as pinned', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS)], + allRequestMetas: [], + requestGroupMetas: [], + }; + const [item] = flattenCollectionChildren(WS, false, data); + + expect(item.pinned).toBe(false); + }); + + it('populates the children array for request groups', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [mkFolder('fld_1', WS), mkReq('req_1', 'fld_1'), mkReq('req_2', 'fld_1')], + allRequestMetas: [], + requestGroupMetas: [], + }; + const result = flattenCollectionChildren(WS, false, data); + const folder = result.find(c => c.doc._id === 'fld_1')!; + + expect(folder.children.map(c => c.doc._id)).toEqual(expect.arrayContaining(['req_1', 'req_2'])); + }); + + it('sorts folder children by metaSortKey ascending', () => { + const data: AllRequestsAndMetaInWorkspace = { + allRequests: [ + mkFolder('fld_1', WS), + mkReq('req_last', 'fld_1', { metaSortKey: 300 }), + mkReq('req_first', 'fld_1', { metaSortKey: 100 }), + mkReq('req_mid', 'fld_1', { metaSortKey: 200 }), + ], + allRequestMetas: [], + requestGroupMetas: [], + }; + const result = flattenCollectionChildren(WS, false, data); + const childIds = result.filter(c => c.doc.parentId === 'fld_1').map(c => c.doc._id); + + expect(childIds).toEqual(['req_first', 'req_mid', 'req_last']); + }); +}); + +// ── filterCollection ─────────────────────────────────────────────────────── + +describe('filterCollection', () => { + it('returns the collection unchanged (same reference) when filter is empty', () => { + const collection = [mkChild('req_1', 'Get User'), mkChild('req_2', 'Post User')]; + expect(filterCollection(collection, '')).toBe(collection); + }); + + it('hides items whose name does not match the filter', () => { + const collection = [mkChild('req_1', 'Get User'), mkChild('req_2', 'Post User')]; + const result = filterCollection(collection, 'Get'); + + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + expect(byId['req_1'].hidden).toBe(false); + expect(byId['req_2'].hidden).toBe(true); + }); + + it('hides all items when nothing matches', () => { + const collection = [mkChild('req_1', 'Get User'), mkChild('req_2', 'Post User')]; + expect(filterCollection(collection, 'zzz_no_match').every(c => c.hidden)).toBe(true); + }); + + it('reveals an ancestor folder when a descendant matches', () => { + const collection = [mkFolderChild('fld_1', 'Auth Folder'), mkChild('req_1', 'Login Request', ['fld_1'])]; + const result = filterCollection(collection, 'Login'); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['req_1'].hidden).toBe(false); + expect(byId['fld_1'].hidden).toBe(false); + }); + + it('matches against the description field', () => { + const collection = [ + mkChild('req_1', 'Untitled', [], { description: 'creates a new user account' }), + mkChild('req_2', 'Other', [], { description: 'fetches a list of posts' }), + ]; + const result = filterCollection(collection, 'user account'); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['req_1'].hidden).toBe(false); + expect(byId['req_2'].hidden).toBe(true); + }); + + it('matches against the URL field for request items', () => { + const collection = [ + mkChild('req_1', 'Untitled', [], { url: 'https://api.example.com/users' }), + mkChild('req_2', 'Untitled', [], { url: 'https://api.example.com/posts' }), + ]; + const result = filterCollection(collection, '/users'); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + expect(byId['req_1'].hidden).toBe(false); + expect(byId['req_2'].hidden).toBe(true); + }); + + it('does not match folder items against the URL field', () => { + // Folders have no URL — filtering should only check name/description for them + const collection = [mkFolderChild('fld_url', '/special-path'), mkFolderChild('fld_other', 'Other Folder')]; + const result = filterCollection(collection, '/special-path'); + const byId = Object.fromEntries(result.map(c => [c.doc._id, c])); + + // Matches on name (not URL), so this should still be visible + expect(byId['fld_url'].hidden).toBe(false); + expect(byId['fld_other'].hidden).toBe(true); + }); + + it('sets collapsed to false on every item regardless of match', () => { + const collection = [ + mkFolderChild('fld_1', 'Auth Folder', [], { collapsed: true }), + mkChild('req_1', 'Login Request', ['fld_1']), + mkChild('req_2', 'Unrelated Request'), + ]; + const result = filterCollection(collection, 'Login'); + + expect(result.every(c => c.collapsed === false)).toBe(true); + }); +}); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts index cc49fea4be..72697436d1 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.ts @@ -196,7 +196,9 @@ export function flattenCollectionChildren( const hidden = parentIsCollapsed; const pinned = (!isRequestGroup(doc) && allRequestMetas.find(m => m.parentId === doc._id)?.pinned) || false; const collapsed = - (isRequestGroup(doc) && (requestGroupMetas.find(m => m.parentId === doc._id)?.collapsed ?? false)) || false; + parentIsCollapsed || + (isRequestGroup(doc) && (requestGroupMetas.find(m => m.parentId === doc._id)?.collapsed ?? false)) || + false; collection.push({ doc, pinned, collapsed, hidden, level, ancestors, children: [] }); @@ -255,7 +257,7 @@ export function filterCollection(collection: Child[], filter: string): Child[] { // Common tailwind classes export const ROW_CLASS = - 'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-1'; + 'relative flex h-(--line-height-xs) w-full items-center gap-1 overflow-hidden text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font) pr-4'; export const ACTIVE_BORDER_CLASS = 'absolute top-0 left-0 h-full w-0.5 bg-transparent transition-colors group-aria-selected:bg-(--color-surprise)'; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 2d4d9ed52f..570360c2a4 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -6,6 +6,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; import { fuzzyMatchAll } from '~/common/misc'; +import { + getAllRemoteBackendProjectsByProjectId, + getUnsyncedRemoteWorkspaces, + type InsomniaFile, +} from '~/common/project'; import type { Workspace } from '~/insomnia-data'; import { models, services } from '~/insomnia-data'; import type { SyncResult } from '~/konnect/sync'; @@ -15,7 +20,9 @@ import { showModal } from '~/ui/components/modals'; import { AlertModal } from '~/ui/components/modals/alert-modal'; import { AskModal } from '~/ui/components/modals/ask-modal'; import { ProjectModal } from '~/ui/components/modals/project-modal'; +import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; +import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; @@ -74,13 +81,7 @@ function showSkippedRoutesModal(result: SyncResult | null) { export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: ProjectNavigationSidebarProps) => { const navigate = useNavigate(); - const { - organizationId, - projectId: activeProjectId, - workspaceId: activeWorkspaceId, - requestId: activeRequestId, - requestGroupId: activeRequestGroupId, - } = useParams() as { + const { organizationId, projectId: activeProjectId } = useParams() as { organizationId: string; projectId?: string; workspaceId?: string; @@ -99,6 +100,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const tabNavigate = useTabNavigate(); const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false); + const [unsyncedFilesByProjectId, setUnsyncedFilesByProjectId] = useState>(new Map()); const [projectNavigationSidebarFilter, setProjectNavigationSidebarFilter] = reactUse.useLocalStorage( `${organizationId}:project-navigation-sidebar-filter`, '', @@ -130,6 +132,8 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const cachedWorkspacesRef = useRef>(new Map()); // ref to cache queried collection children (request & requestGroups) data and meta by workspace id const cachedCollectionChildrenAndMetaRef = useRef>(new Map()); + // ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests + const isFetchingUnsyncedFilesRef = useRef(false); const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID; @@ -160,6 +164,57 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P [projects, isProjectTabActive, presence, checkAllProjectSyncStatus, userSession.accountId], ); + const cloudSyncProjects = useMemo(() => projects.filter(p => models.project.isRemoteProject(p)), [projects]); + // Generate a stable string key to trigger getOrFetchUnsyncedFiles when the list of cloud sync projects changes. + const cloudSyncProjectIdsKey = useMemo( + () => + cloudSyncProjects + .map(p => p._id) + .sort() + .join(','), + [cloudSyncProjects], + ); + + const getAllRemoteFilesByProjectId = useCallback(async () => { + if (!cloudSyncProjectIdsKey) return new Map(); + // Avoid duplicate fetch + if (isFetchingUnsyncedFilesRef.current) { + return; + } + const cloudSyncProjectIds = cloudSyncProjectIdsKey.split(','); + const result = new Map(); + isFetchingUnsyncedFilesRef.current = true; + for (const projectId of cloudSyncProjectIds) { + try { + const targetProject = await services.project.getById(projectId); + if (targetProject && 'remoteId' in targetProject && targetProject.remoteId) { + const files = await getAllRemoteBackendProjectsByProjectId({ + teamProjectId: targetProject.remoteId, + organizationId, + }); + result.set( + projectId, + files.map(f => ({ + id: f.rootDocumentId, + name: f.name, + scope: 'unsynced', + label: 'Unsynced', + remoteId: f.id, + created: 0, + lastModifiedTimestamp: 0, + })), + ); + } + } catch (error) { + console.error(`Failed to fetch unsynced files for project ${projectId}`, error); + result.set(projectId, []); + } finally { + isFetchingUnsyncedFilesRef.current = false; + } + } + return setUnsyncedFilesByProjectId(result); + }, [organizationId, cloudSyncProjectIdsKey]); + const handleSync = async () => { if (!konnectSyncEnabled) { return; @@ -205,6 +260,16 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P } }; + useEffect(() => { + getAllRemoteFilesByProjectId(); + const updateUnsyncedFiles = () => { + getAllRemoteFilesByProjectId(); + }; + // Subscribe to changes in unsynced workspace files to keep the sidebar up to date. + // The subscribe is triggered in insomnia-event-stream-context when cloud sync files is changed remotely + return uiEventBus.on(CLOUD_SYNC_FILE_CHANGE, updateUnsyncedFiles); + }, [getAllRemoteFilesByProjectId, organizationId]); + useEffect(() => { // clear caches on any router data change to avoid showing stale data cachedWorkspacesRef.current.clear(); @@ -267,62 +332,82 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P const workspaces = workspacesByProject.get(projectId) || []; // TODO workspace sort const sortedWorkspaces = [...workspaces].sort((a, b) => a.name.localeCompare(b.name)); + const unsyncedWorkspaces = models.project.isRemoteProject(project) + ? getUnsyncedRemoteWorkspaces(unsyncedFilesByProjectId.get(projectId) || [], sortedWorkspaces) + : []; - for (const workspace of sortedWorkspaces) { - const { scope, _id: workspaceId } = workspace; - const isCollection = scope === 'collection'; - // Only collection workspace has nested children - const isWorkspaceCollapsed = !(isCollection && (expandedProjectAndWorkspaceIds ?? []).includes(workspaceId)); - - items.push({ - kind: 'workspace', - organizationId, - project: project, - doc: workspace, - collapsed: isWorkspaceCollapsed, - hidden: isProjectCollapsed, - }); - - const allRequestsAndMetaInWorkspace = collectionChildrenAndMetaByWorkspaceId.get(workspaceId); - // build collection children if it's a collection workspace and parent workspace and project are not collapsed or there is an active filter - const shouldHideCollectionChildren = isWorkspaceCollapsed || isProjectCollapsed; - let collectionChildren = - (!shouldHideCollectionChildren || !!projectNavigationSidebarFilter) && allRequestsAndMetaInWorkspace - ? flattenCollectionChildren(workspaceId, shouldHideCollectionChildren, allRequestsAndMetaInWorkspace) - : []; - - if (projectNavigationSidebarFilter) { - // apply filter to collection children first - collectionChildren = filterCollection(collectionChildren, projectNavigationSidebarFilter); - const collectionChildMatchesFilter = collectionChildren.some(child => !child.hidden); - const workspaceMatchesFilter = Boolean( - fuzzyMatchAll( - projectNavigationSidebarFilter.toLowerCase(), - // Todo: support remote files (cloud sync) in filter - [workspace.name?.toLowerCase() || ''], - { splitSpace: true, loose: true }, - )?.indexes, - ); - const shouldHide = !collectionChildMatchesFilter && !workspaceMatchesFilter; - // If workspace or any of its collection child matches the filter, show the workspace; otherwise hide - items.find(i => i.kind === 'workspace' && i.doc._id === workspaceId)!.hidden = shouldHide; - } - - collectionChildren.forEach(child => { + for (const workspace of [...sortedWorkspaces, ...unsyncedWorkspaces]) { + if (workspace.scope === 'unsynced') { items.push({ - kind: 'collectionChild', + kind: 'unsyncedWorkspace', organizationId, project: project, - workspace: workspace, - children: child.children, - ancestors: child.ancestors, - doc: child.doc, - collapsed: child.collapsed, - hidden: child.hidden, - level: child.level, - pinned: child.pinned, + doc: { + // ensure _id is present in doc for unsynced workspace flat item + _id: workspace.id, + ...workspace, + }, + collapsed: false, + hidden: isProjectCollapsed, }); - }); + } else { + const { scope, _id: workspaceId } = workspace as Workspace; + const isCollection = scope === 'collection'; + // Only collection workspace has nested children + const isWorkspaceCollapsed = !( + isCollection && (expandedProjectAndWorkspaceIds ?? []).includes(workspaceId) + ); + + items.push({ + kind: 'workspace', + organizationId, + project: project, + doc: workspace as Workspace, + collapsed: isWorkspaceCollapsed, + hidden: isProjectCollapsed, + }); + + const allRequestsAndMetaInWorkspace = collectionChildrenAndMetaByWorkspaceId.get(workspaceId); + // build collection children if it's a collection workspace and parent workspace and project are not collapsed or there is an active filter + const shouldHideCollectionChildren = isWorkspaceCollapsed || isProjectCollapsed; + let collectionChildren = + (!shouldHideCollectionChildren || !!projectNavigationSidebarFilter) && allRequestsAndMetaInWorkspace + ? flattenCollectionChildren(workspaceId, shouldHideCollectionChildren, allRequestsAndMetaInWorkspace) + : []; + + if (projectNavigationSidebarFilter) { + // apply filter to collection children first + collectionChildren = filterCollection(collectionChildren, projectNavigationSidebarFilter); + const collectionChildMatchesFilter = collectionChildren.some(child => !child.hidden); + const workspaceMatchesFilter = Boolean( + fuzzyMatchAll( + projectNavigationSidebarFilter.toLowerCase(), + // Todo: support remote files (cloud sync) in filter + [workspace.name?.toLowerCase() || ''], + { splitSpace: true, loose: true }, + )?.indexes, + ); + const shouldHide = !collectionChildMatchesFilter && !workspaceMatchesFilter; + // If workspace or any of its collection child matches the filter, show the workspace; otherwise hide + items.find(i => i.kind === 'workspace' && i.doc._id === workspaceId)!.hidden = shouldHide; + } + + collectionChildren.forEach(child => { + items.push({ + kind: 'collectionChild', + organizationId, + project: project, + workspace: workspace as Workspace, + children: child.children, + ancestors: child.ancestors, + doc: child.doc, + collapsed: child.collapsed, + hidden: child.hidden, + level: child.level, + pinned: child.pinned, + }); + }); + } } // If project or any of its descendant workspace/collection child matches the filter, show the project; otherwise hide @@ -352,6 +437,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P organizationId, projectNavigationSidebarFilter, projectsWithPresence, + unsyncedFilesByProjectId, ]); const toggleProjectOrWorkspace = useCallback( @@ -368,6 +454,18 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P [expandedProjectAndWorkspaceIds, projectNavigationSidebarFilter, setExpandedProjectAndWorkspaceIds], ); + const expandProjectOrWorkspaces = useCallback( + (projectOrWorkspaceIds: string[]) => { + // Do not update toggle state if there is an active filter + if (!projectNavigationSidebarFilter) { + const expandedIds = expandedProjectAndWorkspaceIds || []; + const newExpandedIds = Array.from(new Set([...expandedIds, ...projectOrWorkspaceIds])); + setExpandedProjectAndWorkspaceIds(newExpandedIds); + } + }, + [expandedProjectAndWorkspaceIds, projectNavigationSidebarFilter, setExpandedProjectAndWorkspaceIds], + ); + const toggleRequestGroups = useCallback( async (requestGroupIds: string[], collapsed?: boolean) => { if (requestGroupIds.length === 0) { @@ -458,8 +556,9 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P virtualizer, }); const { selectedItemId, routeInfo } = useProjectNavigationSidebarNavigation({ - setExpandedProjectAndWorkspaceIds, + setActiveTab, toggleRequestGroups, + expandProjectOrWorkspaces, visibleFlatItems, virtualizer, }); @@ -518,7 +617,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P ) : syncing ? ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts index b607948566..f514335713 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts @@ -25,13 +25,15 @@ const getSelectedItemId = (resources?: NavigationResources) => { }; export const useProjectNavigationSidebarNavigation = ({ - setExpandedProjectAndWorkspaceIds, + setActiveTab, toggleRequestGroups, + expandProjectOrWorkspaces, visibleFlatItems, virtualizer, }: { - setExpandedProjectAndWorkspaceIds: Dispatch>; + setActiveTab: Dispatch>; toggleRequestGroups: (requestGroupIds: string[], collapsed?: boolean) => Promise; + expandProjectOrWorkspaces: (ids: string[]) => void; visibleFlatItems: FlatItem[]; virtualizer: Virtualizer; }) => { @@ -69,6 +71,9 @@ export const useProjectNavigationSidebarNavigation = ({ return; } + // update active tab + setActiveTab(resources.project.konnectControlPlaneId != null ? 'konnect' : 'projects'); + const idsToExpand = [resources.project._id]; if (resources.workspace && models.workspace.isCollection(resources.workspace)) { idsToExpand.push(resources.workspace._id); @@ -96,18 +101,13 @@ export const useProjectNavigationSidebarNavigation = ({ } } - setExpandedProjectAndWorkspaceIds(previousExpandedIds => { - const expandedIds = previousExpandedIds || []; - const missingIds = idsToExpand.filter(id => !expandedIds.includes(id)); - - return missingIds.length > 0 ? [...expandedIds, ...missingIds] : expandedIds; - }); + expandProjectOrWorkspaces([...idsToExpand]); }); return () => { cancelled = true; }; - }, [getNavigationResources, navigationKey, routeInfo, setExpandedProjectAndWorkspaceIds, toggleRequestGroups]); + }, [expandProjectOrWorkspaces, getNavigationResources, navigationKey, routeInfo, setActiveTab, toggleRequestGroups]); useEffect(() => { if (!selectedItemId) { diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index d5db82d803..db91ba2a1a 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -21,6 +21,7 @@ interface WorkspaceNodeProps { export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => { const { doc, collapsed, project, organizationId } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; + const isCollection = workspaceScope === 'collection'; return (
@@ -28,12 +29,10 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
diff --git a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx index 0705f8bd00..55a64defa3 100644 --- a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx +++ b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx @@ -6,13 +6,14 @@ import * as reactUse from 'react-use'; import { CDN_INVALIDATION_TTL } from '~/common/constants'; import { useRootLoaderData } from '~/root'; import { useClearVaultKeyFetcher } from '~/routes/auth.clear-vault-key'; -import { useProjectIndexLoaderData } from '~/routes/organization.$organizationId.project.$projectId._index'; +import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useInsomniaSyncDataActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data'; import { useStorageRulesActionFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { useOrganizationSyncProjectsActionFetcher } from '~/routes/organization.$organizationId.sync-projects'; import { useOrganizationSyncActionFetcher } from '~/routes/organization.sync'; import { VCSInstance } from '~/sync/vcs/insomnia-sync'; +import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { avatarImageCache } from '~/ui/hooks/image-cache'; const InsomniaEventStreamContext = createContext<{ @@ -93,7 +94,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) }; const { userSession } = useRootLoaderData()!; - const projectData = useProjectIndexLoaderData(); + const projectData = useProjectLoaderData(); const workspaceData = useWorkspaceLoaderData(); const remoteId = projectData?.activeProject?.remoteId || workspaceData?.activeProject.remoteId; @@ -154,6 +155,10 @@ export const InsomniaEventStreamProvider: FC = ({ children }) | BranchDeletedEvent | FileChangedEvent | VaultKeyChangeEvent; + if (event.type === 'FileChanged' || event.type === 'FileDeleted') { + // Emit unsynced workspace file change when file changed or deleted event received, so that the workspace list can be updated if needed + uiEventBus.emit(CLOUD_SYNC_FILE_CHANGE); + } if (event.type === 'PresentUserLeave') { setPresence(prev => prev.filter(p => { diff --git a/packages/insomnia/src/ui/event-bus.ts b/packages/insomnia/src/ui/event-bus.ts index 5d34edb59b..87e4a464f8 100644 --- a/packages/insomnia/src/ui/event-bus.ts +++ b/packages/insomnia/src/ui/event-bus.ts @@ -1,21 +1,29 @@ type EventHandler = (...args: any[]) => void; export const OAUTH2_AUTHORIZATION_STATUS_CHANGE = 'OAUTH2_AUTHORIZATION_STATUS_CHANGE'; +// This event is emitted when remote cloud sync file is changed, including project, workspace creation, deletion and update. +export const CLOUD_SYNC_FILE_CHANGE = 'CLOUD_SYNC_FILE_CHANGE'; -type UIEventType = 'CLOSE_TAB' | 'CHANGE_ACTIVE_ENV' | typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE; +type UIEventType = + | 'CLOSE_TAB' + | 'CHANGE_ACTIVE_ENV' + | typeof CLOUD_SYNC_FILE_CHANGE + | typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE; class EventBus { private events: Record = { CLOSE_TAB: [], CHANGE_ACTIVE_ENV: [], + [CLOUD_SYNC_FILE_CHANGE]: [], [OAUTH2_AUTHORIZATION_STATUS_CHANGE]: [], }; - // Subscribe to event - on(event: UIEventType, handler: EventHandler): void { + // Subscribe to event, returns unsubscribe function + on(event: UIEventType, handler: EventHandler): () => void { if (!this.events[event]) { this.events[event] = []; } this.events[event].push(handler); + return () => this.off(event, handler); } // Unsubscribe from event diff --git a/packages/insomnia/src/ui/hooks/use-vcs-version.ts b/packages/insomnia/src/ui/hooks/use-vcs-version.ts index 561c28c5b9..2a46794b9f 100644 --- a/packages/insomnia/src/ui/hooks/use-vcs-version.ts +++ b/packages/insomnia/src/ui/hooks/use-vcs-version.ts @@ -1,11 +1,12 @@ -import { useProjectIndexLoaderData } from '../../routes/organization.$organizationId.project.$projectId._index'; +import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; + import { useWorkspaceLoaderData } from '../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; // We use this hook to determine if the active workspace has been updated from the Git VCS // For example, by pulling a new version from the remote, switching branches, etc. export function useGitVCSVersion() { const workspaceData = useWorkspaceLoaderData(); - const projectData = useProjectIndexLoaderData(); + const projectData = useProjectLoaderData(); const gitRepository = workspaceData?.gitRepository || projectData?.activeProjectGitRepository; return `${gitRepository?.cachedGitLastCommitTime}:${gitRepository?.cachedGitRepositoryBranch}`;