From fa960e1353e8142c1eda1fa017e05036a002f488 Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 16 Apr 2026 22:26:39 +0800 Subject: [PATCH] refine loader behavior 1.support cloud sync project p1 add nav support for unsynced workspace fix issues and support konnect tab switch fix expand issues fix the sidebar issue fix the failure test --- packages/insomnia/src/common/project.ts | 258 ++++++++++ ...ganizationId.project.$projectId._index.tsx | 322 +----------- ...nId.project.$projectId.list-workspaces.tsx | 2 +- ...ion.$organizationId.project.$projectId.tsx | 83 ++-- ...projectId.workspace.$workspaceId.debug.tsx | 11 +- ...Id.project.$projectId.workspace.delete.tsx | 3 + ...ization.$organizationId.project._index.tsx | 2 +- .../dropdowns/workspace-card-dropdown.tsx | 1 - .../panes/scratchpad-tutorial-pane.tsx | 5 +- .../project-navigation-sidebar-utils.test.ts | 466 ++++++++++++++++++ .../project-navigation-sidebar-utils.ts | 6 +- .../project-navigation-sidebar.tsx | 225 ++++++--- .../request-node.tsx | 11 +- .../project-navigation-sidebar/types.ts | 12 +- .../unsynced-workspace-node.tsx | 64 +++ ...e-project-navigation-sidebar-navigation.ts | 18 +- .../workspace-node.tsx | 7 +- .../app/insomnia-event-stream-context.tsx | 9 +- packages/insomnia/src/ui/event-bus.ts | 14 +- .../insomnia/src/ui/hooks/use-vcs-version.ts | 5 +- 20 files changed, 1069 insertions(+), 455 deletions(-) create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar-utils.test.ts create mode 100644 packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node.tsx 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}`;